diff --git a/.claude/settings.json b/.claude/settings.json
deleted file mode 100644
index fb6efa505..000000000
--- a/.claude/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "permissions": {}
-}
diff --git a/tools/check_guide.py b/.github/scripts/check_guide.py
similarity index 98%
rename from tools/check_guide.py
rename to .github/scripts/check_guide.py
index b74217e38..69c2eff74 100644
--- a/tools/check_guide.py
+++ b/.github/scripts/check_guide.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Checks the bilingual GitHub Pages guide in docs/guide/.
- python3 tools/check_guide.py [guide-dir]
+ python3 .github/scripts/check_guide.py [guide-dir]
Fails (exit 1) when:
1. an HTML file does not parse into balanced elements;
@@ -153,7 +153,7 @@ def check(guide):
def main():
here = os.path.dirname(os.path.abspath(__file__))
- guide = sys.argv[1] if len(sys.argv) > 1 else os.path.join(here, "..", "docs", "guide")
+ guide = sys.argv[1] if len(sys.argv) > 1 else os.path.join(here, "..", "..", "docs", "guide")
failures = check(guide)
for f in failures:
print("FAIL", f)
diff --git a/.github/scripts/ft-mobile/build-android.sh b/.github/scripts/ft-mobile/build-android.sh
new file mode 100755
index 000000000..cc5ced80d
--- /dev/null
+++ b/.github/scripts/ft-mobile/build-android.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# Build free-threaded CPython (--disable-gil) for aarch64-linux-android with
+# CPython's own tooling (Platforms/Android). Usage: build-android.sh [VERSION]
+# Needs ANDROID_HOME (SDK with cmdline-tools/latest) and a JDK 17, 21 or 25.
+set -euo pipefail
+here="$(cd "$(dirname "$0")" && pwd)"
+# shellcheck source=common.sh
+source "$here/common.sh"
+
+PY_VERSION="${1:-3.15.0}"
+HOST=aarch64-linux-android
+: "${ANDROID_HOME:?ANDROID_HOME must point at the Android SDK}"
+
+src="$(fetch_source "$PY_VERSION")"
+cd "$src"
+
+# `Platforms/Android package` lists the files to ship with `git ls-files`, so
+# the unpacked tarball needs an index that tracks Platforms/Android.
+if [ ! -d .git ]; then
+ git init -q .
+ git add Platforms/Android
+fi
+
+# Install the NDK version the tooling pins (android-env.sh would do it on first
+# use; doing it here makes a missing cmdline-tools fail early and visibly).
+ndk_version="$(sed -n 's/^ndk_version=//p' Platforms/Android/android-env.sh)"
+[ -n "$ndk_version" ] || die "no ndk_version in Platforms/Android/android-env.sh"
+log "NDK required by CPython $PY_VERSION: $ndk_version"
+if [ ! -e "$ANDROID_HOME/ndk/$ndk_version/package.xml" ]; then
+ yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$ndk_version" >/dev/null || true
+fi
+[ -e "$ANDROID_HOME/ndk/$ndk_version/package.xml" ] || die "NDK $ndk_version not installed"
+
+# --disable-gil goes to configure after `--`, for the build Python and the host.
+python3 Platforms/Android configure-build -- --disable-gil
+python3 Platforms/Android make-build
+python3 Platforms/Android pythoninfo-build
+python3 Platforms/Android configure-host "$HOST" -- --disable-gil
+python3 Platforms/Android make-host "$HOST"
+python3 Platforms/Android package "$HOST"
+
+# ---- verification -------------------------------------------------------
+minor="$(py_minor "$PY_VERSION")"
+archive="$(ls cross-build/$HOST/dist/python-*-$HOST.tar.gz)"
+log "package: $archive"
+
+# The build Python must itself be free-threaded.
+cross-build/build/python -c 'import sys, sysconfig; assert sysconfig.get_config_var("Py_GIL_DISABLED") == 1; print("build python free-threaded:", sys.version)'
+
+check="$FT_WORK/android-check"
+rm -rf "$check"; mkdir -p "$check"
+tar -xzf "$archive" -C "$check"
+
+require_gil_disabled "$check/prefix/include/python${minor}t/pyconfig.h"
+lib="$(ls "$check"/prefix/lib/libpython${minor}t*.so* 2>/dev/null | head -n 1 || true)"
+[ -n "$lib" ] || die "no libpython${minor}t*.so in the package"
+log "ok: $lib"
+file "$lib" | tee /dev/stderr | grep -q 'ARM aarch64' || die "$lib is not an aarch64 ELF"
+[ -d "$check/prefix/lib/python${minor}t" ] || die "no lib/python${minor}t in the package"
+
+cp "$archive" "$FT_OUT/"
+echo "Android archive:" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
+record_sha256 "$FT_OUT/$(basename "$archive")"
diff --git a/.github/scripts/ft-mobile/build-ios.sh b/.github/scripts/ft-mobile/build-ios.sh
new file mode 100755
index 000000000..f4e16966e
--- /dev/null
+++ b/.github/scripts/ft-mobile/build-ios.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# Build free-threaded CPython (--disable-gil) as an iOS XCframework with
+# CPython's own tooling (Platforms/Apple). Usage: build-ios.sh [VERSION]
+# Needs macOS with a full Xcode and python3 >= 3.10.
+set -euo pipefail
+here="$(cd "$(dirname "$0")" && pwd)"
+# shellcheck source=common.sh
+source "$here/common.sh"
+
+PY_VERSION="${1:-3.15.0}"
+# The XCframework needs every slice Platforms/Apple declares: one device host
+# and two simulator hosts (arm64, x86_64).
+HOSTS=(arm64-apple-ios arm64-apple-ios-simulator x86_64-apple-ios-simulator)
+
+src="$(fetch_source "$PY_VERSION")"
+cd "$src"
+xcodebuild -version
+
+# Platforms/Apple names the standard library directory "python"
+# when it packages and in the Xcode build helper, but a free-threaded build
+# installs "pythont" (LIBDEST has $(ABI_THREAD)). Without these two
+# edits `package` fails copying the xcprivacy files and the helper script would
+# not find the standard library. Each edit is asserted so a changed upstream
+# script fails loudly instead of silently skipping the patch.
+python3 -I - <<'PY'
+import pathlib
+def patch(path, old, new):
+ p = pathlib.Path(path)
+ s = p.read_text()
+ assert s.count(old) == 1, f"{path}: expected exactly one {old!r}"
+ p.write_text(s.replace(old, new))
+patch("Platforms/Apple/__main__.py",
+ 'version_tag = ".".join(version.split(".")[:2])',
+ 'version_tag = ".".join(version.split(".")[:2]) + "t"')
+patch("Platforms/Apple/testbed/Python.xcframework/build/utils.sh",
+ 'grep -E "^python3\\.\\d+$"',
+ 'grep -E "^python3\\.[0-9]+t?$"')
+PY
+
+# --disable-gil goes to configure after `--`, for the build Python and each host.
+python3 Platforms/Apple configure-build -- --disable-gil
+python3 Platforms/Apple make-build
+python3 Platforms/Apple pythoninfo-build
+for host in "${HOSTS[@]}"; do
+ python3 Platforms/Apple configure-host iOS "$host" -- --disable-gil
+ python3 Platforms/Apple make-host iOS "$host"
+done
+python3 Platforms/Apple package iOS
+
+# ---- verification -------------------------------------------------------
+minor="$(py_minor "$PY_VERSION")"
+archive="$(ls cross-build/dist/python-*-iOS-XCframework.tar.gz)"
+log "package: $archive"
+
+# On macOS the build interpreter is python.exe (cross-build/build/python is a directory).
+build_py=cross-build/build/python.exe; [ -x "$build_py" ] || build_py=cross-build/build/python
+"$build_py" -c 'import sys, sysconfig; assert sysconfig.get_config_var("Py_GIL_DISABLED") == 1; print("build python free-threaded:", sys.version)'
+
+check="$FT_WORK/ios-check"
+rm -rf "$check"; mkdir -p "$check"
+tar -xzf "$archive" -C "$check"
+xcf="$check/Python.xcframework"
+[ -d "$xcf" ] || die "no Python.xcframework in the package"
+
+[ -d "$xcf/lib/python${minor}t" ] || die "no lib/python${minor}t in the XCframework"
+slices=0
+for slice in "$xcf"/ios-*; do
+ slices=$((slices + 1))
+ # Headers/pyconfig.h is a wrapper that includes pyconfig-.h.
+ found=0
+ for h in "$slice"/Python.framework/Headers/pyconfig-*.h; do
+ require_gil_disabled "$h"; found=$((found + 1))
+ done
+ [ "$found" -gt 0 ] || die "no pyconfig-.h in $slice"
+ [ -e "$slice/lib/libpython${minor}t.dylib" ] || die "no libpython${minor}t.dylib in $slice"
+ lipo -info "$slice/Python.framework/Python"
+done
+[ "$slices" -eq 2 ] || die "expected 2 slices (device, simulator), found $slices"
+
+cp "$archive" "$FT_OUT/"
+echo "iOS archive:" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
+record_sha256 "$FT_OUT/$(basename "$archive")"
diff --git a/.github/scripts/ft-mobile/common.sh b/.github/scripts/ft-mobile/common.sh
new file mode 100755
index 000000000..74ddd4035
--- /dev/null
+++ b/.github/scripts/ft-mobile/common.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Helpers shared by build-android.sh and build-ios.sh. Source this file, do not run it.
+
+# FT_WORK: scratch directory (source tree and cross-build output).
+# FT_OUT : directory that receives the archives the workflow uploads.
+FT_WORK="${FT_WORK:-$PWD/.tmp/ft-mobile}"
+FT_OUT="${FT_OUT:-$PWD/.tmp/ft-mobile-out}"
+
+log() { echo "[ft-mobile] $*" >&2; }
+die() { echo "[ft-mobile] ERROR: $*" >&2; exit 1; }
+
+# "3.15.0" -> "3.15" (also "3.15.0rc1" -> "3.15").
+py_minor() { echo "$1" | sed -E 's/^([0-9]+\.[0-9]+).*/\1/'; }
+
+# Download and unpack the CPython source tarball; print the source directory.
+# CPython's mobile tooling needs the directory to be a git work tree only for
+# `Platforms/Android package` (it runs `git ls-files` there), so the caller
+# decides whether to `git init`.
+fetch_source() {
+ local version="$1"
+ local dirver="${version%%[a-z]*}" # the ftp directory has no rc/alpha suffix
+ local tgz="$FT_WORK/Python-$version.tgz"
+ mkdir -p "$FT_WORK" "$FT_OUT"
+ if [ ! -f "$tgz" ]; then
+ log "downloading CPython $version"
+ curl -fL --retry 5 --retry-all-errors -o "$tgz" \
+ "https://www.python.org/ftp/python/$dirver/Python-$version.tgz"
+ fi
+ if [ ! -d "$FT_WORK/Python-$version" ]; then
+ tar -xzf "$tgz" -C "$FT_WORK"
+ fi
+ echo "$FT_WORK/Python-$version"
+}
+
+# Fail unless FILE contains a line matching `#define Py_GIL_DISABLED 1`.
+require_gil_disabled() {
+ local file="$1"
+ grep -Eq '^#define Py_GIL_DISABLED 1$' "$file" \
+ || die "$file does not define Py_GIL_DISABLED 1"
+ log "ok: Py_GIL_DISABLED 1 in $file"
+}
+
+# Print "sha256 name" lines for the given files, to stdout and the job summary.
+record_sha256() {
+ local f line
+ for f in "$@"; do
+ line="$(cd "$(dirname "$f")" && shasum -a 256 "$(basename "$f")")"
+ echo "$line"
+ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
+ echo "$line" >> "$GITHUB_STEP_SUMMARY"
+ fi
+ done
+}
diff --git a/tools/release/README.md b/.github/scripts/release/README.md
similarity index 81%
rename from tools/release/README.md
rename to .github/scripts/release/README.md
index a4f6302d9..cfb4de357 100644
--- a/tools/release/README.md
+++ b/.github/scripts/release/README.md
@@ -7,7 +7,7 @@ between runs, and any existing `release` (e.g. a stale one from an older workflo
ignored and overwritten.
1. Every push to `develop` triggers `.github/workflows/release-sync.yml`, which runs
- `tools/release/sync-release.sh --push`. It rebuilds `release` from scratch as exactly
+ `.github/scripts/release/sync-release.sh --push`. It rebuilds `release` from scratch as exactly
one commit (`Release: sync from develop `) whose parent is that develop commit and
whose tree is the develop tree minus the files below. This happens even when nothing
is dropped. It is a no-op only if `release` already has that tree and that parent.
@@ -31,10 +31,10 @@ Only Markdown is ever dropped.
## Local use
- tools/release/sync-release.sh --dry-run # list paths that would be dropped
- tools/release/sync-release.sh # regenerate local release branch
- tools/release/sync-release.sh --push # also push to origin
- tools/release/test-sync-release.sh # tests (throwaway repo in .tmp/)
+ .github/scripts/release/sync-release.sh --dry-run # list paths that would be dropped
+ .github/scripts/release/sync-release.sh # regenerate local release branch
+ .github/scripts/release/sync-release.sh --push # also push to origin
+ .github/scripts/release/test-sync-release.sh # tests (throwaway repo in .tmp/)
The script uses plumbing and a private index, so your working tree, index and HEAD are untouched.
It refuses to run while `release` is checked out.
diff --git a/tools/release/sync-release.sh b/.github/scripts/release/sync-release.sh
similarity index 100%
rename from tools/release/sync-release.sh
rename to .github/scripts/release/sync-release.sh
diff --git a/tools/release/test-sync-release.sh b/.github/scripts/release/test-sync-release.sh
similarity index 99%
rename from tools/release/test-sync-release.sh
rename to .github/scripts/release/test-sync-release.sh
index cba1d3139..2ee99e3f9 100755
--- a/tools/release/test-sync-release.sh
+++ b/.github/scripts/release/test-sync-release.sh
@@ -2,7 +2,7 @@
# Tests for sync-release.sh. Builds a throwaway repo under /.tmp/.
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
-ROOT="$(cd "$HERE/../.." && pwd)"
+ROOT="$(cd "$HERE/../../.." && pwd)"
SYNC="$HERE/sync-release.sh"
SCRATCH="$ROOT/.tmp/release-sync-test.$$"
FAILS=0; PASSES=0
diff --git a/tools/stubs/check-stubs.sh b/.github/scripts/stubs/check-stubs.sh
similarity index 55%
rename from tools/stubs/check-stubs.sh
rename to .github/scripts/stubs/check-stubs.sh
index b8c1c41d3..5e74fa0f8 100755
--- a/tools/stubs/check-stubs.sh
+++ b/.github/scripts/stubs/check-stubs.sh
@@ -1,14 +1,14 @@
#!/usr/bin/env bash
-# Type-checks tools/stubs/consumer.py against a directory of generated Kotlin-name stubs (issue #31).
+# Type-checks .github/scripts/stubs/consumer.py against a directory of generated Kotlin-name stubs (issue #31).
#
-# tools/stubs/check-stubs.sh [venv-dir]
+# .github/scripts/stubs/check-stubs.sh [venv-dir]
#
# is what `generatePythonStubs` wrote, e.g.
-# ksp-fixtures/compose/build/generated/pythonStubs/desktopMain
+# python-multiplatform-compose/fixtures/compose/build/generated/pythonStubs/desktopMain
# The venv defaults to /.tmp/stubs-venv (git-ignored) and is created on first use.
set -euo pipefail
-root="$(cd "$(dirname "$0")/../.." && pwd)"
+root="$(cd "$(dirname "$0")/../../.." && pwd)"
stubs="$(cd "${1:?usage: check-stubs.sh [venv-dir]}" && pwd)"
venv="${2:-$root/.tmp/stubs-venv}"
@@ -17,10 +17,10 @@ if [ ! -x "$venv/bin/mypy" ]; then
"$venv/bin/pip" install --quiet mypy
fi
-# tools/stubs/mypy.ini: `strict`, and `warn_unused_ignores` for the consumer so that the lines it
+# .github/scripts/stubs/mypy.ini: `strict`, and `warn_unused_ignores` for the consumer so that the lines it
# expects to be rejected fail the check when they stop being. Errors inside the stubs themselves are
# reported too: MYPYPATH modules are checked, not silenced.
MYPYPATH="$stubs" "$venv/bin/mypy" \
- --config-file "$root/tools/stubs/mypy.ini" \
+ --config-file "$root/.github/scripts/stubs/mypy.ini" \
--cache-dir "$root/.tmp/mypy-cache" \
- "$root/tools/stubs/consumer.py"
+ "$root/.github/scripts/stubs/consumer.py"
diff --git a/tools/stubs/consumer.py b/.github/scripts/stubs/consumer.py
similarity index 50%
rename from tools/stubs/consumer.py
rename to .github/scripts/stubs/consumer.py
index 3111280c6..99e4e61d8 100644
--- a/tools/stubs/consumer.py
+++ b/.github/scripts/stubs/consumer.py
@@ -1,15 +1,29 @@
-"""A small consumer of the generated Kotlin-name stubs, type-checked by `check-stubs.sh` (issue #31).
+"""A small consumer of the generated Kotlin-named stubs, type-checked by `check-stubs.sh` (issue #31).
-It is the code a user writes against the binder -- Kotlin names, Kotlin parameter names -- and it
-must type-check against the stubs generated for the real Compose jars: a Modifier chain started from
-the class and continued from an instance, `Checkbox`, `Text`, and a container with its trailing
-`content` lambda. The last four lines are **meant** to be rejected; they carry `# type: ignore[...]`
-and mypy runs with `warn_unused_ignores`, so a stub that stopped rejecting one fails the check too.
+It is the code a user writes against the binder, and it must type-check against the stubs generated
+for the real Compose jars: a Modifier chain started from the class and continued from an instance,
+`Checkbox`, `Text`, and a container with its trailing `content` lambda.
+
+Names: a declaration is reachable by its Kotlin name and by its Pythonic (snake_case) alias, and the
+stubs carry both (issue #131), so both spellings are written below. Keywords: a stub can name a
+parameter only once, and it names it the way `inspect.signature` does -- by the Pythonic keyword
+(`on_checked_change`). The Kotlin keyword (`onCheckedChange=`) still works at run time; a checker
+reports it, so it is not written here.
+
+The last four lines are **meant** to be rejected; they carry `# type: ignore[...]` and mypy runs
+with `warn_unused_ignores`, so a stub that stopped rejecting one fails the check too.
"""
from typing import assert_type
-from androidx.compose.foundation.layout import Arrangement, Column, Row, fillMaxWidth, padding__Dp
+from androidx.compose.foundation.layout import (
+ Arrangement,
+ Column,
+ Row,
+ fill_max_width,
+ fillMaxWidth,
+ padding__Dp,
+)
from androidx.compose.material.icons import Icons
from androidx.compose.material3 import Checkbox, Icon, Text
from androidx.compose.ui import Modifier
@@ -17,13 +31,18 @@
# A chain started from the class, the way Kotlin writes `Modifier.padding(16.dp)`.
chain: Modifier = Modifier.fillMaxWidth().padding(16.0).size(24.0)
-# ... and continued from an instance, and with Kotlin parameter names.
+# ... and continued from an instance, with keyword arguments.
more: Modifier = chain.padding(horizontal=8.0, vertical=4.0).padding(start=1.0, bottom=2.0)
# The explicit table-key spelling, as an attribute and as a module function.
explicit: Modifier = chain.padding__Dp(4.0)
by_function: Modifier = padding__Dp(chain, 4.0)
by_extension: Modifier = fillMaxWidth(chain, fraction=0.5)
+# Issue #131: the Pythonic alias of a module function and of a method is the same declaration.
+by_alias: Modifier = fill_max_width(chain, fraction=0.5)
+assert_type(fill_max_width(chain), Modifier)
+pythonic_chain: Modifier = Modifier.fill_max_width().padding(16.0).fill_max_width(0.5)
+
checked = True
@@ -33,7 +52,7 @@ def on_change(value: bool) -> None:
Checkbox(checked, on_change, modifier=more, enabled=True)
-Checkbox(checked=False, onCheckedChange=None)
+Checkbox(checked=False, on_checked_change=None)
Text("hello", modifier=Modifier.padding(8.0))
@@ -44,18 +63,18 @@ def content() -> None:
Column(modifier=chain, content=lambda scope: None)
Row(content=lambda scope: None)
# `SpaceBetween` is a `HorizontalOrVertical`: a `Vertical` slot and a `Horizontal` slot both take it (#71).
-Column(verticalArrangement=Arrangement.SpaceBetween, content=lambda scope: None)
-Row(horizontalArrangement=Arrangement.SpaceBetween, content=lambda scope: None)
+Column(vertical_arrangement=Arrangement.SpaceBetween, content=lambda scope: None)
+Row(horizontal_arrangement=Arrangement.SpaceBetween, content=lambda scope: None)
# An extension property on a type nested in an object (issue #68): `Icons.Default` is `Icons.Filled`.
# `assert_type`, not an annotation: an `Any` (the stub before #53/#67) would satisfy an annotation.
assert_type(Icons.Default.Add, ImageVector)
-Icon(Icons.Default.Add, contentDescription=None)
+Icon(Icons.Default.Add, content_description=None)
# Rejected on purpose. (`padding("sixteen")` is not among them: one `padding` overload takes a
# `PaddingValues`, a type the stubs cannot name -- `PaddingValues(...)` the factory owns the name -- so
# it is `Any` and accepts anything.)
-Modifier.fillMaxWidth("wide") # type: ignore[arg-type]
+Modifier.fill_max_width("wide") # type: ignore[arg-type]
Checkbox("not a bool", on_change) # type: ignore[call-overload]
Checkbox(checked, on_change, enabled="yes") # type: ignore[call-overload]
Column(chain) # type: ignore[call-arg]
diff --git a/tools/stubs/mypy.ini b/.github/scripts/stubs/mypy.ini
similarity index 100%
rename from tools/stubs/mypy.ini
rename to .github/scripts/stubs/mypy.ini
diff --git a/.github/workflows/android-native.yml b/.github/workflows/android-native.yml
index 6ff4ff985..3e77c4f17 100644
--- a/.github/workflows/android-native.yml
+++ b/.github/workflows/android-native.yml
@@ -1,15 +1,16 @@
name: Android Native CI
on:
+ # Checked on develop after merging, not on every pull request (user decision,
+ # 2026-10-17). A red develop is fixed forward by its own pull request.
push:
branches: [ "develop", "main" ]
- pull_request:
- branches: [ "develop", "main" ]
-
+ workflow_dispatch:
concurrency:
- # A newer push to the same ref supersedes this run; frees the shared macOS runners.
+ # A run in progress finishes; a newer push replaces only the queued run (frequent merges
+ # to develop otherwise cancelled every run before it reported).
group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: false
jobs:
compile:
diff --git a/.github/workflows/build-ft-mobile.yml b/.github/workflows/build-ft-mobile.yml
new file mode 100644
index 000000000..0b9d05954
--- /dev/null
+++ b/.github/workflows/build-ft-mobile.yml
@@ -0,0 +1,80 @@
+name: Build free-threaded CPython for mobile
+
+# Builds CPython with --disable-gil ("3.15t") for Android arm64 and iOS with
+# CPython's own tooling (Platforms/Android, Platforms/Apple). Manual only: the
+# builds take a long time and the archives are inputs to later work (#163).
+# See docs/platforms/free-threaded-mobile.md.
+
+on:
+ # Runs on develop when the build itself changes (workflow_dispatch needs the file on the
+ # default branch, main, which develop reaches only through a release).
+ push:
+ branches: [develop]
+ paths:
+ - ".github/workflows/build-ft-mobile.yml"
+ - ".github/scripts/ft-mobile/**"
+ workflow_dispatch:
+ inputs:
+ python_version:
+ description: CPython version to build (a python.org source release)
+ default: "3.15.0"
+ required: true
+ type: string
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ android:
+ name: Android aarch64 (free-threaded)
+ runs-on: ubuntu-24.04
+ timeout-minutes: 120
+ env:
+ FT_WORK: ${{ github.workspace }}/.tmp/ft-mobile
+ FT_OUT: ${{ github.workspace }}/.tmp/ft-mobile-out
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+
+ - name: Install build Python prerequisites
+ run: >
+ sudo apt-get update && sudo apt-get install -y
+ build-essential pkg-config libssl-dev zlib1g-dev libffi-dev
+ libbz2-dev liblzma-dev libsqlite3-dev libzstd-dev
+
+ - name: Build
+ run: .github/scripts/ft-mobile/build-android.sh "${{ inputs.python_version || '3.15.0' }}"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: python-${{ inputs.python_version || '3.15.0' }}t-android-aarch64
+ path: ${{ env.FT_OUT }}/*.tar.gz
+ if-no-files-found: error
+
+ ios:
+ name: iOS XCframework (free-threaded)
+ runs-on: macos-15
+ timeout-minutes: 180
+ env:
+ FT_WORK: ${{ github.workspace }}/.tmp/ft-mobile
+ FT_OUT: ${{ github.workspace }}/.tmp/ft-mobile-out
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Build
+ run: .github/scripts/ft-mobile/build-ios.sh "${{ inputs.python_version || '3.15.0' }}"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: python-${{ inputs.python_version || '3.15.0' }}t-ios-xcframework
+ path: ${{ env.FT_OUT }}/*.tar.gz
+ if-no-files-found: error
diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml
index 67c9c2c7f..0b43ff3e5 100644
--- a/.github/workflows/desktop.yml
+++ b/.github/workflows/desktop.yml
@@ -1,15 +1,16 @@
name: Desktop CI
on:
+ # Checked on develop after merging, not on every pull request (user decision,
+ # 2026-10-17). A red develop is fixed forward by its own pull request.
push:
branches: [ "develop", "main" ]
- pull_request:
- branches: [ "develop", "main" ]
-
+ workflow_dispatch:
concurrency:
- # A newer push to the same ref supersedes this run; frees the shared macOS runners.
+ # A run in progress finishes; a newer push replaces only the queued run (frequent merges
+ # to develop otherwise cancelled every run before it reported).
group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: false
jobs:
test:
@@ -41,6 +42,191 @@ jobs:
restore-keys: |
${{ runner.os }}-konan-
+ # The dev agent's protocol (SPEC L-12, #233): stdlib-only, so the runner's own python3 runs it.
+ - name: Dev agent protocol tests
+ working-directory: python-multiplatform/src/test/python
+ run: python3 -m unittest -v test_devagent
+
- name: Run Desktop Tests
# Using --console=plain so logs are readable in GitHub Actions
run: ./gradlew :python-multiplatform:desktopTest --console=plain
+
+ # The dev agent's protocol on the other desktop OSes (#238). Its socket options differ per platform
+ # (SO_EXCLUSIVEADDRUSE on Windows, SO_REUSEADDR on POSIX), and the port-takeover and restart tests are
+ # what check them. Stdlib only: no JDK, no Gradle.
+ dev-agent:
+ name: Dev agent protocol (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+
+ - name: Dev agent protocol tests
+ working-directory: python-multiplatform/src/test/python
+ run: python -m unittest -v test_devagent
+
+ # pythonx-compose's UI.ipynb, end to end (#26, #213): the installed pythonx-compose wheel drives a
+ # real desktop Compose host, pixel-compared with the same composables drawn from Kotlin. Its own
+ # job and Gradle invocation, so it neither hides nor is hidden by the module tests above.
+ notebook-e2e:
+ name: Notebook E2E (pythonx-compose wheel)
+ runs-on: macos-latest
+ env:
+ WHEEL_DIR: ${{ github.workspace }}/.tmp/pythonx-compose-dist
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Beside this repository, as on a developer machine; the wheel is built from it, not installed
+ # from PyPI, so the scenarios follow pythonx-compose develop.
+ - name: Checkout pythonx-compose develop
+ uses: actions/checkout@v4
+ with:
+ repository: thisisthepy/pythonx-compose
+ ref: develop
+ path: .tmp/pythonx-compose
+ fetch-depth: 1
+
+ - name: Setup uv
+ uses: astral-sh/setup-uv@v6
+
+ - name: Build the pythonx-compose wheel
+ run: |
+ uv build --wheel --directory .tmp/pythonx-compose --out-dir "$WHEEL_DIR"
+ ls -l "$WHEEL_DIR"
+ git -C .tmp/pythonx-compose log -1 --format='pythonx-compose %h %s'
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'zulu'
+ cache: 'gradle'
+
+ - name: Cache Konan
+ uses: actions/cache@v4
+ with:
+ path: ~/.konan
+ key: ${{ runner.os }}-konan-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-konan-
+
+ # No skip property: without a wheel every scenario fails by name, which is what CI wants.
+ - name: Run the notebook scenarios
+ run: >
+ ./gradlew :python-multiplatform-compose:fixtures:notebook-e2e:desktopTest --rerun
+ -PpythonxComposeWheel="$WHEEL_DIR" --console=plain
+
+ - name: Count the scenarios
+ if: always()
+ run: |
+ python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY"
+ import glob, xml.etree.ElementTree as ET
+ files = glob.glob("python-multiplatform-compose/fixtures/notebook-e2e/build/test-results/desktopTest/*.xml")
+ total = failed = skipped = 0
+ for f in files:
+ suite = ET.parse(f).getroot()
+ total += int(suite.get("tests", 0))
+ failed += int(suite.get("failures", 0)) + int(suite.get("errors", 0))
+ skipped += int(suite.get("skipped", 0))
+ print(f"Notebook E2E: {total} tests, {total - failed - skipped} passed, {failed} failed, {skipped} skipped ({len(files)} suites)")
+ PY
+ cat "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload the test reports
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: notebook-e2e-reports
+ path: |
+ python-multiplatform-compose/fixtures/notebook-e2e/build/reports/tests/desktopTest
+ python-multiplatform-compose/fixtures/notebook-e2e/build/test-results/desktopTest
+
+ # The notebook E2E in a GraalVM native image on macOS (#249, #251). It takes `ui`, `ui-text` and
+ # `foundation` from compose-multiplatform-core-extended, which a runner reads from the fork's GitHub
+ # Packages registry; until the repository variable COMPOSE_EXTENDED_VERSION names a published
+ # development version (`1.11.1-ext.N-dev.`) the job is skipped.
+ notebook-e2e-native-image:
+ name: Notebook E2E in a native image (macOS)
+ if: ${{ vars.COMPOSE_EXTENDED_VERSION != '' }}
+ runs-on: macos-latest
+ permissions:
+ contents: read
+ packages: read
+ env:
+ WHEEL_DIR: ${{ github.workspace }}/.tmp/pythonx-compose-dist
+ COMPOSE_EXTENDED_VERSION: ${{ vars.COMPOSE_EXTENDED_VERSION }}
+ COMPOSE_EXTENDED_GROUP_PREFIX: ${{ vars.COMPOSE_EXTENDED_GROUP_PREFIX || 'org.thisisthepy.compose' }}
+ COMPOSE_EXTENDED_REPOSITORY: ${{ vars.COMPOSE_EXTENDED_REPOSITORY || 'https://maven.pkg.github.com/thisisthepy/compose-multiplatform-core-extended' }}
+ # GitHub Packages asks for a token even for a public package. A read:packages PAT in the secret
+ # GH_PACKAGES_TOKEN is used when it exists; otherwise this repository's own token.
+ GITHUB_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN || secrets.GITHUB_TOKEN }}
+ TOKEN_SOURCE: ${{ secrets.GH_PACKAGES_TOKEN != '' && 'GH_PACKAGES_TOKEN' || 'GITHUB_TOKEN' }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Checkout pythonx-compose develop
+ uses: actions/checkout@v4
+ with:
+ repository: thisisthepy/pythonx-compose
+ ref: develop
+ path: .tmp/pythonx-compose
+ fetch-depth: 1
+
+ # Before any build: can this token read the fork's packages? One known POM, so a refusal is
+ # reported as what it is rather than as a dependency resolution error minutes later.
+ - name: Check access to the fork's packages
+ run: |
+ group_path="${COMPOSE_EXTENDED_GROUP_PREFIX//.//}/ui"
+ pom="$COMPOSE_EXTENDED_REPOSITORY/$group_path/ui-desktop/$COMPOSE_EXTENDED_VERSION/ui-desktop-$COMPOSE_EXTENDED_VERSION.pom"
+ status=$(curl -s -o /dev/null -w '%{http_code}' -u "${GITHUB_ACTOR}:${GITHUB_TOKEN}" "$pom")
+ echo "reading $pom with $TOKEN_SOURCE: HTTP $status"
+ if [ "$status" != "200" ]; then
+ echo "::error::The fork's packages could not be read with $TOKEN_SOURCE (HTTP $status at $pom). Add a personal access token (classic) with read:packages as the repository secret GH_PACKAGES_TOKEN, or check COMPOSE_EXTENDED_VERSION / COMPOSE_EXTENDED_REPOSITORY."
+ exit 1
+ fi
+
+ - name: Setup uv
+ uses: astral-sh/setup-uv@v6
+
+ - name: Build the pythonx-compose wheel
+ run: uv build --wheel --directory .tmp/pythonx-compose --out-dir "$WHEEL_DIR"
+
+ - name: Setup Java 21 (Gradle)
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'zulu'
+ cache: 'gradle'
+
+ # The JDK 25 toolchain native-image comes from, found by Gradle's toolchain resolution.
+ - name: Setup Liberica NIK 25
+ uses: graalvm/setup-graalvm@v1
+ with:
+ java-version: '25'
+ distribution: 'liberica'
+ set-java-home: 'false'
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ # Records Compose and skiko's configuration against the fork version this run uses, then builds
+ # the image and runs it with JAVA_HOME and PYTHONHOME removed: 20 of 20 and no libjvm.
+ - name: Build and run the native image
+ run: >
+ ./gradlew :python-multiplatform-compose:fixtures:notebook-e2e:notebookE2eNativeAgent
+ :python-multiplatform-compose:fixtures:notebook-e2e:notebookE2eNativeRun
+ -PpythonxComposeWheel="$WHEEL_DIR"
+ -PcomposeExtended.version="$COMPOSE_EXTENDED_VERSION"
+ -PcomposeExtended.groupPrefix="$COMPOSE_EXTENDED_GROUP_PREFIX"
+ -PcomposeExtended.mavenLocal=false
+ -PcomposeExtended.repository="$COMPOSE_EXTENDED_REPOSITORY"
+ --console=plain
diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml
index 0bd46c48c..f1287532b 100644
--- a/.github/workflows/ios.yml
+++ b/.github/workflows/ios.yml
@@ -1,15 +1,16 @@
name: iOS CI
on:
+ # Checked on develop after merging, not on every pull request (user decision,
+ # 2026-10-17). A red develop is fixed forward by its own pull request.
push:
branches: [ "develop", "main" ]
- pull_request:
- branches: [ "develop", "main" ]
-
+ workflow_dispatch:
concurrency:
- # A newer push to the same ref supersedes this run; frees the shared macOS runners.
+ # A run in progress finishes; a newer push replaces only the queued run (frequent merges
+ # to develop otherwise cancelled every run before it reported).
group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: false
jobs:
compile:
@@ -38,3 +39,55 @@ jobs:
- name: Compile iOS Simulator Arm64
# Compile iOS Simulator which includes commonMain + nativeMain + iosMain
run: ./gradlew :python-multiplatform:compileKotlinIosSimulatorArm64 --console=plain
+
+ # The klib walker on Kotlin/Native (#234, #237, #240): object handles, their lifetime and member
+ # functions, called from Python on the iOS simulator. Its own job and Gradle invocation.
+ klib-fixture:
+ name: Klib fixture on the iOS simulator
+ runs-on: macos-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'zulu'
+ cache: 'gradle'
+
+ - name: Cache Konan
+ uses: actions/cache@v4
+ with:
+ path: ~/.konan
+ key: ${{ runner.os }}-konan-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-konan-
+
+ - name: Run the klib fixture's suite
+ run: ./gradlew :python-multiplatform-gradle-plugin-fixtures:klib-artifact:iosSimulatorArm64Test --rerun --console=plain
+
+ - name: Count the tests
+ if: always()
+ run: |
+ python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY"
+ import glob, xml.etree.ElementTree as ET
+ files = glob.glob("python-multiplatform-gradle-plugin/fixtures/klib-artifact/build/test-results/iosSimulatorArm64Test/*.xml")
+ total = failed = skipped = 0
+ for f in files:
+ suite = ET.parse(f).getroot()
+ total += int(suite.get("tests", 0))
+ failed += int(suite.get("failures", 0)) + int(suite.get("errors", 0))
+ skipped += int(suite.get("skipped", 0))
+ print(f"Klib fixture (iOS simulator): {total} tests, {total - failed - skipped} passed, {failed} failed, {skipped} skipped ({len(files)} suites)")
+ PY
+ cat "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload the test reports
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: klib-fixture-ios-reports
+ path: |
+ python-multiplatform-gradle-plugin/fixtures/klib-artifact/build/reports/tests/iosSimulatorArm64Test
+ python-multiplatform-gradle-plugin/fixtures/klib-artifact/build/test-results/iosSimulatorArm64Test
diff --git a/.github/workflows/release-sync.yml b/.github/workflows/release-sync.yml
index 746a0b9de..8914610b3 100644
--- a/.github/workflows/release-sync.yml
+++ b/.github/workflows/release-sync.yml
@@ -29,7 +29,7 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Not used as a base (release is regenerated); the script reads the
# remote sha itself for --force-with-lease.
- bash tools/release/sync-release.sh --source "${GITHUB_SHA}" --push
+ bash .github/scripts/release/sync-release.sh --source "${GITHUB_SHA}" --push
- name: Open or update PR release -> main
env:
@@ -43,7 +43,7 @@ jobs:
echo "release already equals main; nothing to PR"; exit 0
fi
title="Release: merge release into main"
- body="Automated sync of \`develop\` (${GITHUB_SHA::7}) into \`release\`. Markdown outside README.md and docs/ subfolders is removed by tools/release/sync-release.sh."
+ body="Automated sync of \`develop\` (${GITHUB_SHA::7}) into \`release\`. Markdown outside README.md and docs/ subfolders is removed by .github/scripts/release/sync-release.sh."
existing="$(gh pr list --head release --base main --state open --json number --jq '.[0].number // empty')"
if [ -n "$existing" ]; then
gh pr edit "$existing" --title "$title" --body "$body"
diff --git a/.github/workflows/stubs.yml b/.github/workflows/stubs.yml
index b6a7a785d..2d9356bba 100644
--- a/.github/workflows/stubs.yml
+++ b/.github/workflows/stubs.yml
@@ -5,21 +5,15 @@ name: Kotlin-name stubs
# build resolves (`compose-plugin` in gradle/libs.versions.toml) and publishes the result.
#
# - every push to develop and every pull request: the stubs are generated, type-checked against
-# tools/stubs/consumer.py with mypy (issue #31), and uploaded as the workflow artifact `kotlin-stubs`;
+# .github/scripts/stubs/consumer.py with mypy (issue #31), and uploaded as the workflow artifact `kotlin-stubs`;
# - every `v*` tag: the same stubs are also attached to the tag's GitHub release as `kotlin-stubs.zip`.
on:
+ # Checked on develop after merging, not on every pull request (user decision,
+ # 2026-10-17). A red develop is fixed forward by its own pull request.
push:
branches: [ "develop" ]
tags: [ "v*" ]
- pull_request:
- branches: [ "develop" ]
- paths:
- - "python-multiplatform-gradle-plugin/**"
- - "ksp-fixtures/compose/**"
- - "tools/stubs/**"
- - "gradle/libs.versions.toml"
- - ".github/workflows/stubs.yml"
workflow_dispatch:
permissions:
@@ -27,7 +21,7 @@ permissions:
concurrency:
group: stubs-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: false
jobs:
stubs:
@@ -52,10 +46,10 @@ jobs:
python-version: '3.13'
- name: Generate the stubs over the Compose jars
- run: ./gradlew :ksp-fixtures:compose:generatePythonStubs --console=plain
+ run: ./gradlew :python-multiplatform-compose:fixtures:compose:generatePythonStubs --console=plain
- name: Type-check a consumer against them (mypy)
- run: tools/stubs/check-stubs.sh ksp-fixtures/compose/build/generated/pythonStubs/desktopMain
+ run: .github/scripts/stubs/check-stubs.sh python-multiplatform-compose/fixtures/compose/build/generated/pythonStubs/desktopMain
- name: Assemble the artifact
run: |
@@ -65,7 +59,7 @@ jobs:
out=build/kotlin-stubs
rm -rf "$out"
mkdir -p "$out"
- cp -R ksp-fixtures/compose/build/generated/pythonStubs/desktopMain/. "$out/"
+ cp -R python-multiplatform-compose/fixtures/compose/build/generated/pythonStubs/desktopMain/. "$out/"
cat > "$out/README.md" <\`, or the
IDE's "stub path" / interpreter paths). It is checked with mypy against
- \`tools/stubs/consumer.py\` in the commit above.
+ \`.github/scripts/stubs/consumer.py\` in the commit above.
README
(cd build && zip -qr kotlin-stubs.zip kotlin-stubs)
@@ -129,11 +123,11 @@ jobs:
python-version: '3.13'
- name: Generate the stubs over the Compose jars
- run: ./gradlew :ksp-fixtures:compose:generatePythonStubs --console=plain
+ run: ./gradlew :python-multiplatform-compose:fixtures:compose:generatePythonStubs --console=plain
- name: No two stub paths are equal under case folding
run: |
- python3 - ksp-fixtures/compose/build/generated/pythonStubs/desktopMain <<'PY'
+ python3 - python-multiplatform-compose/fixtures/compose/build/generated/pythonStubs/desktopMain <<'PY'
import os, sys
seen = {}
for root, dirs, files in os.walk(sys.argv[1]):
@@ -146,4 +140,4 @@ jobs:
PY
- name: Type-check a consumer against them (mypy)
- run: tools/stubs/check-stubs.sh ksp-fixtures/compose/build/generated/pythonStubs/desktopMain
+ run: .github/scripts/stubs/check-stubs.sh python-multiplatform-compose/fixtures/compose/build/generated/pythonStubs/desktopMain
diff --git a/.github/workflows/typedpython.yml b/.github/workflows/typedpython.yml
index c03f0ea4d..4c47c2ac8 100644
--- a/.github/workflows/typedpython.yml
+++ b/.github/workflows/typedpython.yml
@@ -3,17 +3,17 @@ name: TypedPython CI
# The TypedPython gate and compiler are Python (python-multiplatform-ksp/src/{main,test}/python).
# Gradle does not run them; this workflow does.
on:
+ # Checked on develop after merging, not on every pull request (user decision,
+ # 2026-10-17). A red develop is fixed forward by its own pull request.
push:
branches: [ "develop", "main" ]
- paths: [ "python-multiplatform-ksp/**", "ksp-fixtures/app/**", ".github/workflows/typedpython.yml" ]
- pull_request:
- branches: [ "develop", "main" ]
- paths: [ "python-multiplatform-ksp/**", "ksp-fixtures/app/**", ".github/workflows/typedpython.yml" ]
-
+ paths: [ "python-multiplatform-ksp/**", "python-multiplatform-ksp/fixtures/app/**", ".github/workflows/typedpython.yml" ]
+ workflow_dispatch:
concurrency:
- # A newer push to the same ref supersedes this run; frees the shared macOS runners.
+ # A run in progress finishes; a newer push replaces only the queued run (frequent merges
+ # to develop otherwise cancelled every run before it reported).
group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: false
jobs:
test:
@@ -34,11 +34,11 @@ jobs:
- name: Create the environment
# pyrefly is pinned exactly: the gate reads its versioned type-report format.
run: |
- uv venv .venv --python 3.13
- uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest
+ uv venv .venv --python 3.15t
+ uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest numpy
- name: Behaviour tests
- run: .venv/bin/python -m pytest -q -m "not measurement"
+ run: .venv/bin/python -m pytest -q -m "not measurement and not race_mutant"
- name: Measurements (recorded, sanity bounds only)
# A shared runner is not an idle machine (AGENTS.md §8): read ratios, not absolute times.
@@ -62,8 +62,8 @@ jobs:
- name: Create the environment
run: |
- uv venv .venv --python 3.13
- uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest
+ uv venv .venv --python 3.15t
+ uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest numpy
- name: Relax ASLR entropy (ASan shadow mapping on newer kernels)
run: sudo sysctl -w vm.mmap_rnd_bits=28 || true
@@ -78,15 +78,15 @@ jobs:
# only (tools/sanitizers.py env prints it); the gate's pyrefly runs without it.
run: |
env $(.venv/bin/python tools/sanitizers.py env) \
- .venv/bin/python -m pytest -q -m "not measurement" \
- src/test/python/test_cgen.py src/test/python/test_runtime.py src/test/python/test_pipeline.py
+ .venv/bin/python -m pytest -q -m "not measurement and not race_mutant" \
+ src/test/python/test_cgen.py src/test/python/test_runtime.py src/test/python/test_pipeline.py src/test/python/test_interval_proofs.py
app-fixture:
# #45: a TypedPython-compiled module calling Kotlin through the upcall table, in the app
# fixture's desktop JVM. buildTypedPythonBridge needs a host Python with pyrefly; without one the
# four TypedPythonKotlinCallTest tests skip. Locally that is allowed; here a skip fails the job.
# macOS only: elsewhere the JVM does not load libpython with global symbols, so the
- # dynamic_lookup extension cannot resolve the C API (ksp-fixtures/app/build.gradle.kts).
+ # dynamic_lookup extension cannot resolve the C API (python-multiplatform-ksp/fixtures/app/build.gradle.kts).
name: TypedPython from Kotlin, app fixture (macos-latest)
runs-on: macos-latest
steps:
@@ -103,20 +103,20 @@ jobs:
- name: Create the TypedPython host environment
working-directory: python-multiplatform-ksp
run: |
- uv venv .venv --python 3.13
- uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest
+ uv venv .venv --python 3.15t
+ uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest numpy
- name: App fixture tests
run: |
- rm -rf ksp-fixtures/app/build/test-results/desktopTest
- ./gradlew :ksp-fixtures:app:desktopTest --rerun --console=plain \
+ rm -rf python-multiplatform-ksp/fixtures/app/build/test-results/desktopTest
+ ./gradlew :python-multiplatform-ksp:fixtures:app:desktopTest --rerun --console=plain \
-PtypedpythonPython="$PWD/python-multiplatform-ksp/.venv/bin/python"
- name: The TypedPython tests ran (none skipped)
run: |
python3 - <<'PY'
import glob, sys, xml.etree.ElementTree as ET
- cases = [c for p in glob.glob("ksp-fixtures/app/build/test-results/desktopTest/*.xml")
+ cases = [c for p in glob.glob("python-multiplatform-ksp/fixtures/app/build/test-results/desktopTest/*.xml")
for c in ET.parse(p).getroot().iter("testcase")
if c.get("classname") == "fixture.app.TypedPythonKotlinCallTest"]
skipped = [c.get("name") for c in cases if c.find("skipped") is not None]
diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml
index a800e5dcd..291124f33 100644
--- a/.github/workflows/wasm.yml
+++ b/.github/workflows/wasm.yml
@@ -13,15 +13,16 @@ name: WasmJS CI
# `test` runs the real suite, and runs only when there is a runtime to run it against.
on:
+ # Checked on develop after merging, not on every pull request (user decision,
+ # 2026-10-17). A red develop is fixed forward by its own pull request.
push:
branches: [ "develop", "main" ]
- pull_request:
- branches: [ "develop", "main" ]
-
+ workflow_dispatch:
concurrency:
- # A newer push to the same ref supersedes this run; frees the shared macOS runners.
+ # A run in progress finishes; a newer push replaces only the queued run (frequent merges
+ # to develop otherwise cancelled every run before it reported).
group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: false
jobs:
compile:
@@ -129,7 +130,7 @@ jobs:
# `-PrequireWasmRuntime` a missing wheel is a failure, not a skip, so fetch them first: by URL,
# each accepted only against the sha256 the script pins.
- name: Fetch the pinned wasm wheels
- run: bash tools/wasm/build-cpython.sh wheels
+ run: bash python-multiplatform/scripts/wasm/build-cpython.sh wheels
# `-PrequireWasmRuntime` is the second half of the guard, and it is what makes this job unable
# to repeat the original defect: if either task would skip -- no interpreter, or no
diff --git a/.gitignore b/.gitignore
index 670457d97..70924b2d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,8 +49,11 @@ google-services.json
# subagent worktrees live inside the repo
.worktrees/
-# repo-local caches: emsdk, the CPython wasm build and its staged runtime (tools/wasm/build-cpython.sh)
+# repo-local caches: emsdk, the CPython wasm build and its staged runtime (python-multiplatform/scripts/wasm/build-cpython.sh)
.caches/
# scratch space for agents and scripts; delete when done
.tmp/
+
+# Local Claude Code settings: per machine, not part of the repository.
+.claude/
diff --git a/LICENSE b/LICENSE
index 958f105ed..57bc88a15 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,202 @@
-MIT License
-
-Copyright (c) 2024 thisisthepy
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
diff --git a/README.md b/README.md
index b4a7106f8..500f6086e 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,9 @@ English | [한국어](docs/locale/README_ko.md)
# 🐍 python-multiplatform
-**Real CPython inside Kotlin Multiplatform — Kotlin calls Python, Python calls Kotlin, on every target.**
+**Real CPython inside Kotlin Multiplatform, Kotlin calls Python, Python calls Kotlin, on every target.**
-[](LICENSE)
+[](LICENSE)
[](https://kotlinlang.org/docs/multiplatform.html)
[](https://www.python.org/)
[-lightgrey.svg)](#-platforms)
@@ -21,31 +21,31 @@ English | [한국어](docs/locale/README_ko.md)
## 💡 Why
-Python has the libraries; Kotlin Multiplatform has the reach — desktop, Android, iOS, the web.
+Python has the libraries; Kotlin Multiplatform has the reach, desktop, Android, iOS, the web.
python-multiplatform puts **the real CPython interpreter** inside a Kotlin Multiplatform app and
builds a two-way bridge across it:
- Kotlin gets a **typed, Kotlin-idiomatic object model** over Python: a `PyList` *is* a `MutableList`,
a `PyDict` *is* a `MutableMap`, a Python error *is* a `Throwable`.
- Python gets **Kotlin classes as ordinary Python**: `Greeter('Kotlin').greet(2)`, `await` on a
- `suspend fun`, Kotlin extension functions as methods — all through a table generated at build time,
+ `suspend fun`, Kotlin extension functions as methods, all through a table generated at build time,
so it works where reflection does not: Kotlin/Native and GraalVM native images.
It is CPython itself, not a re-implementation, so C extensions keep working.
## ✨ Features
-- 🔌 **Downcalls** — the CPython Stable ABI (~330 functions) as one `expect` surface with an `actual`
+- 🔌 **Downcalls**: the CPython Stable ABI (~330 functions) as one `expect` surface with an `actual`
per platform: Panama `invokeExact` on desktop, `RegisterNatives` JNI on Android, cinterop on Native.
-- 🧩 **Object model** — `PyObject`, `PyType`, `PyException`, basic types, `PyList` / `PyDict` /
+- 🧩 **Object model**: `PyObject`, `PyType`, `PyException`, basic types, `PyList` / `PyDict` /
`PySet` / `PyTuple` implementing Kotlin collection interfaces, modules, callables, conversion.
-- 🚀 **Upcalls** — a KSP processor generates a function table; Python constructs Kotlin classes, calls
+- 🚀 **Upcalls**: a KSP processor generates a function table; Python constructs Kotlin classes, calls
methods, reads and writes properties, and `await`s `suspend` functions with cancellation.
-- 📦 **Bind prebuilt libraries** — the Gradle plugin walks jars (Compose included) and generates the
+- 📦 **Bind prebuilt libraries**: the Gradle plugin walks jars (Compose included) and generates the
bindings and `.pyi` stubs, with default-argument omission and value-class support.
-- ♻️ **No manual memory management** — wrappers release their Python reference when collected; reference
+- ♻️ **No manual memory management**: wrappers release their Python reference when collected; reference
cycles that cross the boundary are collected by Python's cyclic GC.
-- 🧪 **Measured** — every boundary mechanism ships with overhead tests; the cost table is rendered from
+- 🧪 **Measured**: every boundary mechanism ships with overhead tests; the cost table is rendered from
real runs ([cost table](docs/investigations/cost-table.md)).
## 🚀 Quick start
@@ -118,7 +118,7 @@ name, unchanged.
### Platform setup
-Desktop — apply the Gradle plugin so PYTHONHOME has a stdlib
+Desktop, apply the Gradle plugin so PYTHONHOME has a stdlib
```kotlin
plugins {
@@ -138,7 +138,7 @@ prefix with `-Dpython.multiplatform.home=...`. See `docs/platforms/desktop-packa
-Android — call PythonBootstrap.initialize
+Android, call PythonBootstrap.initialize
```kotlin
import python.multiplatform.env.PythonBootstrap
@@ -172,7 +172,7 @@ flowchart LR
T --> K
```
-Behind each platform's single entry point, **every platform uses the same generated table** — there is
+Behind each platform's single entry point, **every platform uses the same generated table**, there is
no per-platform Python → Kotlin binding code. Details: [design notes](docs/design/).
## 🌍 Platforms
@@ -188,7 +188,7 @@ no per-platform Python → Kotlin binding code. Details: [design notes](docs/des
## 📊 Status
-Honest summary — see the [roadmap](docs/roadmap/ROADMAP.md) for what is left to do.
+Honest summary, see the [roadmap](docs/roadmap/ROADMAP.md) for what is left to do.
| Area | State |
|---|---|
@@ -213,10 +213,10 @@ Honest summary — see the [roadmap](docs/roadmap/ROADMAP.md) for what is left t
## 📖 Documentation
-- 🌐 **[Guide](https://thisisthepy.github.io/python-multiplatform/)** (English / 한국어) — source in [`docs/guide/`](docs/guide/)
-- 🏗 [Design notes](docs/design/) — architecture, downcalls, upcalls, lifetime, threading, binding policy
-- 📱 [Platform notes](docs/platforms/) — Android FFM, wasm, CPython acquisition, GraalVM verification
-- 🔬 [Investigations](docs/investigations/) — measurements and audits
+- 🌐 **[Guide](https://thisisthepy.github.io/python-multiplatform/)** (English / 한국어), source in [`docs/guide/`](docs/guide/)
+- 🏗 [Design notes](docs/design/), architecture, downcalls, upcalls, lifetime, threading, binding policy
+- 📱 [Platform notes](docs/platforms/), Android FFM, wasm, CPython acquisition, GraalVM verification
+- 🔬 [Investigations](docs/investigations/), measurements and audits
- 🗺 [Roadmap](docs/roadmap/ROADMAP.md)
- 🇰🇷 [README in Korean](docs/locale/README_ko.md)
@@ -229,4 +229,4 @@ before opening a pull request. Building iOS targets requires macOS with Xcode.
## 📄 License
-[MIT](LICENSE) © 2024 thisisthepy
+[Apache-2.0](LICENSE) © 2024 thisisthepy
diff --git a/benchmarks/cost_table.py b/benchmarks/cost_table.py
index a854c36c4..c62da078e 100644
--- a/benchmarks/cost_table.py
+++ b/benchmarks/cost_table.py
@@ -749,7 +749,7 @@ def _samples(runs: list[dict[str, Any]], target: str, source: str, key: str,
def _fmt_range(values: list[float], unit: str) -> str:
if not values:
- return "—"
+ return ", "
lo, hi = min(values), max(values)
suffix = "x" if unit == "x" else " ns"
if abs(hi - lo) < 0.005:
@@ -780,7 +780,7 @@ def render_markdown(report: dict[str, Any]) -> str:
targets = report.get("targetsRequested") or DEFAULT_TARGETS
lines: list[str] = []
- dirty = " **(working tree dirty — these numbers are not reproducible from the commit alone)**" \
+ dirty = " **(working tree dirty, these numbers are not reproducible from the commit alone)**" \
if report.get("workingTreeDirty") else ""
lines.append(f"_Generated by `benchmarks/cost_table.py` from "
f"`{report.get('sourceFile', 'the newest JSON')}`._")
@@ -815,23 +815,23 @@ def render_markdown(report: dict[str, Any]) -> str:
host = report["host"]
warm = report.get("benchmarkTestWarmup", {})
lines += [
- f"- **commit** `{report['commit'][:12]}` — {report.get('commitSubject', '')}{dirty}",
+ f"- **commit** `{report['commit'][:12]}`, {report.get('commitSubject', '')}{dirty}",
f"- **branch** `{report.get('branch', '?')}`, worktree `{report.get('worktree', '?')}`",
f"- **captured** {report['capturedAt']}"
- + (" — the first of several cuts; each one's own time is in the source table below"
+ + (", the first of several cuts; each one's own time is in the source table below"
if len(report.get("sources") or []) > 1 else ""),
f"- **host** {host.get('cpu', '?')}, {host.get('cores', '?')} cores, "
f"{host.get('os', '?')} {host.get('machine', '')}",
f"- **load average** {report.get('loadAtStart')} at start, {report.get('loadAtEnd')} at end "
f"(ceiling {report['loadCeiling']}, guard `{report.get('loadGuard')}`)"
- + (" — of the first cut only; per-cut figures below"
+ + (", of the first cut only; per-cut figures below"
if len(report.get("sources") or []) > 1 else ""),
- f"- **runs per target** {report['runsRequested']} — ranges below are min–max over them",
+ f"- **runs per target** {report['runsRequested']}, ranges below are min–max over them",
f"- **`UpcallBoundaryCostTest`** warmup and iteration counts are self-reported per target "
"in the table below (the test prints them)",
f"- **`BenchmarkTest`** warmup, read from the source at this commit: "
f"`WARMUP` = {warm.get('WARMUP', '?')}, `WARMUP_BULK` = {warm.get('WARMUP_BULK', '?')} "
- "(the ≥4096-char string rows use the second — see that file's own caveat)",
+ "(the ≥4096-char string rows use the second, see that file's own caveat)",
"- **suite scope** full task, never `--tests`-filtered: narrowing the suite changes what is "
"being measured",
"",
@@ -839,7 +839,7 @@ def render_markdown(report: dict[str, Any]) -> str:
sources = report.get("sources") or []
if len(sources) > 1:
- lines += ["This table is merged from several cuts — which target came from which:", ""]
+ lines += ["This table is merged from several cuts, which target came from which:", ""]
lines += _table(
["cut", "commit", "captured", "load at start", "contaminated", "targets"],
[[f"`{s['file']}`", f"`{s['commit']}`", str(s.get("capturedAt")),
@@ -875,16 +875,16 @@ def render_markdown(report: dict[str, Any]) -> str:
device = sim
wall = "/".join(str(r.get("wallSeconds", "?")) for r in mine)
counts = (f"{suite.get('tests', '?')}/{suite.get('failures', '?')}/{suite.get('skipped', '?')}"
- if suite else "—")
+ if suite else ", ")
rows.append([
f"`{name}`",
f"`{target.task}`" if target else "?",
status if not reason else f"**{status}**",
- device or ("—" if not (target and target.avd) else "—"),
- platform_name or "—",
+ device or (", " if not (target and target.avd) else ", "),
+ platform_name or ", ",
counts,
- wall or "—",
- conditions or "—",
+ wall or ", ",
+ conditions or ", ",
])
lines += _table(
["target", "task", "status", "device / simulator", "platform, self-reported",
@@ -895,7 +895,7 @@ def render_markdown(report: dict[str, Any]) -> str:
notes = [(n, r) for n, r in notes if r]
if notes:
lines += ["**Targets that did not produce numbers, and why:**", ""]
- lines += [f"- `{n}` — {r}" for n, r in notes]
+ lines += [f"- `{n}`, {r}" for n, r in notes]
lines.append("")
# ---- upcall --------------------------------------------------------------------
@@ -913,7 +913,7 @@ def render_markdown(report: dict[str, Any]) -> str:
lines += _table(["target"] + [c for c, _ in UPCALL_COLUMNS], rows)
lines.append("")
- lines += ["Controls, from the same block — these are what say whether a row moved because the "
+ lines += ["Controls, from the same block, these are what say whether a row moved because the "
"boundary moved or because the whole host did:", ""]
rows = []
for name in targets:
diff --git a/benchmarks/rn-benchmark/COMPARISON.md b/benchmarks/rn-benchmark/COMPARISON.md
index b3a8d446f..fb95f338c 100644
--- a/benchmarks/rn-benchmark/COMPARISON.md
+++ b/benchmarks/rn-benchmark/COMPARISON.md
@@ -14,11 +14,11 @@ its numbers are read, not reproduced.
`docs/cost-table.md`, whose `iosSimulatorArm64` row was captured at **`958c0082b294`** at load
[1.97, 2.29, 5.92], marked uncontaminated, from three runs. §4b and §1c use it rather than
`docs/design/upcall.md` because it is the only reference-side source that publishes an iOS upcall, its
-same-run pure-Python callee control and its `withPython` scope figure together — the three cells the
+same-run pure-Python callee control and its `withPython` scope figure together, the three cells the
comparison needs from one execution. Its upcall figure (2253.20–2312.97 ns) and §4a's quoted
2266–2301 ns are close but **are not the same measurement**; neither is derived from the other.
-**The iOS column is now measured** — `results/ios-20260814-200903.txt`, iPhone 17 Pro simulator on
+**The iOS column is now measured**, `results/ios-20260814-200903.txt`, iPhone 17 Pro simulator on
iOS 26.2, load average 3.79 at the start and 3.33 at the end, under the 4.0 ceiling. §7, which used
to say iOS could not be built on this machine, was **wrong about why**, and the correction is
recorded there rather than deleted. **The Android column is still 미측정** (unmeasured / 인용 불가):
@@ -29,7 +29,7 @@ recorded there rather than deleted. **The Android column is still 미측정** (u
Read the iOS column with one caveat the run states about itself, and only one: its **Hermes JS-only
baseline did not settle** far enough before the warmup (1.11x margin against a 1.5x bar), so the JS
-rows and every ratio that divides by one carry that. **The boundary rows do not** — the boundary
+rows and every ratio that divides by one carry that. **The boundary rows do not**, the boundary
sweep settled after 10 000 calls with a 10x margin, which is what an AOT callee with no tier-up
should do, and the absolute figures do not depend on the JS baseline at all. §4b is the table that
matters and it is made of absolute nanoseconds for exactly this reason.
@@ -57,7 +57,7 @@ the seam, and gets a value back before the next bytecode runs. Neither queues, n
neither involves a scheduler.
The correspondence goes deeper than the description. Both projects pay a **different number of
-boundaries per platform, and it is the same difference on the same platforms** — established here
+boundaries per platform, and it is the same difference on the same platforms**, established here
by reading generated code rather than documentation:
| | React Native | PythonMultiplatform |
@@ -90,13 +90,13 @@ crossing-plus-scheduler-plus-microtask-drain.
| direction | RN | ours | comparable? |
|---|---|---|---|
-| script → host, synchronous | sync TurboModule method (returns a value) | upcall (`trampoline.add`) | **yes — this is the comparison** |
+| script → host, synchronous | sync TurboModule method (returns a value) | upcall (`trampoline.add`) | **yes, this is the comparison** |
| host → script, synchronous | *does not exist for app code* | downcall (`PyObject_CallObject`) | **no** |
| host → script, asynchronous | `Callback` / `Promise` round trip | (no equivalent; the reference project has no async downcall path) | **no, in the other direction** |
| fire-and-forget script → host | `void` TurboModule method | (no equivalent) | no |
-A JSI-level synchronous native → JS call *is* possible — `jsi::Function::call` from a C++
-TurboModule while on the JS thread — and that would be the true mirror. It needs a pure C++ module
+A JSI-level synchronous native → JS call *is* possible, `jsi::Function::call` from a C++
+TurboModule while on the JS thread, and that would be the true mirror. It needs a pure C++ module
rather than the Kotlin/ObjC one built here. Recorded as the next thing to add, not done.
### 1c. Row for row: what each row here must be read against, and on which ratio
@@ -112,17 +112,17 @@ Reference-side sources: `UpcallBoundaryCostTest` (`commonTest`, quoted through
| row here | what it charges | reference-project counterpart | same direction? | comparable? |
|---|---|---|---|---|
-| `addInts(3, 4)` | JS → native sync crossing, two doubles in, one out | **`trampoline.add`, "upcall to Kotlin"** — two ints in, one out, driven from Python | yes, script → host | **yes. This is the row the cross-project comparison is made on.** |
-| `zeroArgs()` | the same crossing with no arguments | *none.* The nearest is `UpcallTrampoline.invoke` with the GIL held, but that is the marshalling **with the boundary removed**, not the boundary with the marshalling removed — the opposite subtraction | yes | only as `addInts − zeroArgs`, i.e. what two numbers cost. The absolute value has nothing to sit beside |
-| `stringLength(8 / 128)` | one string conversion inward | `PyUnicode_AsUTF8 (8 chars)` in `overhead/BenchmarkTest` (78–84 ns desktop at the settled warmup) | **no** — that is a downcall | shape only. Do not put them in one table cell |
+| `addInts(3, 4)` | JS → native sync crossing, two doubles in, one out | **`trampoline.add`, "upcall to Kotlin"**, two ints in, one out, driven from Python | yes, script → host | **yes. This is the row the cross-project comparison is made on.** |
+| `zeroArgs()` | the same crossing with no arguments | *none.* The nearest is `UpcallTrampoline.invoke` with the GIL held, but that is the marshalling **with the boundary removed**, not the boundary with the marshalling removed, the opposite subtraction | yes | only as `addInts − zeroArgs`, i.e. what two numbers cost. The absolute value has nothing to sit beside |
+| `stringLength(8 / 128)` | one string conversion inward | `PyUnicode_AsUTF8 (8 chars)` in `overhead/BenchmarkTest` (78–84 ns desktop at the settled warmup) | **no**, that is a downcall | shape only. Do not put them in one table cell |
| `echoString(8 / 128)` | two string conversions | same as above, doubled | **no** | shape only |
-| `sumArray(8 / 1000)` | JSI Array → `ReadableArray`, eager, per element | `list → LongArray`, 1000 elements: 50065.89 → 4532.55 ns after composition | **no** — downcall | **slope only.** The per-element slope is the transferable quantity; the intercepts are not |
-| `sumObject(3 keys)` | JSI Object → `ReadableMap` | `getAttr` composition, 3929.84 → 713.34 ns | **no** — downcall | slope/composition ratio only |
-| `nativeLoopNs(n)` | the callee with the crossing removed, native clock | `_pm_time_loop` (empty Python loop) and `_pm_py_add` (pure-Python callee), both timed inside Python | n/a — both are floors | **yes in role**, not in value: each says "what this side costs before anything crosses" |
+| `sumArray(8 / 1000)` | JSI Array → `ReadableArray`, eager, per element | `list → LongArray`, 1000 elements: 50065.89 → 4532.55 ns after composition | **no**, downcall | **slope only.** The per-element slope is the transferable quantity; the intercepts are not |
+| `sumObject(3 keys)` | JSI Object → `ReadableMap` | `getAttr` composition, 3929.84 → 713.34 ns | **no**, downcall | slope/composition ratio only |
+| `nativeLoopNs(n)` | the callee with the crossing removed, native clock | `_pm_time_loop` (empty Python loop) and `_pm_py_add` (pure-Python callee), both timed inside Python | n/a, both are floors | **yes in role**, not in value: each says "what this side costs before anything crosses" |
| JS-only rows (`jsRows.js`) | the language floor: loop, call, array, object | `_pm_time_loop` 8–9 ns and `_pm_py_add` 24–27 ns on desktop | n/a | **yes, and both projects require them from the same run as the boundary row** |
| `noop()` (`void`) | an enqueue onto the module's method queue | *none* | fire-and-forget | **no** |
-| callback / Promise round trips | crossing + `RuntimeScheduler` hop + microtask drain | *none* — the reference project has no async downcall path | host → script, async | **no** |
-| — | | `Python3.withPython { }`, empty: **50–57 ns** desktop, **276–303 ns** ART `pmp_api36` | charged to every reference crossing in both directions | **no counterpart here at all.** React Native has no GIL |
+| callback / Promise round trips | crossing + `RuntimeScheduler` hop + microtask drain | *none*, the reference project has no async downcall path | host → script, async | **no** |
+| - | | `Python3.withPython { }`, empty: **50–57 ns** desktop, **276–303 ns** ART `pmp_api36` | charged to every reference crossing in both directions | **no counterpart here at all.** React Native has no GIL |
#### The ratio that actually transfers, and it is not the one either table leads with
@@ -132,7 +132,7 @@ the other would be the exact mistake this section exists to prevent: React Nativ
host → script call to be the denominator of the first (§1b), so that ratio cannot be formed here.
What *can* be formed on both sides is **boundary cost ÷ the script language's own call of the same
-shape** — `addInts / jsCall` here, `upcall / _pm_py_add` there. Both are a pure number, both come
+shape**, `addInts / jsCall` here, `upcall / _pm_py_add` there. Both are a pure number, both come
from one run, and both answer "what multiple of a normal call in this language does crossing cost".
Computed here from two rows the reference project publishes from the same runs (it publishes the
@@ -145,7 +145,7 @@ rows, not this ratio):
| iOS simulator (`docs/cost-table.md` @ `958c0082`) | 2253.20–2312.97 ns | 36.80–38.32 ns | **58.8–62.9x** |
Two very different platforms landing in the same band is what made this worth using as the
-cross-project quantity — **and the iOS row, added once it could be measured, does not land in that
+cross-project quantity, **and the iOS row, added once it could be measured, does not land in that
band.** 59–63x against 19–23x is not a measurement problem: the reference project's iOS upcall is
genuinely ~4x its desktop one while CPython's own call barely moves, so the ratio moves with it.
Whatever this quantity normalises away, it does not normalise away the platform.
@@ -158,11 +158,11 @@ So on the one platform where both sides exist, the ratio says RN's boundary is t
relative to its own language (82–84x against 59–63x) while the absolute nanoseconds say the opposite
(1.10 µs against 2.25–2.31 µs). **Both are true and §4b is where they are reconciled.** The reason
is the warning immediately below, arriving exactly as predicted: the denominators are *not* within
-30% of each other here — CPython's call is 2.8x Hermes' — so this ratio has stopped transferring on
+30% of each other here, CPython's call is 2.8x Hermes', so this ratio has stopped transferring on
this platform, and the absolute table is the one to read.
> **Read the denominator before the ratio.** Hermes' function call is ~33 ns and CPython's is
-> ~25 ns on desktop, so the two denominators are within about 30% of each other — which is the only
+> ~25 ns on desktop, so the two denominators are within about 30% of each other, which is the only
> reason this ratio is meaningful across the projects at all. If the RN column were measured under
> JSC or V8 (~4–10 ns) the same boundary would show a 3–8x larger ratio without anything about the
> boundary changing. §6.5 is the long form of this.
@@ -194,13 +194,13 @@ stated convention.
---
-## 3. What warmup React Native needs — measured, and the answer is not the JVM's
+## 3. What warmup React Native needs, measured, and the answer is not the JVM's
The reference project's 100 000-call warmup is not a round number: its docs record a 40-repetition
sweep in which the JVM read 1307, 716, 696, 660, 548 and then flattened at ~535 ns per 10 000
calls, converging on the same plateau from the warm side too, and it attributes that to host JIT
tier-up (C2 on the JVM, V8's tiering on wasm). Its pure-Python control rows, by contrast, were flat
-from the first repetition — which is what identified the warming as host-side rather than
+from the first repetition, which is what identified the warming as host-side rather than
CPython-side.
**The same sweep was run here on Hermes and on V8 from the same source file.** Both series are 40
@@ -226,7 +226,7 @@ V8, cold: 18.08 14.93 2342.30 28.74 20.68 20.64 20.69 20.63
**Findings, stated separately from what they do and do not license:**
-1. **Hermes does not tier up.** Eighty consecutive readings, cold and warm, span 32.47–32.96 ns —
+1. **Hermes does not tier up.** Eighty consecutive readings, cold and warm, span 32.47–32.96 ns,
a 1.5% band, with the first reading inside it. There is no plateau to reach because there is no
compilation tier to reach it from: Hermes ships without a JIT. **The reference project's reason
for warming 100 000 calls does not exist on this engine.**
@@ -235,7 +235,7 @@ V8, cold: 18.08 14.93 2342.30 28.74 20.68 20.64 20.69 20.63
point), and an earlier run of the identical script stepped down *further*, to 0.58 ns, at a
different repetition. One reading in the series above is 2342.30 ns. V8's spread across
repetitions is larger than most of the effects this benchmark is trying to measure.
-3. **The warmup stays at 100 000**, and the reason is stronger than the two originally given here
+3. **The warmup stays at 100 000**: and the reason is stronger than the two originally given here
(that the engine is a build-time choice, and that the warmup is free on Hermes anyway). Both are
true, but they are "in case" arguments. The real reason is in 3a below: **this sweep only prices
half of the boundary, and the half it cannot reach is the half that tiers up.**
@@ -247,13 +247,13 @@ V8, cold: 18.08 14.93 2342.30 28.74 20.68 20.64 20.69 20.63
**Caveat, and it is a real one.** The only standalone Hermes VM obtainable for this machine is
`hermes-engine-cli@0.12.0` (2022-era, RN 0.68 vintage), while the app ships RN 0.87's much newer
Hermes. Absolute ns figures from the host binary must **not** be quoted as the app's. What survives
-the version gap is the *structural* finding — no JIT, therefore no tier-up, therefore no warmup
-curve — and that has been Hermes' design throughout.
+the version gap is the *structural* finding, no JIT, therefore no tier-up, therefore no warmup
+curve, and that has been Hermes' design throughout.
### 3a. The sweep above prices half the boundary, and the other half is on ART
**"Hermes has no JIT, so no warmup is needed" does not follow, and on Android it is wrong.** The
-reference project's warmup exists because of *host JIT tier-up* — its own words: "C2 on the JVM,
+reference project's warmup exists because of *host JIT tier-up*, its own words: "C2 on the JVM,
V8's tiering on wasm". The question is therefore not "does the script engine tier up" but "does
**anything on the call path** tier up". On Android, the call path is:
@@ -273,16 +273,16 @@ in its entire table** (`docs/design/upcall.md` §7.2):
| androidNative `pmp_api36` | 1.27x | ~40 000 | 2.5x |
| androidNative `pmp_api26` | 1.30x | ~30 000 | 3.3x |
| ART `pmp_api26` | 1.93x | ~20 000, noisily | 5x |
-| **ART `pmp_api36`** | **8.56x** | **~90 000–100 000** | **~1x — none** |
+| **ART `pmp_api36`** | **8.56x** | **~90 000–100 000** | **~1x, none** |
Its `pmp_api36` ART series reads 9194.8, 3218.9, 2288.7, 1648.3, 1218.3, 1173.3, 1129.5, 1532.7,
-1057.8, 1044.6 against a plateau of 1074.2 — **still 9% high at 70 000 calls**, and note the 1532.7
+1057.8, 1044.6 against a plateau of 1074.2, **still 9% high at 70 000 calls**, and note the 1532.7
at repetition 8, after four readings that were already closer. A series that dips toward the plateau
and back out has not settled.
So the RN Android boundary rows sit on the same curve, for the same reason, on the same runtime, at
the same API level. **100 000 is not margin here; it is roughly exactly enough**, and that is the
-finding — not "Hermes needs no warmup".
+finding, not "Hermes needs no warmup".
**Two things follow, and both were wrong before this pass:**
@@ -296,7 +296,7 @@ finding — not "Hermes needs no warmup".
computes, from that run's own sweeps, where the series settled and what margin 100 000 has over
it. If the margin is under 1.5x, or the series never settles within ±5%, the report says in
as many words that its own boundary rows must not be quoted. The threshold is printed with the
- answer because the answer depends on it — at ±5% the reference ART series settles at ~90 000
+ answer because the answer depends on it, at ±5% the reference ART series settles at ~90 000
and at ±10% at ~50 000, and a settle point quoted without its band is not reproducible.
**The iOS half is now answered, and it lands on top of the reference project's iOS row:**
@@ -306,7 +306,7 @@ finding — not "Hermes needs no warmup".
| reference project, iOS simulator (quoted, top of this section) | 1.01x | ~10 000 | 10x |
| **RN `addInts` through the TurboModule, iOS simulator (measured today)** | **1.05x** | **10 000** | **10.00x** |
-Two different projects, two different script engines, two different host languages — and the same
+Two different projects, two different script engines, two different host languages, and the same
settling behaviour on the same platform, because on iOS *neither* project has a tiering runtime on
the far side of the boundary. That is the control that makes §3a's Android argument falsifiable
rather than merely plausible: the claim is that ART's tier-up is what forces the large warmup, and
@@ -315,7 +315,7 @@ the platform without ART shows no warm-up curve at all in either project.
**What is still not known:** whether RN 0.87's Hermes, on the shipping build, behaves like the
2022 standalone binary, and where the ART-backed boundary row actually settles on this hardware.
Both need one emulator and one `./run-bench.sh android`. **Neither has been measured**, and the iOS
-result above says nothing about them — it is the arm of the experiment where the effect is expected
+result above says nothing about them, it is the arm of the experiment where the effect is expected
to be absent. The 100 000 is still carried over from the reference project's ART measurement, which
is a defensible prior and not a result.
@@ -328,7 +328,7 @@ sweep did not settle within a 1.5x margin on either iOS run (1.11x and 1.43x, at
^ one repetition, and it sets "flat from 90 000"
```
-The first repetition is **1.02x the plateau** — flat from the start. What sets the settle point is a
+The first repetition is **1.02x the plateau**, flat from the start. What sets the settle point is a
**single 15.37 ns excursion at repetition 9**, and `analyzeSweep` reports the *last* reading outside
±5%, so one outlier anywhere early moves "flat from" to its position. The step down to ~13.57 in the
middle third and back to ~14.1 by the end is frequency scaling, not warming: a series that warms
@@ -337,8 +337,8 @@ does not go back up.
**So read "flat from" as an upper bound that a single noisy repetition can dominate, not as a
measured settle point.** The same fragility bit the boundary sweep on the load-5.8 run, where a
spike at repetition 30 produced "flat from 320 000" for a series whose first repetition was 0.99x
-its plateau. The metric is left as it is — it is the reference project's, and changing it here would
-break the comparison — but the sweep series is printed in full in every report precisely so this is
+its plateau. The metric is left as it is, it is the reference project's, and changing it here would
+break the comparison, but the sweep series is printed in full in every report precisely so this is
checkable rather than taken on trust.
The practical consequence: **a quiet machine matters more for the JS baseline than for the boundary
@@ -373,7 +373,7 @@ project's Android column is compared against**, and it is the one that was most
under-warmed.
‡ and ※ mark rows where upstream kept a single outlying run rather than dropping it; see the source
-document. `wasmJs`'s absolute column is only meaningful as a full-suite figure — its ratio column is
+document. `wasmJs`'s absolute column is only meaningful as a full-suite figure, its ratio column is
the quotable one there.
Per-crossing FFI cost, once the desktop path was fixed (`docs/downcall-design.md`):
@@ -387,7 +387,7 @@ Per-crossing FFI cost, once the desktop path was fixed (`docs/downcall-design.md
The language floor those sit on, **corrected**: on desktop an empty Python loop is 8–9 ns and a
pure-Python callee of the same shape as the upcall is **24–27 ns**; on wasmJs they are 14–16 ns and
47–54 ns (`docs/downcall-design.md`). On ART `pmp_api36` they are 17.3 ns and 49.7–50.4 ns.
-**Both are flat from the first 10 000 calls on every host and device** — the control that identified
+**Both are flat from the first 10 000 calls on every host and device**, the control that identified
the warming as host-side rather than CPython-side.
> An earlier version of this line gave the desktop pure-Python callee as 8.4–8.6 ns. That is the
@@ -404,7 +404,7 @@ native → JS direction has no cell here at all.
| | React Native (this repo) | PythonMultiplatform (quoted) |
|---|---|---|
-| the call | `Bench.addInts(3, 4)` — JS → ObjC++ TurboModule | Python → Kotlin upcall |
+| the call | `Bench.addInts(3, 4)`, JS → ObjC++ TurboModule | Python → Kotlin upcall |
| **cost of one crossing** | **1101.28 – 1119.40 ns** | **2253.20 – 2312.97 ns** |
| the script language's own call, same shape, same run | 13.37 – 13.39 ns (Hermes) | 36.80 – 38.32 ns (CPython) |
| crossing ÷ that call | 82.25 – 83.72x | 58.80 – 62.85x |
@@ -417,11 +417,11 @@ native → JS direction has no cell here at all.
Two things must be said about that number, in this order.
**It is not a like-for-like verdict on the two designs.** §1a establishes that on iOS *neither*
-project pays a second managed-runtime crossing — RN does one ObjC message send, ours calls a
-`staticCFunction` in the same binary — so the 2x is not "one extra boundary". What it prices is the
+project pays a second managed-runtime crossing, RN does one ObjC message send, ours calls a
+`staticCFunction` in the same binary, so the 2x is not "one extra boundary". What it prices is the
two runtimes' cost of *arriving at* their host function: CPython's calling convention, argument
tuple and GIL discipline against Hermes' JSI dispatch. The reference project's own controls locate
-most of ours: an empty `withPython` scope alone is **701.18 – 723.85 ns** on this target — **63 – 66%
+most of ours: an empty `withPython` scope alone is **701.18 – 723.85 ns** on this target, **63 – 66%
of RN's entire `addInts` call**, spent by us before a single argument is converted. That, not the
crossing, is where the 2x lives.
@@ -429,14 +429,14 @@ crossing, is where the 2x lives.
CPython calls' worth of crossing; RN's is 82.3 – 83.7 Hermes calls' worth. Ours is cheaper *relative
to its own language* and dearer *in nanoseconds*, because CPython's own call (36.8 – 38.3 ns) is
about 2.8x Hermes' (13.4 ns). §1c is explicit that this ratio only transfers between projects while
-the two denominators are close, and here they differ by nearly 3x — so **the absolute row is the
+the two denominators are close, and here they differ by nearly 3x, so **the absolute row is the
representative one and the ratio row is the supporting one**, not the reverse. A reader who quotes
only the ratio will conclude ours is the cheaper boundary, which on this platform it is not.
> The ratio row inherits the run's own caveat: the Hermes baseline sweep did not settle (1.11x
-> margin). The absolute RN row does not — its sweep settled at 10 000 calls with 10x margin.
+> margin). The absolute RN row does not, its sweep settled at 10 000 calls with 10x margin.
-### 4c. React Native — measured on iOS, 미측정 on Android
+### 4c. React Native, measured on iOS, 미측정 on Android
The iOS column is filled from `results/ios-20260814-200903.txt`. Ranges are min–max over 5
repetitions inside that single process; nothing is averaged across runs and nothing is discarded.
@@ -448,18 +448,18 @@ The Android column is marked 미측정 (인용 불가): 2026-08-18/19에 수행
| empty JS loop | 미측정 | 13.68 – 14.08 ns |
| JS call, same shape as `addInts` | 미측정 | 13.37 – 13.39 ns † |
| **JS → native, synchronous** | | |
-| `zeroArgs()` — boundary only | 미측정 | 975.82 – 985.27 ns |
-| `addInts(3, 4)` — two numbers | 미측정 | **1101.28 – 1119.40 ns** |
-| `stringLength(8 chars)` — one conversion | 미측정 | 1376.30 – 1377.83 ns |
+| `zeroArgs()`, boundary only | 미측정 | 975.82 – 985.27 ns |
+| `addInts(3, 4)`, two numbers | 미측정 | **1101.28 – 1119.40 ns** |
+| `stringLength(8 chars)`, one conversion | 미측정 | 1376.30 – 1377.83 ns |
| `stringLength(128 chars)` | 미측정 | 1783.75 – 1844.78 ns |
-| `echoString(8 chars)` — two conversions | 미측정 | 1428.03 – 1479.65 ns |
+| `echoString(8 chars)`, two conversions | 미측정 | 1428.03 – 1479.65 ns |
| `echoString(128 chars)` | 미측정 | 1882.72 – 1916.37 ns |
| `sumArray(8 elements)` | 미측정 | 2024.79 – 2065.26 ns |
| `sumArray(1000 elements)` | 미측정 | 64584.08 – 65445.09 ns |
| `sumObject(3 keys)` | 미측정 | 2250.80 – 2270.83 ns |
| **controls** | | |
| `addInts` body, native loop, no crossing | 미측정 | 3.50 – 3.54 ns |
-| `noop()` — `void`, async dispatch, *not* a crossing | 미측정 | 1421.74 – 1514.50 ns |
+| `noop()`, `void`, async dispatch, *not* a crossing | 미측정 | 1421.74 – 1514.50 ns |
| **native → JS ROUND TRIPS (not comparable with the above)** | | |
| callback round trip | 미측정 | 13642.42 – 14032.71 ns |
| Promise round trip | 미측정 | 13868.88 – 14646.50 ns |
@@ -467,7 +467,7 @@ The Android column is marked 미측정 (인용 불가): 2026-08-18/19에 수행
| **convergence sweeps (cold, 40 x 10 000), see §3a** | | |
| JS-only row: settles after how many calls | 미측정 | 90 000 † |
| **`addInts` through the TurboModule: settles after how many calls** | 미측정 | **10 000** |
-| margin the 100 000 warmup has over that | 미측정 | JS-only **1.11x — too thin †**; boundary **10.00x — adequate** |
+| margin the 100 000 warmup has over that | 미측정 | JS-only **1.11x, too thin †**; boundary **10.00x, adequate** |
| **ratios, all terms from the same run** | | |
| **`addInts` / a JS call of the same shape** ← the cross-project ratio (§1c) | 미측정 | 82.25 – 83.72x † |
| `addInts` / `zeroArgs` (what two numbers cost) | 미측정 | 1.13x (116.01 – 143.58 ns) † |
@@ -475,25 +475,25 @@ The Android column is marked 미측정 (인용 불가): 2026-08-18/19에 수행
| `echoString(128)` / `echoString(8)` (per-character slope) | 미측정 | 1.32x |
† **carries the run's own caveat.** The Hermes JS-only sweep settled only at 90 000 calls against a
-100 000 warmup — a 1.11x margin, under the 1.5x bar the report applies — so the JS baseline row and
+100 000 warmup, a 1.11x margin, under the 1.5x bar the report applies, so the JS baseline row and
every ratio computed against it are marked. This is a statement about the *denominator*, not about
the boundary: the boundary sweep in the same run settled at 10 000 calls with a 10x margin, and its
first repetition was 1.05x the plateau. On iOS the callee is AOT-compiled Objective-C++ with no
tier-up, so there is no warm-up curve for it to climb; the JS-side drift (14.15 → 13.57 ns across
the sweep) has the shape of CPU frequency scaling on a machine that was not idle, not of a warmup
-that is too short. Re-running quieter is the fix, **not** raising `WARMUP` — see §3a for why that
+that is too short. Re-running quieter is the fix, **not** raising `WARMUP`, see §3a for why that
number has no room in it on the Android side.
**What the iOS column says on its own,** before any cross-project reading:
-- **The crossing dominates everything it carries.** `zeroArgs` — the boundary with no arguments at
- all — is 975.82 ns, which is 88% of `addInts`. Two integers cost 116 – 144 ns on top of it.
+- **The crossing dominates everything it carries.** `zeroArgs`, the boundary with no arguments at
+ all, is 975.82 ns, which is 88% of `addInts`. Two integers cost 116 – 144 ns on top of it.
Anything that reduces the *number* of calls pays; anything that reduces the *arguments* barely
does.
- **Array elements are priced like arguments, not like bulk data.** `sumArray` goes 2024.79 →
64584.08 ns from 8 to 1000 elements: **63.1 ns per element**, which is the same order as the
- 58 – 72 ns each of `addInts`' two integers costs. Strings are ~17x cheaper per unit —
- `echoString` at 8 → 128 characters is 1.32x, or **3.6 – 3.8 ns per character** — so a 1000-element
+ 58 – 72 ns each of `addInts`' two integers costs. Strings are ~17x cheaper per unit,
+ `echoString` at 8 → 128 characters is 1.32x, or **3.6 – 3.8 ns per character**, so a 1000-element
array is the row to avoid, and a long string is not.
- **The async round trips are ~12x a synchronous crossing** (13.6 – 14.6 µs against 1.10 µs) and
most of that is not the boundary: a bare `await Promise.resolve(1)` with no native code in it is
@@ -507,7 +507,7 @@ blended number: `sumArray` at 8 and 1000 separates the per-call boundary from th
conversion, and `echoString` at 8 and 128 does the same for characters. The reference project's
`getAttr` and `list → LongArray` composition rows are the equivalent measurement on its side.
-Host-side JS floor, **measured** (host binaries, not the app — see the caveat in section 3):
+Host-side JS floor, **measured** (host binaries, not the app, see the caveat in section 3):
| row | Hermes 0.12.0 | Node 24.19.0 / V8 13.6 |
|---|---|---|
@@ -533,7 +533,7 @@ and it is **not uniform, which is itself the finding**: on min-to-min,
| 1000-element array sum | 23.4x |
Hermes' bytecode dispatch is within striking distance of V8's on a bare loop and falls behind by an
-order of magnitude the moment property access and array indexing are involved — which is exactly
+order of magnitude the moment property access and array indexing are involved, which is exactly
what a JIT with shape feedback buys and an interpreter cannot. That shape matters for the boundary
rows still to be measured: the `sumArray` rows have the most JS-side work in them, so their *JS*
half is where Hermes is weakest, and a boundary cost read as a ratio against them will look
@@ -543,7 +543,7 @@ correspondingly small.
## 5. Size and startup
-### 5a. Size — measured on both sides, but they are not the same kind of artefact
+### 5a. Size, measured on both sides, but they are not the same kind of artefact
**Read this comparison as two different questions.** The reference project's 23.42 MB is a
*library AAR* that a consumer app adds; the RN figure is a *whole application*. They are placed
@@ -552,7 +552,7 @@ denominators differ and neither number should be lifted out of its row.
| | measured | note |
|---|---|---|
-| **PythonMultiplatform AAR** (release, 2 ABIs) | **23.42 MB** on disk, 22.04 MB of entries, 1740 files | after removing CPython's own test suite and C headers; was 40.10 MB. The library's own classes are **0.49 MB** of it — the rest is CPython plus its stdlib. `ROADMAP.md` §15d. |
+| **PythonMultiplatform AAR** (release, 2 ABIs) | **23.42 MB** on disk, 22.04 MB of entries, 1740 files | after removing CPython's own test suite and C headers; was 40.10 MB. The library's own classes are **0.49 MB** of it, the rest is CPython plus its stdlib. `ROADMAP.md` §15d. |
| **RN release APK** (arm64-v8a only, this app) | **17.71 MB** on disk, 24.80 MB raw | measured here: `./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a` |
| RN release APK (all four ABIs) | 51.56 MB on disk | measured here; this is what `run-bench.sh android` builds, since it does not know the device's ABI in advance. An App Bundle strips this per device; the AAR row above does **not** get that treatment for its `assets/`, which is the reference project's §15d finding. |
| RN debug APK (arm64-v8a only) | 38.84 MB on disk, 45.32 MB raw | measured here |
@@ -574,8 +574,8 @@ And what is inside ours, per `ROADMAP.md`'s breakdown of the pre-trim AAR (compr
`jni/` 5.09–5.31 MB, pure-Python stdlib 3.16 MB, `lib-dynload` 2.37–2.46 MB.
**The shapes are different in a way worth saying out loud.** RN's runtime cost is dominated by
-*engine and framework code* — 11.1 MB of dex plus 11.1 MB of native library, for 1.0 MB of actual
-application. Ours is dominated by *a language's standard library shipped as data* — 0.49 MB of
+*engine and framework code*, 11.1 MB of dex plus 11.1 MB of native library, for 1.0 MB of actual
+application. Ours is dominated by *a language's standard library shipped as data*, 0.49 MB of
library for ~23 MB of CPython. An RN app that grows adds JS bytecode at roughly 1 MB per
application; a PythonMultiplatform app that grows adds Python source, and the fixed CPython cost is
already paid.
@@ -584,8 +584,8 @@ already paid.
| | ours (measured, `ROADMAP.md`) | RN |
|---|---|---|
-| first launch | **237 ms** (API 26) / **480 ms** (API 36) — unpacking 804 files, 20 147 280 bytes of stdlib from assets | 미측정 |
-| every later launch | **4 ms** (API 26) / **28 ms** (API 36) — a stamp check | 미측정 |
+| first launch | **237 ms** (API 26) / **480 ms** (API 36), unpacking 804 files, 20 147 280 bytes of stdlib from assets | 미측정 |
+| every later launch | **4 ms** (API 26) / **28 ms** (API 36), a stamp check | 미측정 |
The RN counterpart to measure is time-to-first-render, and the structural expectation to test is
that it has **no equivalent of the first-launch unpack at all**: Hermes bytecode is `mmap`ed
@@ -597,7 +597,7 @@ by path, and RN's is one bytecode blob.
---
-## 6. What creates the cost — the part that transfers
+## 6. What creates the cost, the part that transfers
This is the section worth keeping. It does not need a device, because it is about mechanism.
@@ -609,8 +609,8 @@ shim on Android and an `ObjCTurboModule`-based one on iOS. Anything that reduces
on Android roughly twice as much as on iOS, in both projects.
**2. Ours pays for something RN has no analogue of: the GIL, on every single call.** The reference
-project measures an *empty* `Python3.withPython { }` scope — a GIL acquire/release with no C API
-call in it — at 50–57 ns on desktop, 24–28 ns on wasm, and 725–982 ns on the iOS simulator. That is
+project measures an *empty* `Python3.withPython { }` scope, a GIL acquire/release with no C API
+call in it, at 50–57 ns on desktop, 24–28 ns on wasm, and 725–982 ns on the iOS simulator. That is
charged to every crossing in both directions. React Native has no such lock: Hermes is
single-threaded per runtime and a synchronous TurboModule call is already on the JS thread, so
there is nothing to acquire. **A large part of what looks like "our boundary is expensive" is not
@@ -618,21 +618,21 @@ the boundary; it is a design constraint of the runtime being embedded.**
**3. RN pays for something we do not: everything crosses as a JSI value.** Our upcall passes a
`PyObject*` and the callee decides what to do with it; ownership is explicit and nothing is copied
-unless asked. An RN argument is converted at the seam — `jsi::String` to `NSString`/`jstring`,
-`jsi::Array` to `NSArray`/`ReadableArray` — and the conversion is eager and per element. This is
+unless asked. An RN argument is converted at the seam, `jsi::String` to `NSString`/`jstring`,
+`jsi::Array` to `NSArray`/`ReadableArray`, and the conversion is eager and per element. This is
exactly why `sumArray` is measured at 8 and at 1000 elements: the slope between them *is* that
cost, and it is the row most likely to differ by an order of magnitude from ours.
**4. Both projects' worst case is the same shape: many small crossings.** The reference project
-measured composition against per-call — `getAttr` 3929.84 → 713.34 ns, `list → LongArray` at 1000
-elements 50065.89 → 4532.55 ns — and found that the win was mostly *redundant work removed*, not
+measured composition against per-call, `getAttr` 3929.84 → 713.34 ns, `list → LongArray` at 1000
+elements 50065.89 → 4532.55 ns, and found that the win was mostly *redundant work removed*, not
crossings removed. RN's architectural answer to the same problem is the same one: batch at the
boundary. That agreement is worth more than either project's absolute numbers.
**5. The engine underneath changes what "overhead" means.** Hermes has no JIT, so its JS floor is
~33 ns per call where V8's is ~4–10 ns. A 200 ns boundary is a 6x tax on Hermes and a 20–50x tax on
V8. CPython's floor is 8–9 ns per bytecode loop iteration and **24–27 ns per pure-Python call** on
-desktop — i.e. *within about 30% of Hermes*, not far below it. **That near-coincidence is the only
+desktop, i.e. *within about 30% of Hermes*, not far below it. **That near-coincidence is the only
reason §1c's cross-project ratio means anything**; under JSC or V8 the same boundary would score
3–8x worse against its floor without anything about the boundary having changed. **Any cross-project
ratio must be read against the floor measured in the same run, which is why both harnesses measure
@@ -660,12 +660,12 @@ The `-showdestinations` output was real; the inference from it was not. That com
Xcode's **scheme-destination resolution**, which insists on a platform component matching the
*current* SDK. Building the target against `-sdk iphonesimulator` with no destination does not go
through that layer, and it works. The runtimes that were dismissed as "from older Xcode installs and
-Xcode 26.6 will not use them" — iOS 26.2 among them — are exactly the runtimes the app now runs on.
+Xcode 26.6 will not use them", iOS 26.2 among them, are exactly the runtimes the app now runs on.
**The lesson is about the test, not about Xcode.** `-showdestinations` was treated as a capability
probe for the machine when it is a resolution probe for a scheme, and a negative result from it was
generalised to "any project on this machine including CocoaPods' own targets". A capability claim
-that broad should have been falsified by attempting the build once — which costs minutes and is what
+that broad should have been falsified by attempting the build once, which costs minutes and is what
`build-ios.sh` now does. `run-bench.sh` no longer consults `-showdestinations` at all; the comment
in `cmd_ios` records why, so this cannot be reintroduced as a fast pre-check.
@@ -673,7 +673,7 @@ in `cmd_ios` records why, so this cannot be reintroduced as a fast pre-check.
Two `./run-bench.sh ios` runs **exited 0 with results files containing the conditions block and not
one benchmark row** (`results/ios-20260814-171508.txt`, 22 lines; `results/ios-20260814-183543.txt`,
-19 lines). The app built, installed and launched correctly in both, and — as far as can be told — ran
+19 lines). The app built, installed and launched correctly in both, and, as far as can be told, ran
the entire benchmark correctly in both. The reports were never captured.
**Cause: `log stream` defaults to `--level default`, which streams Default, Error and Fault and
@@ -682,7 +682,7 @@ silently drops Info and Debug.** React Native's iOS logger emits JS `console.log
messages had already been excluded from. logcat has no equivalent default filter, so the Android
path never had this problem and the harness had never needed to think about levels.
-**Confirmed by A/B on a single launch** — same app, same predicate, two `log stream` processes
+**Confirmed by A/B on a single launch**, same app, same predicate, two `log stream` processes
attached at once:
| `log stream` invocation | lines matching `RNBENCH\|` |
@@ -711,14 +711,14 @@ than a missing file: it looks like a measurement until someone reads it**, and i
| condition | verdict | exit |
|---|---|---|
| zero benchmark rows | `FAILED`, with the three causes in the order they have actually occurred here | non-zero |
-| rows but no `END` marker | `INCOMPLETE` — capture window too short, or the app died partway | non-zero |
-| no "all N rows returned a positive duration" | `SUSPECT` — look for `UNUSABLE ROWS` | non-zero |
+| rows but no `END` marker | `INCOMPLETE`, capture window too short, or the app died partway | non-zero |
+| no "all N rows returned a positive duration" | `SUSPECT`, look for `UNUSABLE ROWS` | non-zero |
| the run's own warmup verdict is `INSUFFICIENT` | `NOT QUOTABLE AS A WHOLE`, naming which sweep and therefore which rows | non-zero |
| otherwise | rows and `END` recorded in a `capture check` block | 0 |
A "row" is matched as `name | min | max | reps` with three numeric fields, not counted as lines: the
capture also yields `#` progress lines and prose, and a run that printed only progress before dying
-is exactly as empty as one that printed nothing. **Verified against all four states** — the two
+is exactly as empty as one that printed nothing. **Verified against all four states**, the two
empty files above return non-zero, a file with rows and `END` returns 0, and the same file with
`END` stripped returns non-zero.
@@ -730,7 +730,7 @@ warning eleven lines above it is the same failure as an empty file**, and it was
One further race was closed: the harness slept a fixed 2 seconds after starting `log stream` before
launching the app. It now waits for the stream's own `Filtering the log data using …` banner, up to
30 s, and records a skip if it never arrives. A lost race there produces an empty capture of a
-healthy run — indistinguishable from the bug above.
+healthy run, indistinguishable from the bug above.
### 7d. Still blocked
@@ -759,12 +759,12 @@ healthy run — indistinguishable from the bug above.
- **다만 워밍업은 JS 쪽에서는 실제로 부족했습니다 (새 사실, 2026-08-19T09:55)**: 같은 회차에서
JS-only sweep 이 `settles at 150000 calls, so warmup 100000 has only 0.67x margin -- TOO THIN` 로
판정됐습니다. 이것은 boundary 미수렴과 **별개의 결함**이며, JS 기준선과 그것으로 나눈 **모든 비율**을
- 무효화합니다(절대 boundary 수치는 무효화하지 않습니다 — 그쪽은 기준선에 의존하지 않습니다).
+ 무효화합니다(절대 boundary 수치는 무효화하지 않습니다, 그쪽은 기준선에 의존하지 않습니다).
`appRun.js` 의 `WARMUP` 은 "sweep 이 달리 말하지 않는 한 그대로 둔다"는 자기 규칙을 달고 있었고,
sweep 이 달리 말했으므로 **400,000 으로 올렸습니다.** 400,000 은 00:50 회차가 이미 그 sweep 에
충분한 여유를 준다고 보인 값입니다. boundary 쪽은 이것으로 고쳐지지 않습니다.
-- **네 번째 회차 (2026-08-23, WARMUP=400,000, load 2.71, 단독 실행) — 미수렴은 수렴 문제가 아니었습니다.**
+- **네 번째 회차 (2026-08-23, WARMUP=400,000, load 2.71, 단독 실행), 미수렴은 수렴 문제가 아니었습니다.**
이번에는 부하 가드를 고친 뒤(아래 참조) 실제로 조용한 상태에서 돌렸고, 판정은 여전히 양쪽 다
`NEVER SETTLED` 였습니다. **그런데 sweep 의 원자료를 보면 수렴하지 않은 곡선이 아니라
이미 평탄한 바닥에 스파이크가 얹힌 계열입니다:**
@@ -775,13 +775,13 @@ healthy run — indistinguishable from the bug above.
| boundary (Hermes+JSI+JNI+ART) | 233.07 ns | 305.06 | **1080.88** | 13/40 | 13/40 |
JS-only 계열에는 `10.63 10.64 10.63 10.86` 처럼 소수점 둘째 자리까지 반복되는 구간이 있습니다.
- Hermes 는 JIT 이 없어 **1회차부터 평탄한 것이 정상**이고, 실제로 평탄합니다 —
+ Hermes 는 JIT 이 없어 **1회차부터 평탄한 것이 정상**이고, 실제로 평탄합니다,
`first repetition 10.74 ns` 가 이미 바닥입니다. 깨는 것은 수렴 부족이 아니라 **단발 스파이크**이고,
"40회 연속 ±5%" 기준은 스파이크 **한 번**이면 무조건 깨집니다.
**이것이 워밍업을 4배로 올려도 나아지지 않은 이유입니다.** 워밍업을 늘려도 스케줄러 스파이크는
사라지지 않습니다. 8/19 회차에서 100k 로 JS-only 가 "150,000 에서 수렴"으로 나온 것도
- 그 회차의 후반부에 스파이크가 우연히 적었던 것으로 설명됩니다 — 즉 그 "수렴"도 실체가 아니었습니다.
+ 그 회차의 후반부에 스파이크가 우연히 적었던 것으로 설명됩니다, 즉 그 "수렴"도 실체가 아니었습니다.
- **부하 가드 자체에 결함이 둘 있었고 고쳤습니다 (`run-bench.sh`)**:
1. `RN_BENCH_REQUIRE_QUIET=1` 이 `load_guard || true` 로 호출되어 **"refusing to run" 을 출력하고도
@@ -811,10 +811,10 @@ rather than a larger `WARMUP`.
## 8. To fill in the blanks
1. Android 실기기(Physical Device) 또는 수렴 기준 재검토 후 re-run → 에뮬레이터 단독 실행(WARMUP=100k, 400k)은 `NEVER SETTLED`로 인용 불가 판정됨(§7d 참조). 실기기 측정 또는 에뮬레이터 수렴 기준 조정을 통해 Android 열을 완성해야 함.
-2. ~~`xcodebuild -downloadPlatform iOS` (several GB, internal disk — **ask first**)~~ **Done, and it
+2. ~~`xcodebuild -downloadPlatform iOS` (several GB, internal disk, **ask first**)~~ **Done, and it
needed none of that.** §7a: the platform download was never required, the block was a misread
`-showdestinations`. iOS is filled in §4b and §4c. What is left on this side is a **quiet-machine
- re-run** to clear the † on the ratio rows — boot the simulator, wait for a 1-minute load under
+ re-run** to clear the † on the ratio rows, boot the simulator, wait for a 1-minute load under
~2.0, `./run-bench.sh ios`, and check that the warmup verdict block says `Adequate` for both
sweeps rather than only for the boundary one.
3. Startup: RN's time-to-first-render against the reference project's 237/480 ms and 4/28 ms. The
@@ -860,13 +860,13 @@ repository and there is otherwise nothing to tell two runs of "the benchmark" ap
- **Above a 1-minute load average of 4.0 the run prints a boxed warning into its own results file**
saying the figures are not quotable. `RN_BENCH_REQUIRE_QUIET=1` turns that into a refusal.
-- **A platform that cannot run writes a results file saying why**, rather than exiting. A missing
+- **A platform that cannot run writes a results file saying why**: rather than exiting. A missing
file is indistinguishable from a file nobody looked for, and that is how an empty column becomes a
filled one in someone's memory. `all` therefore never stops early, and prints a summary of which
platforms produced figures.
- **Two emulators attached is an error, not a guess.** The app id is the same on both, so a
two-device run installs over itself. `ANDROID_SERIAL=emulator-5554 ./run-bench.sh android` selects
- one, and setting it is your assertion that that emulator is free — the script cannot tell.
+ one, and setting it is your assertion that that emulator is free, the script cannot tell.
- **Progress lines are prefixed `#`** and printed as each row starts, because the heavy rows take
tens of seconds and a benchmark that prints only at the end is indistinguishable from a hung one.
- **The capture window is 900 s** (`RN_BENCH_CAPTURE_SECS`), with a watchdog, and the run says
@@ -877,7 +877,7 @@ repository and there is otherwise nothing to tell two runs of "the benchmark" ap
- **The iOS capture reads the log at `--level info`.** This is load-bearing, not a detail:
`log stream` defaults to `--level default` and silently drops the Info-level messages React
Native's `console.log` produces on iOS. Without it the capture is empty while the app runs
- perfectly. §7b has the A/B that established it — 0 lines against 91 on a single launch.
+ perfectly. §7b has the A/B that established it, 0 lines against 91 on a single launch.
**How long a full quiet-machine pass should take.** The `ios` row is now measured; the rest are
still estimates, and the machine has been at load 2.4–6.9 during the iOS work:
@@ -893,16 +893,16 @@ still estimates, and the machine has been at load 2.4–6.9 during the iOS work:
**The `android` estimate is wrong by a factor of about 60, and the error is a unit slip.** The same
18 rows at the same counts took **~75 s on the iOS simulator**. The estimate's call counts were
-right — `repeat` does pay the warmup on every repetition, so 5 x (100 000 + 10 000) = 550 000 calls
-per row, and 5.5 M boundary calls is correct — but "550 000 of those is ~8 min" is not: 550 000 x
+right, `repeat` does pay the warmup on every repetition, so 5 x (100 000 + 10 000) = 550 000 calls
+per row, and 5.5 M boundary calls is correct, but "550 000 of those is ~8 min" is not: 550 000 x
14.3 µs is **7.9 seconds**, not 8 minutes. Microseconds were read as milliseconds.
Checked against the measurement rather than merely re-divided: on iOS the boundary `sumArray(1000)`
row is 550 000 x 64.6 µs = **35.5 s**, the two async round trips are **15.2 s** together, and the
rest of the rows and both sweeps account for the remainder of the 75 s. Android still pays a JNI hop
-per boundary call that iOS does not, so it will be slower than 75 s — by how much is **미측정**.
+per boundary call that iOS does not, so it will be slower than 75 s, by how much is **미측정**.
**So budget under 10 minutes for `./run-bench.sh all` on a quiet machine**, most of it builds
rather than benchmarking. If it ever does prove too slow, the honest fix is to drop `sumArray(1000)`
-to a smaller length and say so — **not** to lower the warmup, which is the one number §3a shows
+to a smaller length and say so, **not** to lower the warmup, which is the one number §3a shows
there is no room in.
diff --git a/benchmarks/rn-benchmark/README.md b/benchmarks/rn-benchmark/README.md
index 748898527..a6b4c8bf2 100644
--- a/benchmarks/rn-benchmark/README.md
+++ b/benchmarks/rn-benchmark/README.md
@@ -20,14 +20,14 @@ The comparison itself, including what corresponds to what and what is still unme
```
Every run writes to `results/-.txt` as well as to the terminal, **opening with
-the conditions it was taken under** — load average before and after, device, engine version,
+the conditions it was taken under**, load average before and after, device, engine version,
reference-repo commit, a digest of the harness sources, and the warmup/iteration/repetition counts.
A per-call figure without those is not a measurement. See `COMPARISON.md` §9.
`android` and `ios` each require **exactly one** device: the app id is the same on all of them, so
a two-device run would install over itself. Set `ANDROID_SERIAL` to choose when more than one is
-attached — doing so is your assertion that that device is free, which the script cannot check.
-`ios` will not boot a simulator for you — it expects one already booted, deliberately, because the
+attached, doing so is your assertion that that device is free, which the script cannot check.
+`ios` will not boot a simulator for you, it expects one already booted, deliberately, because the
simulators on this machine are shared.
A platform that cannot run **writes a results file saying why** and returns non-zero; `all` carries
@@ -37,7 +37,7 @@ average of 4.0 every run prints a warning into its own output that its figures a
| variable | default | |
|---|---|---|
-| `ANDROID_SERIAL` | — | pick one of several attached devices |
+| `ANDROID_SERIAL` | - | pick one of several attached devices |
| `RN_BENCH_REQUIRE_QUIET` | `0` | `1` = refuse to run on a busy machine |
| `RN_BENCH_MAX_LOAD` | `4.0` | the threshold for the above |
| `RN_BENCH_CAPTURE_SECS` | `900` | how long to wait for the on-device report |
@@ -68,7 +68,7 @@ it in the same run:
| `nativeLoopNs(...)` | the callee with the crossing removed, timed by the native clock |
Plus, kept in their own block and never subtracted from anything, the native → JS direction as
-callback and Promise **round trips** — see "the asymmetry" in `COMPARISON.md`.
+callback and Promise **round trips**, see "the asymmetry" in `COMPARISON.md`.
### The method, and why it is copied rather than invented
@@ -77,13 +77,13 @@ callback and Promise **round trips** — see "the asymmetry" in `COMPARISON.md`.
boundary costs measured with two different loop shapes are not comparable, which is the whole point
of this repository. What that buys, specifically:
-- **Warmup of the same shape and the same block as the timed loop**, never a different block.
+- **Warmup of the same shape and the same block as the timed loop**: never a different block.
- **A warmup count taken from a measured convergence point.** The reference project settled on
100 000 after a 40-repetition sweep; `run-host-sweep.sh` is that same sweep, so the question can
- be asked of Hermes rather than assumed. (It has an answer, and it is not the same as the JVM's —
+ be asked of Hermes rather than assumed. (It has an answer, and it is not the same as the JVM's,
see `COMPARISON.md` §3.)
- **The same sweep on device, over the boundary itself.** The host sweep prices Hermes, which has
- no JIT. It cannot price the Kotlin callee, which runs on **ART, which does** — and which the
+ no JIT. It cannot price the Kotlin callee, which runs on **ART, which does**, and which the
reference project measured as needing the largest warmup of any target it tested. So the
on-device run sweeps `addInts` through the TurboModule, cold, before anything else, and every
report ends with a verdict on whether its own warmup was large enough. `COMPARISON.md` §3a is
@@ -93,7 +93,7 @@ of this repository. What that buys, specifically:
- **Every baseline measured in the same process.** No row is compared against a number from another
run, another build or another machine.
- **No wall-clock assertion.** The only thing checked is that a row came back positive rather than
- as a zero from a clock with no resolution — the failure that would otherwise read as "the
+ as a zero from a clock with no resolution, the failure that would otherwise read as "the
boundary is free".
---
@@ -156,19 +156,19 @@ it and used for that one command.
| | state |
|---|---|
-| Android build | **green** — debug APK builds, JSI shim links into `libappmodules.so`, `BenchPackage` autolinked |
-| Android run | **done but not quotable** — full report, 21 rows, on API 26 emulator. `results/android-20260814-203238.txt`, load 3.17; warmup verdict is INSUFFICIENT (both sweeps signal inadequate settling), so figures are marked NOT QUOTABLE. `COMPARISON.md` §7c |
-| iOS `pod install` + codegen | **green** — 87 pods, `RNBenchSpec` generated |
-| iOS module compile | **green** — `check-ios-module.sh`, with a negative control |
-| iOS app build | **green** — `./build-ios.sh Release`, `** BUILD SUCCEEDED **`. The earlier "blocked" verdict was a misread `-showdestinations`; nothing was downloaded to clear it. `COMPARISON.md` §7a |
-| iOS run | **done** — full report, 21 rows, on iPhone 17 Pro / iOS 26.2. `results/ios-20260814-200903.txt`, ~75 s |
-| host JS rows + warmup sweep | **done** — see `COMPARISON.md` §3 |
+| Android build | **green**, debug APK builds, JSI shim links into `libappmodules.so`, `BenchPackage` autolinked |
+| Android run | **done but not quotable**, full report, 21 rows, on API 26 emulator. `results/android-20260814-203238.txt`, load 3.17; warmup verdict is INSUFFICIENT (both sweeps signal inadequate settling), so figures are marked NOT QUOTABLE. `COMPARISON.md` §7c |
+| iOS `pod install` + codegen | **green**, 87 pods, `RNBenchSpec` generated |
+| iOS module compile | **green**, `check-ios-module.sh`, with a negative control |
+| iOS app build | **green**, `./build-ios.sh Release`, `** BUILD SUCCEEDED **`. The earlier "blocked" verdict was a misread `-showdestinations`; nothing was downloaded to clear it. `COMPARISON.md` §7a |
+| iOS run | **done**, full report, 21 rows, on iPhone 17 Pro / iOS 26.2. `results/ios-20260814-200903.txt`, ~75 s |
+| host JS rows + warmup sweep | **done**, see `COMPARISON.md` §3 |
| on-device warmup sweep | **done on iOS** (boundary settles at 10 000 calls, 10x margin); **attempted on Android** but did not settle within ±5% over 40 repetitions (first 806 ns against plateau 308 ns = 2.62x, result not conclusive). §3a |
Two things to know before reading or re-running any of this, both written up in `COMPARISON.md` §7:
- **The iOS capture needs `log stream --level info`.** Without it the capture is empty while the app
- runs the whole benchmark perfectly — `console.log` reaches the unified log at Info level, which
+ runs the whole benchmark perfectly, `console.log` reaches the unified log at Info level, which
`log stream` drops by default. This cost two runs that exited 0 with empty results files.
-- **An empty or incomplete capture is now a non-zero exit**, and every results file ends with a
+- **An empty or incomplete capture is now a non-zero exit**: and every results file ends with a
`capture check` block. A run that produced no rows can no longer look like a measurement.
diff --git a/benchmarks/rn-benchmark/RNBench/README.md b/benchmarks/rn-benchmark/RNBench/README.md
index 3e2c3f850..834ac71d9 100644
--- a/benchmarks/rn-benchmark/RNBench/README.md
+++ b/benchmarks/rn-benchmark/RNBench/README.md
@@ -60,13 +60,13 @@ yarn ios
If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device.
-This is one way to run your app — you can also build it directly from Android Studio or Xcode.
+This is one way to run your app, you can also build it directly from Android Studio or Xcode.
## Step 3: Modify your app
Now that you have successfully run the app, let's make changes!
-Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh).
+Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes, this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh).
When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload:
diff --git a/benchmarks/typedpython/py/binary_trees.py b/benchmarks/typedpython/py/binary_trees.py
index 9ab44c379..43e5ea17e 100644
--- a/benchmarks/typedpython/py/binary_trees.py
+++ b/benchmarks/typedpython/py/binary_trees.py
@@ -2,6 +2,7 @@
class Node:
+ __slots__ = ("left", "right")
left: "Node | None"
right: "Node | None"
diff --git a/benchmarks/typedpython/results/2026-10-17-buffer-atomic-kernels.py b/benchmarks/typedpython/results/2026-10-17-buffer-atomic-kernels.py
new file mode 100644
index 000000000..a0cd59c56
--- /dev/null
+++ b/benchmarks/typedpython/results/2026-10-17-buffer-atomic-kernels.py
@@ -0,0 +1,21 @@
+# typedpython: compiled
+import array
+
+
+def dot_arr(a: array.array[float], b: array.array[float]) -> float:
+ s: float = 0.0
+ for i in range(len(a)):
+ s += a[i] * b[i]
+ return s
+
+
+def scale_arr(a: array.array[float], k: float) -> None:
+ for i in range(len(a)):
+ a[i] = a[i] * k
+
+
+def dot_list(a: list[float], b: list[float]) -> float:
+ s: float = 0.0
+ for i in range(len(a)):
+ s += a[i] * b[i]
+ return s
diff --git a/benchmarks/typedpython/results/2026-10-17-buffer-atomic-vs-plain.txt b/benchmarks/typedpython/results/2026-10-17-buffer-atomic-vs-plain.txt
new file mode 100644
index 000000000..2e3a10391
--- /dev/null
+++ b/benchmarks/typedpython/results/2026-10-17-buffer-atomic-vs-plain.txt
@@ -0,0 +1,4 @@
+# atomic vs plain buffer element access, 2026-10-17, quiet window (end load 1.15), interleaved 11 pairs, n=2,000,000 doubles, CPython 3.15.0rc1t
+compiled: ['dot_arr', 'dot_list', 'scale_arr']
+{'dot': 1.001, 'scale': 2.539} (atomic / plain, median of 11)
+dot_arr atomic 1.94 ms, plain 1.92 ms, dot_list (copy-in) 12.28 ms
diff --git a/benchmarks/typedpython/results/2026-10-17-eval-breaker-ab.json b/benchmarks/typedpython/results/2026-10-17-eval-breaker-ab.json
new file mode 100644
index 000000000..8c46e092c
--- /dev/null
+++ b/benchmarks/typedpython/results/2026-10-17-eval-breaker-ab.json
@@ -0,0 +1,308 @@
+{
+ "fannkuch-redux": {
+ "size": "10",
+ "pairs": [
+ {
+ "before_s": 0.617363250348717,
+ "after_s": 0.7355743343941867,
+ "cpython_s": 6.021780250128359,
+ "after_over_before": 1.191477357906708,
+ "speedup_before": 9.75403094811193,
+ "speedup_after": 8.186501307291872,
+ "load": [
+ 3.6943359375,
+ 3.02001953125,
+ 2.7109375
+ ]
+ },
+ {
+ "after_s": 0.27955787535756826,
+ "before_s": 0.2578067500144243,
+ "cpython_s": 6.004611459095031,
+ "after_over_before": 1.0843698830303201,
+ "speedup_before": 23.291133605924095,
+ "speedup_after": 21.47895655386148,
+ "load": [
+ 3.513671875,
+ 3.00244140625,
+ 2.7080078125
+ ]
+ },
+ {
+ "before_s": 0.2493240418843925,
+ "after_s": 0.2699953746050596,
+ "cpython_s": 5.923735792282969,
+ "after_over_before": 1.0829095042918166,
+ "speedup_before": 23.759184022171873,
+ "speedup_after": 21.940138052172244,
+ "load": [
+ 3.31201171875,
+ 2.96875,
+ 2.69775390625
+ ]
+ },
+ {
+ "after_s": 0.2705523748882115,
+ "before_s": 0.2492111660540104,
+ "cpython_s": 5.963486583903432,
+ "after_over_before": 1.0856350426512427,
+ "speedup_before": 23.929451791140824,
+ "speedup_after": 22.04189331683916,
+ "load": [
+ 3.12646484375,
+ 2.93603515625,
+ 2.6875
+ ]
+ },
+ {
+ "before_s": 0.2592446249909699,
+ "after_s": 0.2709223749116063,
+ "cpython_s": 5.9388052499853075,
+ "after_over_before": 1.045045292341329,
+ "speedup_before": 22.908113331924124,
+ "speedup_after": 21.9206894665786,
+ "load": [
+ 2.9560546875,
+ 2.90380859375,
+ 2.67724609375
+ ]
+ },
+ {
+ "after_s": 0.27158462489023805,
+ "before_s": 0.25109495781362057,
+ "cpython_s": 5.959664166904986,
+ "after_over_before": 1.0816012685201997,
+ "speedup_before": 23.73470267502801,
+ "speedup_after": 21.944041085954726,
+ "load": [
+ 3.12255859375,
+ 2.93994140625,
+ 2.69189453125
+ ]
+ },
+ {
+ "before_s": 0.2600955842062831,
+ "after_s": 0.2711727498099208,
+ "cpython_s": 5.943080165889114,
+ "after_over_before": 1.0425888260942267,
+ "speedup_before": 22.84960040373322,
+ "speedup_after": 21.916214553471654,
+ "load": [
+ 2.9521484375,
+ 2.90771484375,
+ 2.681640625
+ ]
+ },
+ {
+ "after_s": 0.2703690002672374,
+ "before_s": 0.2523987921886146,
+ "cpython_s": 5.931899290997535,
+ "after_over_before": 1.0711976785736514,
+ "speedup_before": 23.50209063823371,
+ "speedup_after": 21.940012668369313,
+ "load": [
+ 2.79541015625,
+ 2.8759765625,
+ 2.67138671875
+ ]
+ },
+ {
+ "before_s": 0.25141441682353616,
+ "after_s": 0.2719767093658447,
+ "cpython_s": 5.947182500269264,
+ "after_over_before": 1.0817864496479568,
+ "speedup_before": 23.654898455737715,
+ "speedup_after": 21.86651391634243,
+ "load": [
+ 2.8134765625,
+ 2.87939453125,
+ 2.67431640625
+ ]
+ },
+ {
+ "after_s": 0.27185420878231525,
+ "before_s": 0.24923187540844083,
+ "cpython_s": 5.944646291900426,
+ "after_over_before": 1.0907682186991572,
+ "speedup_before": 23.851869999205956,
+ "speedup_after": 21.86703791906546,
+ "load": [
+ 4.029296875,
+ 3.13037109375,
+ 2.763671875
+ ]
+ },
+ {
+ "before_s": 0.2507732920348644,
+ "after_s": 0.2709160838276148,
+ "cpython_s": 5.93707116600126,
+ "after_over_before": 1.080322715506522,
+ "speedup_before": 23.67505374207012,
+ "speedup_after": 21.91479768244047,
+ "load": [
+ 3.86669921875,
+ 3.111328125,
+ 2.7587890625
+ ]
+ }
+ ],
+ "median_after_over_before": 1.0817864496479568,
+ "min_after_over_before": 1.0425888260942267,
+ "max_after_over_before": 1.191477357906708,
+ "median_speedup_before": 23.654898455737715,
+ "median_speedup_after": 21.916214553471654
+ },
+ "nbody": {
+ "size": "1000000",
+ "pairs": [
+ {
+ "before_s": 0.5934657920151949,
+ "after_s": 0.5233573750592768,
+ "cpython_s": 4.006437666714191,
+ "after_over_before": 0.8818661194980502,
+ "speedup_before": 6.750915925768493,
+ "speedup_after": 7.655261696198342,
+ "load": [
+ 3.95703125,
+ 3.142578125,
+ 2.771484375
+ ]
+ },
+ {
+ "after_s": 0.13168670795857906,
+ "before_s": 0.13310000021010637,
+ "cpython_s": 4.033577458932996,
+ "after_over_before": 0.9893817261510418,
+ "speedup_before": 30.3048644069553,
+ "speedup_after": 30.63010323108482,
+ "load": [
+ 3.73583984375,
+ 3.12109375,
+ 2.767578125
+ ]
+ },
+ {
+ "before_s": 0.12832820834591985,
+ "after_s": 0.1279506660066545,
+ "cpython_s": 4.010861457791179,
+ "after_over_before": 0.9970579941531822,
+ "speedup_before": 31.25471406083652,
+ "speedup_after": 31.346936932571975,
+ "load": [
+ 3.73583984375,
+ 3.12109375,
+ 2.767578125
+ ]
+ },
+ {
+ "after_s": 0.12794629205018282,
+ "before_s": 0.12665566708892584,
+ "cpython_s": 4.075236166827381,
+ "after_over_before": 1.0101900293206054,
+ "speedup_before": 32.175711205769645,
+ "speedup_after": 31.851147083097963,
+ "load": [
+ 3.6767578125,
+ 3.11865234375,
+ 2.7685546875
+ ]
+ },
+ {
+ "before_s": 0.13051274977624416,
+ "after_s": 0.1298941671848297,
+ "cpython_s": 4.128146333154291,
+ "after_over_before": 0.9952603665735725,
+ "speedup_before": 31.630214980771886,
+ "speedup_after": 31.780844533844594,
+ "load": [
+ 3.46240234375,
+ 3.0830078125,
+ 2.7578125
+ ]
+ },
+ {
+ "after_s": 0.1337217092514038,
+ "before_s": 0.1343525000847876,
+ "cpython_s": 4.176525415852666,
+ "after_over_before": 0.9953049564914258,
+ "speedup_before": 31.086324506182844,
+ "speedup_after": 31.23296463404143,
+ "load": [
+ 3.42529296875,
+ 3.08154296875,
+ 2.7587890625
+ ]
+ },
+ {
+ "before_s": 0.13416387513279915,
+ "after_s": 0.1304794168099761,
+ "cpython_s": 4.760525666177273,
+ "after_over_before": 0.9725376274412463,
+ "speedup_before": 35.48291715236439,
+ "speedup_after": 36.48487847788492,
+ "load": [
+ 3.31103515625,
+ 3.0634765625,
+ 2.75390625
+ ]
+ },
+ {
+ "after_s": 0.14065329218283296,
+ "before_s": 0.13487516716122627,
+ "cpython_s": 4.0361085827462375,
+ "after_over_before": 1.0428405402063352,
+ "speedup_before": 29.9247716810729,
+ "speedup_after": 28.695443384999226,
+ "load": [
+ 3.44580078125,
+ 3.09521484375,
+ 2.7666015625
+ ]
+ },
+ {
+ "before_s": 0.12785816565155983,
+ "after_s": 0.1291002919897437,
+ "cpython_s": 4.002894583158195,
+ "after_over_before": 1.0097148768860718,
+ "speedup_before": 31.307304955922156,
+ "speedup_after": 31.006084660724103,
+ "load": [
+ 3.32958984375,
+ 3.07666015625,
+ 2.76171875
+ ]
+ },
+ {
+ "after_s": 0.12757845874875784,
+ "before_s": 0.12731604231521487,
+ "cpython_s": 4.101447000168264,
+ "after_over_before": 1.0020611419328702,
+ "speedup_before": 32.214691295647675,
+ "speedup_after": 32.14842882092897,
+ "load": [
+ 3.142578125,
+ 3.0419921875,
+ 2.7509765625
+ ]
+ },
+ {
+ "before_s": 0.13188975024968386,
+ "after_s": 0.12789770774543285,
+ "cpython_s": 4.141773249953985,
+ "after_over_before": 0.9697319731313953,
+ "speedup_before": 31.403298907709573,
+ "speedup_after": 32.383483042540185,
+ "load": [
+ 3.142578125,
+ 3.0419921875,
+ 2.7509765625
+ ]
+ }
+ ],
+ "median_after_over_before": 0.9953049564914258,
+ "min_after_over_before": 0.8818661194980502,
+ "max_after_over_before": 1.0428405402063352,
+ "median_speedup_before": 31.307304955922156,
+ "median_speedup_after": 31.346936932571975
+ }
+}
diff --git a/benchmarks/typedpython/results/2026-10-17-eval-breaker-nopoll-experiment.json b/benchmarks/typedpython/results/2026-10-17-eval-breaker-nopoll-experiment.json
new file mode 100644
index 000000000..957de61fb
--- /dev/null
+++ b/benchmarks/typedpython/results/2026-10-17-eval-breaker-nopoll-experiment.json
@@ -0,0 +1,155 @@
+{
+ "fannkuch-redux": {
+ "size": "10",
+ "pairs": [
+ {
+ "before_s": 0.5521271657198668,
+ "after_s": 0.6070619588717818,
+ "cpython_s": 5.987047249916941,
+ "after_over_before": 1.099496631505698,
+ "speedup_before": 10.84360202076091,
+ "speedup_after": 9.86233309865735,
+ "load": [
+ 2.44775390625,
+ 2.82666015625,
+ 2.69140625
+ ]
+ },
+ {
+ "after_s": 0.2503165830858052,
+ "before_s": 0.250257957726717,
+ "cpython_s": 6.000943332910538,
+ "after_over_before": 1.0002342597199334,
+ "speedup_before": 23.979031026312455,
+ "speedup_after": 23.97341502082383,
+ "load": [
+ 2.41162109375,
+ 2.8125,
+ 2.68701171875
+ ]
+ },
+ {
+ "before_s": 0.26244004210457206,
+ "after_s": 0.2621465837582946,
+ "cpython_s": 5.951287917327136,
+ "after_over_before": 0.9988818080353739,
+ "speedup_before": 22.67675263882095,
+ "speedup_after": 22.702137987097956,
+ "load": [
+ 2.50146484375,
+ 2.818359375,
+ 2.68994140625
+ ]
+ },
+ {
+ "after_s": 0.25116583285853267,
+ "before_s": 0.2525885826908052,
+ "cpython_s": 5.937470917124301,
+ "after_over_before": 0.9943673232688663,
+ "speedup_before": 23.506489699070784,
+ "speedup_after": 23.639644172734826,
+ "load": [
+ 2.380859375,
+ 2.7880859375,
+ 2.6796875
+ ]
+ },
+ {
+ "before_s": 0.2509259171783924,
+ "after_s": 0.2509960001334548,
+ "cpython_s": 5.959116999991238,
+ "after_over_before": 1.000279297395225,
+ "speedup_before": 23.748511381367926,
+ "speedup_after": 23.741880336032327,
+ "load": [
+ 2.27001953125,
+ 2.75830078125,
+ 2.66943359375
+ ]
+ },
+ {
+ "after_s": 0.24977354193106294,
+ "before_s": 0.24995683319866657,
+ "cpython_s": 5.941518500447273,
+ "after_over_before": 0.9992667083141593,
+ "speedup_before": 23.770178332051973,
+ "speedup_after": 23.78762159719512,
+ "load": [
+ 2.248046875,
+ 2.74560546875,
+ 2.6650390625
+ ]
+ },
+ {
+ "before_s": 0.24905870808288455,
+ "after_s": 0.24908804101869464,
+ "cpython_s": 5.942821917124093,
+ "after_over_before": 1.0001177751865649,
+ "speedup_before": 23.861128819259648,
+ "speedup_after": 23.85831890129992,
+ "load": [
+ 2.203125,
+ 2.72021484375,
+ 2.65673828125
+ ]
+ },
+ {
+ "after_s": 0.2507365830242634,
+ "before_s": 0.24994562473148108,
+ "cpython_s": 5.947943000122905,
+ "after_over_before": 1.0031645214579452,
+ "speedup_before": 23.796947862211372,
+ "speedup_after": 23.72187946561963,
+ "load": [
+ 2.1064453125,
+ 2.69140625,
+ 2.64697265625
+ ]
+ },
+ {
+ "before_s": 0.2490944997407496,
+ "after_s": 0.24957312503829598,
+ "cpython_s": 5.9209016668610275,
+ "after_over_before": 1.0019214607229165,
+ "speedup_before": 23.769700547476287,
+ "speedup_after": 23.7241155911819,
+ "load": [
+ 2.09765625,
+ 2.6796875,
+ 2.64306640625
+ ]
+ },
+ {
+ "after_s": 0.24998520780354738,
+ "before_s": 0.2506584580987692,
+ "cpython_s": 5.945883291773498,
+ "after_over_before": 0.9973140731003918,
+ "speedup_before": 23.721055881667432,
+ "speedup_after": 23.784940493143544,
+ "load": [
+ 2.001953125,
+ 2.64013671875,
+ 2.62939453125
+ ]
+ },
+ {
+ "before_s": 0.2553401249460876,
+ "after_s": 0.25701129203662276,
+ "cpython_s": 6.079530125018209,
+ "after_over_before": 1.0065448667376036,
+ "speedup_before": 23.809536892416883,
+ "speedup_after": 23.654719902936826,
+ "load": [
+ 1.92138671875,
+ 2.61279296875,
+ 2.61962890625
+ ]
+ }
+ ],
+ "median_after_over_before": 1.0002342597199334,
+ "min_after_over_before": 0.9943673232688663,
+ "max_after_over_before": 1.099496631505698,
+ "median_speedup_before": 23.769700547476287,
+ "median_speedup_after": 23.7241155911819
+ }
+}
diff --git a/benchmarks/typedpython/results/2026-10-17-fast-entry-ab.json b/benchmarks/typedpython/results/2026-10-17-fast-entry-ab.json
new file mode 100644
index 000000000..f7fb1f35b
--- /dev/null
+++ b/benchmarks/typedpython/results/2026-10-17-fast-entry-ab.json
@@ -0,0 +1,314 @@
+{
+ "spectral-norm": {
+ "size": "800",
+ "pairs": [
+ {
+ "before_s": 1.1141414581798017,
+ "after_s": 0.798894583247602,
+ "cpython_s": 3.3166386247612536,
+ "after_over_before": 0.7170495069385303,
+ "speedup_before": 2.976855946263522,
+ "speedup_after": 4.151534751029004,
+ "load": [
+ 2.32373046875,
+ 2.8388671875,
+ 3.1533203125
+ ]
+ },
+ {
+ "after_s": 0.05642041703686118,
+ "before_s": 0.5941873327828944,
+ "cpython_s": 3.3151200828142464,
+ "after_over_before": 0.09495392096733946,
+ "speedup_before": 5.579250683934612,
+ "speedup_after": 58.757454427328625,
+ "load": [
+ 2.21728515625,
+ 2.80810546875,
+ 3.140625
+ ]
+ },
+ {
+ "before_s": 0.5943655422888696,
+ "after_s": 0.05575770791620016,
+ "cpython_s": 3.283662290778011,
+ "after_over_before": 0.09381046502372974,
+ "speedup_before": 5.524651173641064,
+ "speedup_after": 58.89162975840255,
+ "load": [
+ 2.19970703125,
+ 2.79443359375,
+ 3.1337890625
+ ]
+ },
+ {
+ "after_s": 0.05573229165747762,
+ "before_s": 0.5970102911815047,
+ "cpython_s": 3.3194454996846616,
+ "after_over_before": 0.09335231315222627,
+ "speedup_before": 5.560114371086234,
+ "speedup_after": 59.560542029842956,
+ "load": [
+ 2.19970703125,
+ 2.79443359375,
+ 3.1337890625
+ ]
+ },
+ {
+ "before_s": 0.5956689999438822,
+ "after_s": 0.05633941572159529,
+ "cpython_s": 3.339143875055015,
+ "after_over_before": 0.0945817488016046,
+ "speedup_before": 5.605703629649343,
+ "speedup_after": 59.26834405872438,
+ "load": [
+ 2.103515625,
+ 2.76416015625,
+ 3.12109375
+ ]
+ },
+ {
+ "after_s": 0.056803958024829626,
+ "before_s": 0.6093089170753956,
+ "cpython_s": 3.3818732076324522,
+ "after_over_before": 0.09322686150316217,
+ "speedup_before": 5.550342548513829,
+ "speedup_after": 59.53587259102964,
+ "load": [
+ 2.25537109375,
+ 2.78466796875,
+ 3.1259765625
+ ]
+ },
+ {
+ "before_s": 0.607568624895066,
+ "after_s": 0.05638662492856383,
+ "cpython_s": 3.3777862917631865,
+ "after_over_before": 0.09280700585600918,
+ "speedup_before": 5.5595140258379345,
+ "speedup_after": 59.904033909504264,
+ "load": [
+ 2.31494140625,
+ 2.7880859375,
+ 3.125
+ ]
+ },
+ {
+ "after_s": 0.05656562466174364,
+ "before_s": 0.6080640838481486,
+ "cpython_s": 3.3865731661207974,
+ "after_over_before": 0.09302576186339882,
+ "speedup_before": 5.569434630456687,
+ "speedup_after": 59.86980938285648,
+ "load": [
+ 2.28955078125,
+ 2.77490234375,
+ 3.1181640625
+ ]
+ },
+ {
+ "before_s": 0.6106662922538817,
+ "after_s": 0.059617833234369755,
+ "cpython_s": 3.3802520409226418,
+ "after_over_before": 0.09762751602733612,
+ "speedup_before": 5.5353506224268845,
+ "speedup_after": 56.6986731576471,
+ "load": [
+ 2.28955078125,
+ 2.77490234375,
+ 3.1181640625
+ ]
+ },
+ {
+ "after_s": 0.05642887484282255,
+ "before_s": 0.6073171659372747,
+ "cpython_s": 3.380930291954428,
+ "after_over_before": 0.09291500060884278,
+ "speedup_before": 5.566992803071236,
+ "speedup_after": 59.91489820365369,
+ "load": [
+ 2.26611328125,
+ 2.76171875,
+ 3.111328125
+ ]
+ },
+ {
+ "before_s": 0.6068534590303898,
+ "after_s": 0.05705862492322922,
+ "cpython_s": 3.3896440002135932,
+ "after_over_before": 0.09402372858580321,
+ "speedup_before": 5.5856054699423705,
+ "speedup_after": 59.406338739748186,
+ "load": [
+ 2.32470703125,
+ 2.765625,
+ 3.1103515625
+ ]
+ }
+ ],
+ "median_after_over_before": 0.09381046502372974,
+ "min_after_over_before": 0.09280700585600918,
+ "max_after_over_before": 0.7170495069385303,
+ "median_speedup_before": 5.560114371086234,
+ "median_speedup_after": 59.406338739748186
+ },
+ "nbody": {
+ "skipped": [
+ "n/a (nothing compiled)",
+ "n/a (nothing compiled)"
+ ]
+ },
+ "fannkuch-redux": {
+ "size": "10",
+ "pairs": [
+ {
+ "before_s": 0.7067045411095023,
+ "after_s": 0.63105074968189,
+ "cpython_s": 6.030535667203367,
+ "after_over_before": 0.8929484855030952,
+ "speedup_before": 8.533319536528843,
+ "speedup_after": 9.55634023134167,
+ "load": [
+ 2.53857421875,
+ 2.80224609375,
+ 3.12109375
+ ]
+ },
+ {
+ "after_s": 0.2739174999296665,
+ "before_s": 0.27865454088896513,
+ "cpython_s": 6.0477433749474585,
+ "after_over_before": 0.9830003094721281,
+ "speedup_before": 21.703372769931963,
+ "speedup_after": 22.078703903548806,
+ "load": [
+ 2.5751953125,
+ 2.80517578125,
+ 3.1201171875
+ ]
+ },
+ {
+ "before_s": 0.27599437488242984,
+ "after_s": 0.2764261248521507,
+ "cpython_s": 6.042619833257049,
+ "after_over_before": 1.0015643433671602,
+ "speedup_before": 21.893996338987453,
+ "speedup_after": 21.859800105684677,
+ "load": [
+ 2.486328125,
+ 2.7783203125,
+ 3.1064453125
+ ]
+ },
+ {
+ "after_s": 0.2808002079837024,
+ "before_s": 0.2761829160153866,
+ "cpython_s": 6.013481875415891,
+ "after_over_before": 1.0167182388937432,
+ "speedup_before": 21.77354762624373,
+ "speedup_after": 21.41551788225496,
+ "load": [
+ 2.447265625,
+ 2.76513671875,
+ 3.099609375
+ ]
+ },
+ {
+ "before_s": 0.2764340410940349,
+ "after_s": 0.2888324582017958,
+ "cpython_s": 6.036349332891405,
+ "after_over_before": 1.0448512674440964,
+ "speedup_before": 21.83649057475527,
+ "speedup_after": 20.899137757827916,
+ "load": [
+ 2.8115234375,
+ 2.8349609375,
+ 3.1220703125
+ ]
+ },
+ {
+ "after_s": 0.2795829582028091,
+ "before_s": 0.2770982920192182,
+ "cpython_s": 6.064032624941319,
+ "after_over_before": 1.0089667322215705,
+ "speedup_before": 21.884049088692134,
+ "speedup_after": 21.6895645711799,
+ "load": [
+ 2.76611328125,
+ 2.82373046875,
+ 3.1142578125
+ ]
+ },
+ {
+ "before_s": 0.27542241709306836,
+ "after_s": 0.2786844172514975,
+ "cpython_s": 6.044994332827628,
+ "after_over_before": 1.011843626211903,
+ "speedup_before": 21.948083952748682,
+ "speedup_after": 21.691181704545574,
+ "load": [
+ 3.18505859375,
+ 2.9091796875,
+ 3.142578125
+ ]
+ },
+ {
+ "after_s": 0.27875441685318947,
+ "before_s": 0.27709112502634525,
+ "cpython_s": 6.081121041905135,
+ "after_over_before": 1.006002688922953,
+ "speedup_before": 21.946285870133714,
+ "speedup_after": 21.81533519918307,
+ "load": [
+ 3.169921875,
+ 2.91064453125,
+ 3.1416015625
+ ]
+ },
+ {
+ "before_s": 0.277795874979347,
+ "after_s": 0.2738394159823656,
+ "cpython_s": 6.0339908339083195,
+ "after_over_before": 0.9857576754972495,
+ "speedup_before": 21.7209518836697,
+ "speedup_after": 22.034778347237236,
+ "load": [
+ 3.2109375,
+ 2.92919921875,
+ 3.1455078125
+ ]
+ },
+ {
+ "after_s": 0.2780231246724725,
+ "before_s": 0.2764884582720697,
+ "cpython_s": 6.0378861669451,
+ "after_over_before": 1.0055505622549086,
+ "speedup_before": 21.83775121999382,
+ "speedup_after": 21.717208502199533,
+ "load": [
+ 3.27392578125,
+ 2.94677734375,
+ 3.150390625
+ ]
+ },
+ {
+ "before_s": 0.2783553749322891,
+ "after_s": 0.2733089169487357,
+ "cpython_s": 6.045158042106777,
+ "after_over_before": 0.9818704489367918,
+ "speedup_before": 21.717410858609366,
+ "speedup_after": 22.11840765971299,
+ "load": [
+ 3.572265625,
+ 3.013671875,
+ 3.1728515625
+ ]
+ }
+ ],
+ "median_after_over_before": 1.0055505622549086,
+ "min_after_over_before": 0.8929484855030952,
+ "max_after_over_before": 1.0448512674440964,
+ "median_speedup_before": 21.83649057475527,
+ "median_speedup_after": 21.717208502199533
+ }
+}
diff --git a/benchmarks/typedpython/results/2026-10-17-interval-proofs-ab.json b/benchmarks/typedpython/results/2026-10-17-interval-proofs-ab.json
new file mode 100644
index 000000000..790a0ccda
--- /dev/null
+++ b/benchmarks/typedpython/results/2026-10-17-interval-proofs-ab.json
@@ -0,0 +1,308 @@
+{
+ "fannkuch-redux": {
+ "size": "10",
+ "pairs": [
+ {
+ "before_s": 1.0887560830451548,
+ "after_s": 0.6829974167048931,
+ "cpython_s": 5.911647166591138,
+ "after_over_before": 0.627319036229501,
+ "speedup_before": 5.429725958505584,
+ "speedup_after": 8.655445865537468,
+ "load": [
+ 2.66748046875,
+ 2.986328125,
+ 3.36767578125
+ ]
+ },
+ {
+ "after_s": 0.27054166700690985,
+ "before_s": 0.2709987089037895,
+ "cpython_s": 5.913880208041519,
+ "after_over_before": 0.9983134905006432,
+ "speedup_before": 21.822540158820743,
+ "speedup_after": 21.8594062551203,
+ "load": [
+ 2.49072265625,
+ 2.93701171875,
+ 3.34521484375
+ ]
+ },
+ {
+ "before_s": 0.2726931250654161,
+ "after_s": 0.27129745902493596,
+ "cpython_s": 5.907416833098978,
+ "after_over_before": 0.9948819170261615,
+ "speedup_before": 21.663240801108767,
+ "speedup_after": 21.774685447960664,
+ "load": [
+ 2.37109375,
+ 2.90478515625,
+ 3.3310546875
+ ]
+ },
+ {
+ "after_s": 0.270517417229712,
+ "before_s": 0.2704989998601377,
+ "cpython_s": 5.924571041949093,
+ "after_over_before": 1.0000680866457319,
+ "speedup_before": 21.90237688498815,
+ "speedup_after": 21.900885727140434,
+ "load": [
+ 2.16015625,
+ 2.841796875,
+ 3.3037109375
+ ]
+ },
+ {
+ "before_s": 0.27097137505188584,
+ "after_s": 0.27024612482637167,
+ "cpython_s": 5.908709957730025,
+ "after_over_before": 0.9973235171966954,
+ "speedup_before": 21.805661046664504,
+ "speedup_after": 21.864180148841232,
+ "load": [
+ 2.14697265625,
+ 2.82763671875,
+ 3.2958984375
+ ]
+ },
+ {
+ "after_s": 0.27170295920223,
+ "before_s": 0.2769245416857302,
+ "cpython_s": 6.009743249975145,
+ "after_over_before": 0.9811443852115281,
+ "speedup_before": 21.701735835299655,
+ "speedup_after": 22.118799396299767,
+ "load": [
+ 2.0546875,
+ 2.796875,
+ 3.2822265625
+ ]
+ },
+ {
+ "before_s": 0.2953220000490546,
+ "after_s": 0.2890004999935627,
+ "cpython_s": 6.02235220791772,
+ "after_over_before": 0.9785945508480849,
+ "speedup_before": 20.3924943177866,
+ "speedup_after": 20.83855290233707,
+ "load": [
+ 2.21044921875,
+ 2.81640625,
+ 3.2861328125
+ ]
+ },
+ {
+ "after_s": 0.27058391692116857,
+ "before_s": 0.2701001246459782,
+ "cpython_s": 6.634010416921228,
+ "after_over_before": 1.0017911590223236,
+ "speedup_before": 24.561300834705143,
+ "speedup_after": 24.51738629703542,
+ "load": [
+ 2.3251953125,
+ 2.82177734375,
+ 3.2822265625
+ ]
+ },
+ {
+ "before_s": 0.3240792923606932,
+ "after_s": 0.3907488747499883,
+ "cpython_s": 6.6358414171263576,
+ "after_over_before": 1.205719970269169,
+ "speedup_before": 20.475980951417316,
+ "speedup_after": 16.982368589985445,
+ "load": [
+ 2.69921875,
+ 2.89111328125,
+ 3.3037109375
+ ]
+ },
+ {
+ "after_s": 0.2735611251555383,
+ "before_s": 0.2766624162904918,
+ "cpython_s": 6.041726541239768,
+ "after_over_before": 0.9887903417582489,
+ "speedup_before": 21.83790130313919,
+ "speedup_after": 22.085471895192093,
+ "load": [
+ 2.95947265625,
+ 2.94287109375,
+ 3.3173828125
+ ]
+ },
+ {
+ "before_s": 0.2883912082761526,
+ "after_s": 0.28800000017508864,
+ "cpython_s": 7.008296665735543,
+ "after_over_before": 0.9986434811816823,
+ "speedup_before": 24.301353386004266,
+ "speedup_after": 24.334363407898863,
+ "load": [
+ 2.88232421875,
+ 2.9267578125,
+ 3.3095703125
+ ]
+ }
+ ],
+ "median_after_over_before": 0.9973235171966954,
+ "min_after_over_before": 0.627319036229501,
+ "max_after_over_before": 1.205719970269169,
+ "median_speedup_before": 21.805661046664504,
+ "median_speedup_after": 21.864180148841232
+ },
+ "binary-trees": {
+ "size": "16",
+ "pairs": [
+ {
+ "before_s": 3.155360875185579,
+ "after_s": 3.0856687501072884,
+ "cpython_s": 3.590506291948259,
+ "after_over_before": 0.9779131047651747,
+ "speedup_before": 1.1379067035358,
+ "speedup_after": 1.1636071732662223,
+ "load": [
+ 2.7314453125,
+ 2.89453125,
+ 3.2958984375
+ ]
+ },
+ {
+ "after_s": 2.393941333051771,
+ "before_s": 2.388220750261098,
+ "cpython_s": 3.54007404204458,
+ "after_over_before": 1.0023953325043538,
+ "speedup_before": 1.4823060396144505,
+ "speedup_after": 1.478763908358494,
+ "load": [
+ 2.8525390625,
+ 2.9140625,
+ 3.2978515625
+ ]
+ },
+ {
+ "before_s": 2.403391916770488,
+ "after_s": 2.392654124647379,
+ "cpython_s": 3.593896792270243,
+ "after_over_before": 0.9955322342360468,
+ "speedup_before": 1.4953436296396774,
+ "speedup_after": 1.5020544571187862,
+ "load": [
+ 2.88134765625,
+ 2.91650390625,
+ 3.2939453125
+ ]
+ },
+ {
+ "after_s": 2.379018457606435,
+ "before_s": 2.754461499862373,
+ "cpython_s": 3.6588334999978542,
+ "after_over_before": 0.8636963913727975,
+ "speedup_before": 1.3283298750701973,
+ "speedup_after": 1.5379592740440777,
+ "load": [
+ 3.04638671875,
+ 2.951171875,
+ 3.3017578125
+ ]
+ },
+ {
+ "before_s": 2.3003429169766605,
+ "after_s": 2.309331167023629,
+ "cpython_s": 3.513636375311762,
+ "after_over_before": 1.0039073522389357,
+ "speedup_before": 1.5274402565725864,
+ "speedup_after": 1.5214952387449465,
+ "load": [
+ 3.580078125,
+ 3.0673828125,
+ 3.3388671875
+ ]
+ },
+ {
+ "after_s": 2.2887648749165237,
+ "before_s": 2.2567570419050753,
+ "cpython_s": 3.2699287086725235,
+ "after_over_before": 1.0141831098417349,
+ "speedup_before": 1.4489502626796564,
+ "speedup_after": 1.4286870374973686,
+ "load": [
+ 3.693359375,
+ 3.09912109375,
+ 3.34814453125
+ ]
+ },
+ {
+ "before_s": 2.160762708168477,
+ "after_s": 2.1336489161476493,
+ "cpython_s": 3.3091259165666997,
+ "after_over_before": 0.987451749366866,
+ "speedup_before": 1.5314619713016095,
+ "speedup_after": 1.5509233461620295,
+ "load": [
+ 3.740234375,
+ 3.12841796875,
+ 3.35498046875
+ ]
+ },
+ {
+ "after_s": 2.1829029163345695,
+ "before_s": 2.155100916977972,
+ "cpython_s": 3.1836033752188087,
+ "after_over_before": 1.012900555671232,
+ "speedup_before": 1.4772409728650073,
+ "speedup_after": 1.4584264611110465,
+ "load": [
+ 3.5205078125,
+ 3.0927734375,
+ 3.3408203125
+ ]
+ },
+ {
+ "before_s": 2.9248738340102136,
+ "after_s": 2.181852374691516,
+ "cpython_s": 3.206505249720067,
+ "after_over_before": 0.7459646119846608,
+ "speedup_before": 1.0962883979592775,
+ "speedup_after": 1.4696252078802639,
+ "load": [
+ 3.2060546875,
+ 3.03955078125,
+ 3.31884765625
+ ]
+ },
+ {
+ "after_s": 2.146172667387873,
+ "before_s": 2.1432514172047377,
+ "cpython_s": 3.184945832937956,
+ "after_over_before": 1.0013629993008217,
+ "speedup_before": 1.4860346328812009,
+ "speedup_after": 1.4840119256641096,
+ "load": [
+ 3.669921875,
+ 3.13818359375,
+ 3.35205078125
+ ]
+ },
+ {
+ "before_s": 2.1659594592638314,
+ "after_s": 2.5923933750018477,
+ "cpython_s": 3.8596382504329085,
+ "after_over_before": 1.196879915694707,
+ "speedup_before": 1.7819531357917135,
+ "speedup_after": 1.4888320143273617,
+ "load": [
+ 3.56640625,
+ 3.13330078125,
+ 3.34716796875
+ ]
+ }
+ ],
+ "median_after_over_before": 1.0013629993008217,
+ "min_after_over_before": 0.7459646119846608,
+ "max_after_over_before": 1.196879915694707,
+ "median_speedup_before": 1.4823060396144505,
+ "median_speedup_after": 1.4840119256641096
+ }
+}
diff --git a/benchmarks/typedpython/results/2026-10-17-wordfreq-cost-rule-ab.json b/benchmarks/typedpython/results/2026-10-17-wordfreq-cost-rule-ab.json
new file mode 100644
index 000000000..32019a960
--- /dev/null
+++ b/benchmarks/typedpython/results/2026-10-17-wordfreq-cost-rule-ab.json
@@ -0,0 +1,155 @@
+{
+ "wordfreq": {
+ "size": "3000000",
+ "pairs": [
+ {
+ "before_s": 2.534866082947701,
+ "after_s": 2.4698741249740124,
+ "cpython_s": 2.246576082892716,
+ "after_over_before": 0.9743607923073743,
+ "speedup_before": 0.8862701260653014,
+ "speedup_after": 0.9095913270140251,
+ "load": [
+ 2.22265625,
+ 2.5498046875,
+ 3.80224609375
+ ]
+ },
+ {
+ "after_s": 2.054086625110358,
+ "before_s": 2.0703248330391943,
+ "cpython_s": 2.226471666712314,
+ "after_over_before": 0.9921566859124232,
+ "speedup_before": 1.0754214175385701,
+ "speedup_after": 1.0839229658061253,
+ "load": [
+ 2.1943359375,
+ 2.53173828125,
+ 3.78076171875
+ ]
+ },
+ {
+ "before_s": 2.0203522499650717,
+ "after_s": 2.0748487496748567,
+ "cpython_s": 2.2474577920511365,
+ "after_over_before": 1.0269737614867542,
+ "speedup_before": 1.112408884188384,
+ "speedup_after": 1.083191144609133,
+ "load": [
+ 2.0986328125,
+ 2.505859375,
+ 3.76416015625
+ ]
+ },
+ {
+ "after_s": 2.057975041680038,
+ "before_s": 2.072377833072096,
+ "cpython_s": 2.225645957980305,
+ "after_over_before": 0.9930501131781035,
+ "speedup_before": 1.0739576164453584,
+ "speedup_after": 1.081473736514991,
+ "load": [
+ 2.17041015625,
+ 2.513671875,
+ 3.75927734375
+ ]
+ },
+ {
+ "before_s": 2.0653403750620782,
+ "after_s": 2.0860109999775887,
+ "cpython_s": 2.24187570810318,
+ "after_over_before": 1.0100083381727765,
+ "speedup_before": 1.0854751764758366,
+ "speedup_after": 1.0747190250325938,
+ "load": [
+ 2.39697265625,
+ 2.5546875,
+ 3.76611328125
+ ]
+ },
+ {
+ "after_s": 2.047571041621268,
+ "before_s": 2.0459034591913223,
+ "cpython_s": 2.240035249851644,
+ "after_over_before": 1.0008150836357668,
+ "speedup_before": 1.0948880504542748,
+ "speedup_after": 1.093996351930228,
+ "load": [
+ 2.3291015625,
+ 2.53564453125,
+ 3.74462890625
+ ]
+ },
+ {
+ "before_s": 2.064202000387013,
+ "after_s": 2.066363917198032,
+ "cpython_s": 2.2770254579372704,
+ "after_over_before": 1.0010473378141354,
+ "speedup_before": 1.1031020498528514,
+ "speedup_after": 1.1019479381080624,
+ "load": [
+ 2.22265625,
+ 2.509765625,
+ 3.728515625
+ ]
+ },
+ {
+ "after_s": 2.047510417178273,
+ "before_s": 2.029783667065203,
+ "cpython_s": 2.238690916914493,
+ "after_over_before": 1.0087333199102446,
+ "speedup_before": 1.1029209433689757,
+ "speedup_after": 1.093372174389076,
+ "load": [
+ 2.12451171875,
+ 2.484375,
+ 3.71240234375
+ ]
+ },
+ {
+ "before_s": 2.051607625093311,
+ "after_s": 2.0582683752290905,
+ "cpython_s": 2.2774744578637183,
+ "after_over_before": 1.0032466003997604,
+ "speedup_before": 1.1100926073815574,
+ "speedup_after": 1.1065002432494886,
+ "load": [
+ 1.951171875,
+ 2.43505859375,
+ 3.68017578125
+ ]
+ },
+ {
+ "after_s": 2.0976750422269106,
+ "before_s": 2.0433855829760432,
+ "cpython_s": 2.259623875375837,
+ "after_over_before": 1.0265683871429683,
+ "speedup_before": 1.1058235382501125,
+ "speedup_after": 1.0772039662430268,
+ "load": [
+ 2.03515625,
+ 2.4443359375,
+ 3.67578125
+ ]
+ },
+ {
+ "before_s": 2.05512237502262,
+ "after_s": 2.072784666903317,
+ "cpython_s": 2.2494797920808196,
+ "after_over_before": 1.0085942774480778,
+ "speedup_before": 1.0945721867565479,
+ "speedup_after": 1.08524528765522,
+ "load": [
+ 1.9521484375,
+ 2.419921875,
+ 3.65966796875
+ ]
+ }
+ ],
+ "median_after_over_before": 1.0032466003997604,
+ "min_after_over_before": 0.9743607923073743,
+ "max_after_over_before": 1.0269737614867542,
+ "median_speedup_before": 1.0948880504542748,
+ "median_speedup_after": 1.0839229658061253
+ }
+}
diff --git a/benchmarks/typedpython/results/2026-10-17-wordfreq-object-mode.md b/benchmarks/typedpython/results/2026-10-17-wordfreq-object-mode.md
new file mode 100644
index 000000000..519d6cfbe
--- /dev/null
+++ b/benchmarks/typedpython/results/2026-10-17-wordfreq-object-mode.md
@@ -0,0 +1,20 @@
+# wordfreq and object-only compiled code (#150, 2026-10-17)
+
+Quiet windows, interleaved against develop, 11 pairs, CPython 3.15.0rc1t.
+
+| build | after / before (median, range) | vs CPython |
+|---|---|---|
+| #150 without the cost rule (`word_of` and `main` compiled) | 1.335 (1.314 to 1.767) | 1.1x to **0.8x** |
+| #150 with the cost rule (only `next_state` compiled, as on develop) | 1.003 (0.974 to 1.027) | 1.1x to 1.1x |
+
+Split, measured in one process: `word_of` compiled 248.6 ms against 95.7 ms interpreted (2.6x slower);
+`main` 332 ms against 345 ms.
+
+Cause (read in the generated C): in an impure function an `int` is a CPython object, so each loop
+iteration boxes constants, calls generic `PyNumber_*` / `PyObject_RichCompare`, and looks up the
+global `chr` through the module and builtins dicts. CPython 3.15's specialising interpreter
+(`BINARY_OP_ADD_INT`, `LOAD_GLOBAL_BUILTIN` caches, the small-int cache) runs that faster.
+
+Consequence: the cost rule (`typedpython/cost.py`) keeps an impure function interpreted when it has
+loops and none of them does native work. The real fix, native ints in impure functions with in-place
+promotion on overflow, pre-boxed constants and cached builtins, is #232.
diff --git a/benchmarks/typedpython/run.py b/benchmarks/typedpython/run.py
index 288cfd746..104ad572d 100644
--- a/benchmarks/typedpython/run.py
+++ b/benchmarks/typedpython/run.py
@@ -12,6 +12,10 @@
interpreter with the gate's dependencies, e.g. that module's .venv): each benchmark is copied with
the `# typedpython: compiled` marker, compiled through `typedpython.pipeline`, and its `main()` is
run from the built extension. Without the option the column reports n/a.
+
+binary-trees declares `__slots__` (TypedPython compiles fixed-layout classes only, SPEC N-11). The
+speedup is reported against that CPython version; `CPython (no __slots__)` runs the same program
+with the `__slots__` line removed, so what adding the slots changes in CPython is recorded too.
"""
import argparse
import datetime
@@ -40,7 +44,10 @@
"binary-trees": ("binary_trees.py", "binary_trees.mjs", "BinaryTrees", "binary_trees", "10", "16"),
"wordfreq": ("wordfreq.py", "wordfreq.mjs", "Wordfreq", "wordfreq", "100000", "3000000"),
}
-COLUMNS = ["CPython", "Node", "Java", "Rust", "TypedPython @compiled"]
+NOSLOTS = "CPython (no __slots__)"
+COLUMNS = ["CPython", NOSLOTS, "Node", "Java", "Rust", "TypedPython @compiled"]
+SLOTS_LINE = ' __slots__ = ("left", "right")\n'
+NOSLOTS_BENCHMARKS = {"binary-trees"}
NOT_BUILT = "n/a (not built)"
@@ -99,6 +106,18 @@ def build_compiled(tp_dir: Path, tp_python: str) -> dict[str, tuple[str, list[st
return built
+def noslots_source(name: str) -> Path:
+ """The benchmark with its `__slots__` line removed, written under tp-build/noslots/."""
+ py = BENCHMARKS[name][0]
+ source = (ROOT / "py" / py).read_text()
+ if SLOTS_LINE not in source:
+ sys.exit(f"{name}: expected the line {SLOTS_LINE.strip()!r} in py/{py}")
+ out = TP_BUILD / "noslots" / py
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(source.replace(SLOTS_LINE, "", 1))
+ return out
+
+
def commands(name: str, size: str, python: str, java_source: bool,
compiled: dict[str, tuple[str, list[str]] | str] | None = None) -> dict[str, list[str]]:
py, js, cls, rs, _, _ = BENCHMARKS[name]
@@ -110,12 +129,22 @@ def commands(name: str, size: str, python: str, java_source: bool,
"Java": java,
"Rust": [str(TARGET / "release" / rs), size],
}
+ if name in NOSLOTS_BENCHMARKS:
+ cmds[NOSLOTS] = [python, str(noslots_source(name)), size]
entry = (compiled or {}).get(name)
if isinstance(entry, tuple):
cmds["TypedPython @compiled"] = [python, "-c", RUN_SNIPPET, Path(py).stem, entry[0], size]
return cmds
+def source_commit() -> str:
+ """The commit the harness (and the TypedPython sources beside it) was measured at."""
+ result = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture_output=True, text=True)
+ dirty = subprocess.run(["git", "-C", str(ROOT), "status", "--porcelain", "--untracked-files=no"],
+ capture_output=True, text=True).stdout.strip()
+ return result.stdout.strip() + ("+dirty" if dirty else "") if result.returncode == 0 else "unknown"
+
+
def run_once(cmd: list[str]) -> tuple[float, str]:
start = time.perf_counter()
result = subprocess.run(cmd, capture_output=True, text=True)
@@ -179,7 +208,8 @@ def measure(args: argparse.Namespace) -> int:
print("| benchmark | size | " + " | ".join(COLUMNS) + " |")
print("|---|---|" + "---|" * len(COLUMNS))
for name, row in results.items():
- cells = [f"{row[c]['median_s']:.3f} s" if isinstance(row.get(c), dict) else str(row.get(c, NOT_BUILT))
+ cells = [f"{row[c]['median_s']:.3f} s" if isinstance(row.get(c), dict)
+ else str(row.get(c, ", " if c == NOSLOTS else NOT_BUILT))
for c in COLUMNS] # type: ignore[index]
flag = "" if row["outputs_agree"] else " (OUTPUTS DIFFER)"
print(f"| {name}{flag} | {row['size']} | " + " | ".join(cells) + " |")
@@ -190,6 +220,7 @@ def measure(args: argparse.Namespace) -> int:
out_dir.mkdir(exist_ok=True)
path = out_dir / f"{datetime.date.today().isoformat()}-{socket.gethostname()}.json"
payload = {"date": datetime.datetime.now().isoformat(timespec="seconds"),
+ "commit": source_commit(),
"host": socket.gethostname(), "load_average": load, "repeat": args.repeat,
"python": subprocess.run([args.python, "--version"], capture_output=True, text=True).stdout.strip(),
"results": results}
@@ -216,6 +247,7 @@ def interleaved(args: argparse.Namespace) -> int:
size = args.size.get(name, spec[5])
cmds = commands(name, size, args.python, False, compiled)
a_cmd, b_cmd = cmds["CPython"], cmds["TypedPython @compiled"]
+ c_cmd = cmds.get(NOSLOTS)
pairs = []
for i in range(args.pairs):
order = (a_cmd, b_cmd) if i % 2 == 0 else (b_cmd, a_cmd) # alternate which runs first
@@ -225,11 +257,21 @@ def interleaved(args: argparse.Namespace) -> int:
if o_a != o_b:
print(f"{name}: OUTPUTS DIFFER in pair {i}: {o_a!r} vs {o_b!r}")
return 1
- pairs.append({"cpython_s": t_a, "compiled_s": t_b, "ratio": t_a / t_b, "load": load})
+ pair = {"cpython_s": t_a, "compiled_s": t_b, "ratio": t_a / t_b, "load": load}
+ if c_cmd is not None:
+ t_c, o_c = run_once(c_cmd)
+ if o_c != o_a:
+ print(f"{name}: OUTPUTS DIFFER in pair {i} (no __slots__): {o_c!r} vs {o_a!r}")
+ return 1
+ pair.update(cpython_noslots_s=t_c, ratio_vs_noslots=t_c / t_b)
+ pairs.append(pair)
ratios = [p["ratio"] for p in pairs]
results[name] = {"size": size, "compiled_functions": entry[1], "pairs": pairs,
"median_ratio": statistics.median(ratios), "min_ratio": min(ratios),
"max_ratio": max(ratios)}
+ if c_cmd is not None:
+ results[name]["median_ratio_vs_noslots"] = statistics.median(
+ p["ratio_vs_noslots"] for p in pairs)
print(f"measured {name}", file=sys.stderr)
print()
@@ -237,7 +279,7 @@ def interleaved(args: argparse.Namespace) -> int:
print("|---|---|---|---|---|")
for name, r in results.items():
if "skipped" in r:
- print(f"| {name} | — | {r['skipped']} | | |")
+ print(f"| {name} | - | {r['skipped']} | | |")
continue
loads = [p["load"][0] for p in r["pairs"]] # type: ignore[index]
print(f"| {name} | {r['size']} | {r['median_ratio']:.1f}x | {r['min_ratio']:.1f}–{r['max_ratio']:.1f}x "
@@ -248,6 +290,7 @@ def interleaved(args: argparse.Namespace) -> int:
out_dir.mkdir(exist_ok=True)
path = out_dir / f"{datetime.date.today().isoformat()}-{socket.gethostname()}-interleaved.json"
path.write_text(json.dumps({"date": datetime.datetime.now().isoformat(timespec="seconds"),
+ "commit": source_commit(),
"host": socket.gethostname(), "method": "interleaved A/B",
"pairs": args.pairs, "results": results}, indent=2) + "\n")
print(f"wrote {path}")
diff --git a/binary/aarch64-linux-android.tar.xz b/binary/aarch64-linux-android.tar.xz
deleted file mode 100644
index 4ff6017ea..000000000
Binary files a/binary/aarch64-linux-android.tar.xz and /dev/null differ
diff --git a/binary/arm64-iphoneos.zip b/binary/arm64-iphoneos.zip
deleted file mode 100644
index 1182d7a7a..000000000
Binary files a/binary/arm64-iphoneos.zip and /dev/null differ
diff --git a/binary/arm64-iphonesimulator.zip b/binary/arm64-iphonesimulator.zip
deleted file mode 100644
index ffb316901..000000000
Binary files a/binary/arm64-iphonesimulator.zip and /dev/null differ
diff --git a/binary/combine_simulator.sh b/binary/combine_simulator.sh
deleted file mode 100644
index 0ae4540e7..000000000
--- a/binary/combine_simulator.sh
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/bin/bash
-
-# Unzip archives
-unzip -o -qq "./arm64-iphoneos.zip" -d "./arm64-iphoneos"
-unzip -o -qq "./arm64-iphonesimulator.zip" -d "./arm64-iphonesimulator"
-unzip -o -qq "./x86_64-iphonesimulator.zip" -d "./x86_64-iphonesimulator"
-
-# Set module directory
-ARM_DIR="./arm64-iphonesimulator/"
-x86_DIR="path/to/modules"
-
-# Loop through each module in the directory
-for module in "$MODULE_DIR"/*; do
- # Extract the module name without the architecture suffix
- module_name=$(basename "$module" | sed 's/_x86_64//;s/_arm64//')
-
- # Define the paths for x86_64 and arm64 versions of the module
- x86_64_module="$MODULE_DIR/${module_name}_x86_64/module.dylib"
- arm64_module="$MODULE_DIR/${module_name}_arm64/module.dylib"
-
- # Define the output path for the combined module
- output_module="$MODULE_DIR/${module_name}/module.dylib"
-
- # Create the combined module using lipo
- lipo -create -output "$output_module" "$x86_64_module" "$arm64_module"
-done
diff --git a/binary/cpython-3.13.0+20241008-aarch64-apple-darwin-pgo+lto-full.tar.zst b/binary/cpython-3.13.0+20241008-aarch64-apple-darwin-pgo+lto-full.tar.zst
deleted file mode 100644
index a99f6508a..000000000
Binary files a/binary/cpython-3.13.0+20241008-aarch64-apple-darwin-pgo+lto-full.tar.zst and /dev/null differ
diff --git a/binary/cpython-3.13.0+20241008-x86_64-apple-darwin-pgo+lto-full.tar.zst b/binary/cpython-3.13.0+20241008-x86_64-apple-darwin-pgo+lto-full.tar.zst
deleted file mode 100644
index 6915f3f51..000000000
Binary files a/binary/cpython-3.13.0+20241008-x86_64-apple-darwin-pgo+lto-full.tar.zst and /dev/null differ
diff --git a/binary/cpython-3.13.0+20241008-x86_64-pc-windows-msvc-shared-pgo-full.tar.zst b/binary/cpython-3.13.0+20241008-x86_64-pc-windows-msvc-shared-pgo-full.tar.zst
deleted file mode 100644
index 00922a019..000000000
Binary files a/binary/cpython-3.13.0+20241008-x86_64-pc-windows-msvc-shared-pgo-full.tar.zst and /dev/null differ
diff --git a/binary/cpython-3.13.0+20241008-x86_64_v4-unknown-linux-gnu-lto-full.tar.zst b/binary/cpython-3.13.0+20241008-x86_64_v4-unknown-linux-gnu-lto-full.tar.zst
deleted file mode 100644
index e0d1e8b23..000000000
Binary files a/binary/cpython-3.13.0+20241008-x86_64_v4-unknown-linux-gnu-lto-full.tar.zst and /dev/null differ
diff --git a/binary/download.md b/binary/download.md
deleted file mode 100644
index 510fdb136..000000000
--- a/binary/download.md
+++ /dev/null
@@ -1 +0,0 @@
-These stand-alone python binaries were downloaded from https://github.com/astral-sh/python-build-standalone/releases/tag/20241008
diff --git a/binary/x86_64-iphonesimulator.zip b/binary/x86_64-iphonesimulator.zip
deleted file mode 100644
index 688c97ec8..000000000
Binary files a/binary/x86_64-iphonesimulator.zip and /dev/null differ
diff --git a/binary/x86_64-linux-android.tar.xz b/binary/x86_64-linux-android.tar.xz
deleted file mode 100644
index 9b42e72ae..000000000
Binary files a/binary/x86_64-linux-android.tar.xz and /dev/null differ
diff --git a/docs/archive/README.md b/docs/archive/README.md
index 0659efaab..c8ffdf8be 100644
--- a/docs/archive/README.md
+++ b/docs/archive/README.md
@@ -24,7 +24,7 @@ full text is in git (`git log -- docs/design/upcall-design.md`).
| [`wasm-design-experiment-log.md`](wasm-design-experiment-log.md) | [`../platforms/wasm-design.md`](../platforms/wasm-design.md) | The chronological lab notebook of the wasm experiment, including its "parked" phase and the conclusions that were wrong before they were tested. wasm is now integrated (`tools/wasm/build-cpython.sh`, `wasmJsTest`). |
| [`wasm-youtrack-issue.md`](wasm-youtrack-issue.md) | [`../platforms/wasm-design.md`](../platforms/wasm-design.md) | A draft Kotlin/Wasm compiler issue. Do not file it: Kotlin 2.4.20-Beta2 imports the shared memory, so the problem it describes no longer applies. |
-## Investigation narratives — `investigations/`
+## Investigation narratives, `investigations/`
The current file of the same name in [`../investigations/`](../investigations/) keeps the conclusion,
the numbers with their conditions, and how to reproduce them. The archived copy is the full original,
diff --git a/docs/archive/android-ffm-design.md b/docs/archive/android-ffm-design.md
index 5c1e10f12..649c05dab 100644
--- a/docs/archive/android-ffm-design.md
+++ b/docs/archive/android-ffm-design.md
@@ -2,7 +2,7 @@
> [`python-multiplatform/src/androidMain/README.md`](../../python-multiplatform/src/androidMain/README.md) on 2026-10-03; kept for history.
> This was a plan to replace the Kotlin/Native JNI bridge on Android with a PanamaPort-style FFM path
> (ART method patching, hidden-API bypass, 14 NDK trampolines). It was **not** built: PanamaPort is
-> not a dependency (AGENTS.md §12.9 — licence, and dependence on ART-internal struct layouts, see §2–§3
+> not a dependency (AGENTS.md §12.9, licence, and dependence on ART-internal struct layouts, see §2–§3
> below), and Android binds CPython through JNI `RegisterNatives` with the calling convention chosen
> per function and per API level (measured; SPEC C-3). The Kotlin/Native Android library
> (`libmultiplatform_python3.14.so`) is still the bridge. The 14-shape census in §6 is still
@@ -106,7 +106,7 @@ Based on an analysis of the ~330 functions in `EmbedAPI.kt`, there are exactly 1
### Upcalls
**No.** CPython upcalls (callbacks) also have a small set of signature shapes (e.g., `PyCFunction` is `II_I`, `destructor` is `I_V`, `getter` is `II_I`, `newfunc` is `III_I`, etc.), which collapse into just 4 distinct shapes (`I_V`, `I_I`, `II_I`, `III_I`), perfectly overlapping with the downcall trampolines.
-The real reason LLVM is unnecessary for upcalls is not just the small number of shapes, but that **routing happens through data, not through the function pointer**. Every standard callback receives enough context to identify the Kotlin target—usually the `PyObject *self` instance data (e.g., `destructor`, `reprfunc`), the `PyTypeObject *type` (e.g., `newfunc`), or the `void *closure` (e.g., `getter`, `setter`). Because the identity of the target is carried in the arguments, a handful of shared static trampolines can serve unlimited Kotlin targets. This is exactly how PyO3, JPype, and JEP work.
+The real reason LLVM is unnecessary for upcalls is not just the small number of shapes, but that **routing happens through data, not through the function pointer**. Every standard callback receives enough context to identify the Kotlin target, usually the `PyObject *self` instance data (e.g., `destructor`, `reprfunc`), the `PyTypeObject *type` (e.g., `newfunc`), or the `void *closure` (e.g., `getter`, `setter`). Because the identity of the target is carried in the arguments, a handful of shared static trampolines can serve unlimited Kotlin targets. This is exactly how PyO3, JPype, and JEP work.
The ONLY case that would require runtime code generation (LLVM or similar) is handing a bare Kotlin function pointer, with no context argument, to a third-party C API (like `qsort`). CPython's object model does not do this.
@@ -159,7 +159,7 @@ That has two consequences, both of which cut against work already committed here
Native Image works under a closed-world assumption, so runtime reflection does not work unless every
reflective access is registered at build time. This rules out the obvious way of exposing Kotlin to
-Python — walking classes reflectively at run time — and is why that discussion landed on a
+Python, walking classes reflectively at run time, and is why that discussion landed on a
**build-time generated class/function table**, reached through a single upcall entry point.
The registration policy decided there is worth carrying forward: every `public` declaration is
@@ -179,7 +179,7 @@ Two problems from that discussion remain unresolved:
`desktopMain/.../Panama.kt` reaches `java.lang.foreign` and `jdk.incubator.foreign` entirely
through `Class.forName` plus `MethodHandles`, precisely so it compiles on any JDK. Under Native
Image that is a problem twice over: the reflective lookups need build-time registration, and more
-fundamentally **GraalVM requires FFM downcalls themselves to be registered at compile time** —
+fundamentally **GraalVM requires FFM downcalls themselves to be registered at compile time**,
an unregistered one fails at run time with
`ForeignFunctionsRuntime$UnregisteredForeignStubException: Cannot perform downcall ... as it was not
registered at compilation time`.
diff --git a/docs/archive/downcall-design-android-first-diagnosis.md b/docs/archive/downcall-design-android-first-diagnosis.md
index 38b98df65..5eae58324 100644
--- a/docs/archive/downcall-design-android-first-diagnosis.md
+++ b/docs/archive/downcall-design-android-first-diagnosis.md
@@ -1,9 +1,9 @@
# 다운콜 설계 초안의 Android 진단 (2026-08 초)
> **Superseded** by [`../design/downcall-design.md`](../design/downcall-design.md) on 2026-10-03; kept for history.
-> 아래 "확인된 버그" 는 **수정되었다** — Android 는 `RegisterNatives` 로 전부 바인딩한다
+> 아래 "확인된 버그" 는 **수정되었다**, Android 는 `RegisterNatives` 로 전부 바인딩한다
> (`python-multiplatform/src/androidMain/README.md`, ROADMAP §2). 호출 규약 표의 전환 비용(115/35/25 ns)은
-> 외부 벤치마크에서 가져온 값이며, 이 프로젝트가 실측한 값(API 26-36)과 반대 경향이다 — 실측은 본 문서의
+> 외부 벤치마크에서 가져온 값이며, 이 프로젝트가 실측한 값(API 26-36)과 반대 경향이다, 실측은 본 문서의
> "Measured" 절과 `androidMain/README.md` 에 있다.
### 확인된 버그: 현재 Android JNI 배선이 어긋나 있다
@@ -42,5 +42,5 @@ shape 트램폴린은 정적이고 `Long`/`Double` 만 주고받으므로 `@Crit
**애노테이션을 붙이는 것이 성능 개선인 동시에 위 버그의 수정**이다.
`@CriticalNative` 는 API 34 부터 공식 SDK 에 포함된다. minSdk 26 구간에서는 `RegisterNatives` 로
-등록해야 한다 — `RegisterNatives` 는 호출 규약이 아니라 바인딩 수단이므로 둘은 대안 관계가 아니다.
+등록해야 한다, `RegisterNatives` 는 호출 규약이 아니라 바인딩 수단이므로 둘은 대안 관계가 아니다.
diff --git a/docs/archive/downcall-design-benchmark-drift.md b/docs/archive/downcall-design-benchmark-drift.md
index ff60c1755..0ee0e941a 100644
--- a/docs/archive/downcall-design-benchmark-drift.md
+++ b/docs/archive/downcall-design-benchmark-drift.md
@@ -22,19 +22,19 @@ is a direct comparison, not a re-derivation):
| Platform | ratio recorded in `upcall-design.md` | ratio, this pass (3 runs) | consistent? |
|---|---|---|---|
-| desktop | 2.49–2.89x | 2.13–2.34x | **no — see below** |
+| desktop | 2.49–2.89x | 2.13–2.34x | **no, see below** |
| iOS simulator | 1.33–1.43x | 1.38–1.42x | yes, nested inside the old range |
-| wasmJs | 2.56–3.36x | 2.36–2.63x | mostly — top of the new range overlaps the bottom of the old one. **Superseded: since drifted to 1.58–1.68x, pinned to `4472f83a` and explained — see "wasmJs: closed too" below** |
+| wasmJs | 2.56–3.36x | 2.36–2.63x | mostly, top of the new range overlaps the bottom of the old one. **Superseded: since drifted to 1.58–1.68x, pinned to `4472f83a` and explained, see "wasmJs: closed too" below** |
**Desktop's ratio moved outside the previously recorded range, and it is the upcall side that
moved, not the downcall side.** This pass's downcall-same-shape figure (315.69–335.32 ns) sits at
-the bottom of the old 315–527 ns range — consistent, just at its floor. This pass's upcall figure
+the bottom of the old 315–527 ns range, consistent, just at its floor. This pass's upcall figure
(687.83–741.26 ns, read from the same `UpcallBoundaryCostTest` runs) sits *below* the old range's
floor of 861 ns entirely. So on this machine, at this commit (`65e1bf5d`), a desktop upcall through
`ctypes` measures cheaper than what `upcall-design.md`'s table recorded, while the downcall side did
not move. Two things separate the two tables and either could be the cause: `develop` has moved a
great deal since that table was captured (this session's own `git merge` pulled in 87 changed files,
-including upcall-adjacent ones — see the merge log above), and the two tables were also captured on
+including upcall-adjacent ones, see the merge log above), and the two tables were also captured on
different runs of the same machine, which is exactly the kind of thing this document elsewhere warns
is not comparable. Neither is ruled out here; this is reported as an open discrepancy, not resolved
into either explanation.
@@ -43,8 +43,8 @@ into either explanation.
The discrepancy above was tested directly rather than argued from a second reading. `537c1a0b`
(the commit the 861 ns floor was recorded on) and the current tip were each built in their own
-freshly created `git worktree` — not reused, not incrementally recompiled — and
-`:python-multiplatform:desktopTest` (the full suite, not a filtered subset — see the pitfall below)
+freshly created `git worktree`, not reused, not incrementally recompiled, and
+`:python-multiplatform:desktopTest` (the full suite, not a filtered subset, see the pitfall below)
was run three times against each, clearing `build/test-results` before every run:
| | run 1 | run 2 | run 3 |
@@ -61,16 +61,16 @@ exactly, which rules out a stale or miscalibrated re-measurement on this end.
**A methodology pitfall found along the way:** an initial attempt to bisect the ~75 commits in
between used `--tests UpcallBoundaryCostTest` to skip the other ~359 tests and go faster. That
filter alone moved the number into the 1300–1400 ns range at *every* commit tried, old and new
-alike — it does not merely add noise, it changes which regime the measurement lands in. Re-running
+alike, it does not merely add noise, it changes which regime the measurement lands in. Re-running
the same checkouts with the unfiltered full suite reproduced the fast band again. The cause is
below; the practical lesson is that this benchmark's absolute number is not a property of the
timed code alone, so a comparison across two runs is only valid if both used the same suite scope
-this document already required (Validation runs the full suite) — a filtered re-run of one
+this document already required (Validation runs the full suite), a filtered re-run of one
benchmark test is not equivalent to the run it is being compared against, even on the same commit.
**Bisection was attempted and did not converge to one commit; it converged to a window, and to a
mechanism.** Git bisect (full-suite grading, threshold at 750 ns) was run between `537c1a0b` and
-the fast tip. Partway through, machine load reached 10–12 on this 8-core host — two Android
+the fast tip. Partway through, machine load reached 10–12 on this 8-core host, two Android
emulators and other concurrent agent activity were running, unrelated to this task and outside its
control (`ps aux` at the time confirmed it; this document's own environment notes elsewhere warn
that this machine hosts several worktrees' worth of parallel agent work). Under that load, repeated
@@ -78,8 +78,8 @@ measurements of the *same* checkout spanned both regimes (one commit read 672, 7
across three consecutive runs), which made single-commit attribution unreliable. What did hold up
under repetition was the window: commits at or before `4722cfe9` measured consistently in the slow
band (854–855 ns), and commits at or after `ec8ff389` measured consistently in the fast band (672,
-700 ns, each confirmed in its own fresh clean worktree). That window —
-`4722cfe9..ec8ff389` (~15 commits) — contains both commits this task flagged as candidates:
+700 ns, each confirmed in its own fresh clean worktree). That window,
+`4722cfe9..ec8ff389` (~15 commits), contains both commits this task flagged as candidates:
`6776329d` ("Perf: Price the layer users actually call, and stop a module read raising an exception
per access") and `d45071e7` (proxy handle lifetime). `975d3900` sits well outside this window and is
ruled out.
@@ -88,27 +88,27 @@ ruled out.
`git diff ..` for both `6776329d` and `d45071e7` shows changes confined to
`PythonProxySource` (the generated-proxy source emitter, its tests) and, for `d45071e7`, a doc-only
comment added to `HandleTable.kt`. Neither touches `UpcallTable.resolve`, `HandleTable.resolve`/
-`release`, the non-suspend branch of `UpcallTrampoline.invoke`, or desktop's `UpcallStub` — the
+`release`, the non-suspend branch of `UpcallTrampoline.invoke`, or desktop's `UpcallStub`, the
actual call chain `UpcallBoundaryCostTest` times through `TrampolineFragment`. A commit that does
not touch the timed path cannot have made the timed path itself cheaper.
What it can do is change what runs *before* the timed path in the same process. `desktopTest` sets
no `forkEvery`, so Gradle's default applies: all ~360 tests in the suite run in one forked JVM, one
JIT compiler, one heap (confirmed by reading the `tasks.named("desktopTest")` block in
-`build.gradle.kts` — no fork-per-test config exists). `6776329d`'s own commit title is "stop a
-module read raising an exception per access" — replacing an exception-driven control-flow path
+`build.gradle.kts`, no fork-per-test config exists). `6776329d`'s own commit title is "stop a
+module read raising an exception per access", replacing an exception-driven control-flow path
(expensive on the JVM: every throw captures a stack trace) with a direct one.
`GeneratedProxyCostTest`, touched by the same commit, drives `N = 10_000` iterations times 3 kept
times multiple rows of generated-proxy calls that exercise exactly that module-attribute path, and
it runs earlier in the same shared JVM process as `UpcallBoundaryCostTest`. Removing tens of
thousands of exception throws from an earlier test changes the shared process's GC pressure and
-JIT tiering history by the time a later, unrelated benchmark in the same run gets timed — without
+JIT tiering history by the time a later, unrelated benchmark in the same run gets timed, without
the later benchmark's own code path changing at all. That is a plausible, mechanism-level
explanation for why only desktop moved: iOS runs through XCTest's own process model and wasmJs
through a Node process with a different (V8) JIT, neither sharing HotSpot's C2 warm-up state the
way every desktop test in one Gradle-forked JVM does.
-**This is not proven to the level of a single blamed commit** — machine contention prevented that —
+**This is not proven to the level of a single blamed commit**, machine contention prevented that,
but it is proven to the level that matters for the table: the number moved for a real, reproducible,
repository-located reason, not for noise, and the most likely mechanism is a same-process
warm-up artifact of the benchmark's own methodology rather than a genuine drop in the upcall
@@ -118,16 +118,16 @@ boundary's per-call cost. The open discrepancy above is closed on that basis, no
on the current tip (same machine, same session, two runs: 321.32 ns and 315.89 ns, tight) puts
wasmJs's upcall figure well below the 687–741 ns-equivalent range implied by this table's own
"this pass" ratio (2.36–2.63x against a ~192–205 ns downcall figure that still matches today's
-run) — today's ratio is 1.26–1.67x. So the "yes, mostly consistent" verdict recorded for wasmJs
+run), today's ratio is 1.26–1.67x. So the "yes, mostly consistent" verdict recorded for wasmJs
above was accurate for the commit it was measured on (`65e1bf5d`) and has since gone stale in the
same direction as desktop's did. iOS was not re-checked: this task's constraints rule out using the
iOS simulator, so its "yes, nested inside the old range" verdict above stands unverified rather
than reconfirmed.
-### wasmJs: closed too — one commit, and the mechanism demonstrated rather than argued (2026-08-14)
+### wasmJs: closed too, one commit, and the mechanism demonstrated rather than argued (2026-08-14)
wasmJs's drift is **repository drift, not noise**, it is pinned to the **single commit `4472f83a`**,
-and — unlike desktop's, which stayed at the level of a plausible story — its mechanism was
+and, unlike desktop's, which stayed at the level of a plausible story, its mechanism was
reproduced by a controlled experiment. It is *not* a genuine change in what the wasm upcall boundary
costs.
@@ -135,7 +135,7 @@ The measurement protocol was the one the desktop section above used, with two ch
this machine: runs at the two ends were **interleaved** rather than run in two blocks, and the
timed task was re-run with `--rerun` on `wasmJsNodeTest` alone rather than `--rerun-tasks`, so a
full Kotlin recompile does not finish moments before the benchmark starts. Every run is the **whole
-wasm suite** (filtering changes the measured region — see the pitfall below), `build/test-results`
+wasm suite** (filtering changes the measured region, see the pitfall below), `build/test-results`
is cleared before each, and all of them are 344/0/0 (258/0/0 at `537c1a0b`, whose suite was smaller).
| commit | upcall | downcall, same shape | ratio | runs |
@@ -149,18 +149,18 @@ is cleared before each, and all of them are 344/0/0 (258/0/0 at `537c1a0b`, whos
| current tip | 355.82–373.21 ns | 222.07–226.88 ns | 1.58–1.64x | 4 (+4 earlier) |
`4472f83a` and its own parent are adjacent commits measured minutes apart in one worktree, and their
-upcall bands do not touch. **The downcall column does not move anywhere in this table** — it is the
+upcall bands do not touch. **The downcall column does not move anywhere in this table**, it is the
upcall side alone, exactly as on desktop.
**The window does *not* overlap desktop's.** Desktop's was `4722cfe9..ec8ff389`; both of those
endpoints measure in wasm's *slow* band and are indistinguishable from each other, so wasm did not
move across desktop's window at all. `4472f83a` sits after `ec8ff389`. The two platforms therefore
-do **not** share a cause in `commonMain`'s marshalling or handle paths — which was the hypothesis
+do **not** share a cause in `commonMain`'s marshalling or handle paths, which was the hypothesis
worth testing, and it is refuted rather than left open.
**The MEMFS change is not the cause either.** `3355851c` (stdlib zip installed into MEMFS instead
of resolving out of a CPython source checkout) was the obvious environmental confound, since it
-lands after the desktop window. `73f2af6b` is its immediate parent and already measures 312–322 ns —
+lands after the desktop window. `73f2af6b` is its immediate parent and already measures 312–322 ns,
fully in the fast band. The transition happened before it.
**Why a commit that only adds a branch to the timed function made it measure cheaper.**
@@ -169,14 +169,14 @@ can only cost more, not less. What it also did was make `PythonProxySource.insta
wasm for the first time. Before it, `GeneratedProxyCostTest` and `ProxyHandleLifetimeTest` printed
"no proxies are installable on this target, so there is nothing to measure" and drove **zero**
upcalls; after it, `GeneratedProxyCostTest` drives on the order of 270 000 calls
-(`N = 10 000` × 3 kept × ~9 rows) straight through `UpcallEntry.invokeMethod` — earlier in the
+(`N = 10 000` × 3 kept × ~9 rows) straight through `UpcallEntry.invokeMethod`, earlier in the
**same Node process**, and therefore into the same V8 wasm tier-up state that
`UpcallBoundaryCostTest` is later timed in. The benchmark's own 3 000-iteration warmup does not
reach that state on its own.
That was tested, not just asserted. At the **current tip**, with nothing changed but
-`GeneratedProxyCostTest`'s body short-circuited so it installs nothing and calls nothing — the
-pre-`4472f83a` behaviour, same commit, same build, suite still 344/0/0 — the upcall figure went
+`GeneratedProxyCostTest`'s body short-circuited so it installs nothing and calls nothing, the
+pre-`4472f83a` behaviour, same commit, same build, suite still 344/0/0, the upcall figure went
back up:
| current tip | upcall | downcall | ratio |
@@ -186,8 +186,8 @@ back up:
One lever, at one commit, moves the number across the whole gap and lands it back on `65e1bf5d`'s
band (2.53–2.61x) and on this document's own "this pass" reading (2.36–2.63x). So the desktop
-section's suspicion — that this benchmark reports a same-process warm-up artifact rather than a
-boundary cost — is **confirmed on wasmJs by direct experiment**, on a different commit and a
+section's suspicion, that this benchmark reports a same-process warm-up artifact rather than a
+boundary cost, is **confirmed on wasmJs by direct experiment**, on a different commit and a
non-overlapping window from desktop's. What the two platforms share is the methodology, not a
code path.
@@ -221,9 +221,9 @@ would have re-derived the same non-number. This section changes it and shows the
#### What is warming, decided by experiment rather than by plausibility
-Both earlier sections reached for "JIT tiering" as a story. It is testable, and the obvious rival —
+Both earlier sections reached for "JIT tiering" as a story. It is testable, and the obvious rival,
CPython-side state, meaning the specializing interpreter, free lists, the allocator, string
-interning — makes an opposite prediction about one row that the report already prints.
+interning, makes an opposite prediction about one row that the report already prints.
The timed loops were run **40 times in succession** inside one run, on both hosts, in two suite
configurations. Per 10 000 calls:
@@ -243,7 +243,7 @@ what the number should be.
**Two rows in the same sweep do not move at all**, and they are what identifies the mechanism: the
empty Python loop (8–9 ns desktop, 14–16 ns wasm) and the pure-Python callee (24–27 ns, 47–54 ns) are
flat from the very first rep, in every configuration, on both hosts. CPython-side state would move
-those — they are pure interpreter work — and it does not. Only rows that cross into host code move.
+those, they are pure interpreter work, and it does not. Only rows that cross into host code move.
It is host JIT tier-up: C2 on the JVM, V8's tiering on wasm.
The confirmation is on desktop, where the two runtimes can be told apart: CPython is a **native
@@ -254,12 +254,12 @@ pure-Python rows are identical (8.44–8.59 ns) whether the class runs alone or
Convergence needed 40 000 calls cold and 70 000 warm on desktop, and ~70 000 on wasm. The warm side
settles later, so it sets the requirement; 100 000 is that with margin. The Kotlin-driven rows were
-on the same curve and worse off — they warmed `N / 4` = 2 500, and desktop's downcall row reads 653,
-266, 266, 161 before settling at ~141 — so they use the same count now. Warming the Python-driven and
+on the same curve and worse off, they warmed `N / 4` = 2 500, and desktop's downcall row reads 653,
+266, 266, 161 before settling at ~141, so they use the same count now. Warming the Python-driven and
Kotlin-driven halves differently would make the headline ratio a ratio of two compilation states.
`GeneratedProxyCostTest` needed far less, and the reason is its shape: it warms all 19 rows before
-timing any, so the shared `_pm_invoke` path already received 19 × 3 000 = 57 000 calls — just under
+timing any, so the shared `_pm_invoke` path already received 19 × 3 000 = 57 000 calls, just under
the knee. 5 000 puts it at 95 000. Measured, its raw rows moved from 3–7% solo-vs-suite disagreement
to 1–3.4%.
@@ -273,9 +273,9 @@ recorded so it is not tried again.
| | full suite | proxy test short-circuited | this class alone (`--tests`) |
|---|---|---|---|
-| desktop upcall, **3 000 warmup** | 673.91 ns | 536.75 ns | — |
+| desktop upcall, **3 000 warmup** | 673.91 ns | 536.75 ns | - |
| desktop upcall, **100 000 warmup** | 510–560 ns (11 runs) | 501–530 ns (3) | 506–550 ns (3) |
-| wasmJs upcall, **3 000 warmup** | 322.03 ns | 291.67 ns | — |
+| wasmJs upcall, **3 000 warmup** | 322.03 ns | 291.67 ns | - |
| wasmJs upcall, **100 000 warmup** | 290–304 ns (7 runs) | 287–301 ns (3) | 321–353 ns (3) |
The middle column is the lever the wasmJs section above used to prove the defect existed. It used to
@@ -284,13 +284,13 @@ is the fix.**
**The third column is honest about what is still not fixed, on one target.** Desktop passes it. wasmJs
does not, and the reason is not the boundary: filtered to this class alone, wasmJs's *pure-Python*
-rows read 23.05 ns and 83.10 ns against the full suite's 15.04 ns and 47.22 ns — 55–74% slower, in
+rows read 23.05 ns and 83.10 ns against the full suite's 15.04 ns and 47.22 ns, 55–74% slower, in
rows with no boundary in them. CPython is itself a wasm module under V8, so a Node process that lives
one second runs the interpreter's own bytecode slower than one that lives ten, and every row is
inflated together. Raising the warmup to 300 000 does not move it (solo read 318.83 and 326.21 ns
against 100 000's 320.54–337.42), which is what distinguishes a host-lifetime effect from a
-call-count effect. **wasmJs's ratio columns do survive filtering** — 2.66–3.13x solo against
-2.91–3.14x in the suite — so the ratio is the quantity to quote there, and the absolute column is a
+call-count effect. **wasmJs's ratio columns do survive filtering**, 2.66–3.13x solo against
+2.91–3.14x in the suite, so the ratio is the quantity to quote there, and the absolute column is a
full-suite figure that must be labelled as one. It is left in the table with that label rather than
deleted, because desktop's is now sound and dropping both would lose a real result.
diff --git a/docs/archive/investigations/android-unregistered-surface.md b/docs/archive/investigations/android-unregistered-surface.md
index de644b51f..507716b47 100644
--- a/docs/archive/investigations/android-unregistered-surface.md
+++ b/docs/archive/investigations/android-unregistered-surface.md
@@ -1,17 +1,17 @@
-> **Superseded** by [docs/investigations/android-unregistered-surface.md](../../investigations/android-unregistered-surface.md) on 2026-10-03 — the conclusion, numbers and reproduction steps live there; this is the full original narrative, kept for history.
+> **Superseded** by [docs/investigations/android-unregistered-surface.md](../../investigations/android-unregistered-surface.md) on 2026-10-03, the conclusion, numbers and reproduction steps live there; this is the full original narrative, kept for history.
# Reachability Analysis: Android Unregistered Surface
> **Status: superseded. Every count and every risk ranking below is historical.**
>
> The unregistered surface is empty as of the pass recorded in ROADMAP §2: 363 of 367 `external fun`
-> are bound through `RegisterNatives`, and the four that are not are deliberate — `ffiAllocUtf8`,
+> are bound through `RegisterNatives`, and the four that are not are deliberate, `ffiAllocUtf8`,
> `ffiFreeUtf8` and `ffiReadUtf8` are hand-written JNI exports with the correct prologue, and
> `echoCriticalNamed` exists in order to be name-linked, so that a benchmark can measure that path.
>
> The claim here worth carrying forward is that the *ordering* was right and the *counts* were not.
> When the last 157 were registered, a recount found **zero** of them reachable from
-> `commonMain`/`commonTest` — the earlier passes had already taken the whole reachable surface, so
+> `commonMain`/`commonTest`, the earlier passes had already taken the whole reachable surface, so
> the "71 reachable" figure below had long since been consumed. The call-graph traces are kept
> because they are still the record of which public API path reaches which C function.
>
@@ -22,7 +22,7 @@
> Recounted from the two files: 187 registered, 178 unregistered (was 145 / 224). Declarations
> still carrying a `jstring` across the boundary went from 52 to 6, and none of those six is on
> the broken path except `ffiSymbolRaw`, which has no caller anywhere in `src/`. The
-> "Critical Risk: Functions taking `String`" section below is therefore resolved in full — not
+> "Critical Risk: Functions taking `String`" section below is therefore resolved in full, not
> just the four functions it happens to list. What remains is the pointer-only surface, and the
> reachability reasoning for that still holds.
>
diff --git a/docs/archive/investigations/gc-scheduling-investigation.md b/docs/archive/investigations/gc-scheduling-investigation.md
index 63565e367..b7b003f54 100644
--- a/docs/archive/investigations/gc-scheduling-investigation.md
+++ b/docs/archive/investigations/gc-scheduling-investigation.md
@@ -1,16 +1,16 @@
-> **Superseded** by [docs/investigations/gc-scheduling-investigation.md](../../investigations/gc-scheduling-investigation.md) on 2026-10-03 — the conclusion, numbers and reproduction steps live there; this is the full original narrative, kept for history.
+> **Superseded** by [docs/investigations/gc-scheduling-investigation.md](../../investigations/gc-scheduling-investigation.md) on 2026-10-03, the conclusion, numbers and reproduction steps live there; this is the full original narrative, kept for history.
# Why a pure C API embedder never collects cycles, and never merges a refcount
Two things this document is careful about, because it corrects a previous document that was not:
- **CPython file:line citations are now verified against the tagged source.** The v3.14.7 tarball
- (the version this build pins — `gradle.properties: pythonVersion=3.14.7`) was fetched to
+ (the version this build pins, `gradle.properties: pythonVersion=3.14.7`) was fetched to
`/Volumes/macMini/cpython-src` for §8d and every line number in this document was re-checked
against it. Two in §1 were wrong by ~100 lines and are corrected below; the rest were right.
Nothing is vendored into this repo. Where a claim matters, §3, §4 and §8 still back it with a
measurement taken here rather than with a citation.
-- **Every number in §3, §5 and §8 was measured on this machine**, on `desktopTest`, macOS arm64,
+- **Every number in §3, §5 and §8 was measured on this machine**: on `desktopTest`, macOS arm64,
CPython 3.14.7, both the default and the `-PpythonFreeThreaded=true` builds.
- **§8 corrects §7.** A measurement probe in `GCSchedulingMeasurementTest` was leaking the list
returned by `gc.get_objects()`, which holds the whole tracked heap, and that leak is what
@@ -36,12 +36,12 @@ not.
materialises a deferred reference is a collection, not a checkpoint. (§3)
- **The checkpoint fixes §1 on the build with the global lock, and on the free-threaded build it
often does not.** It fires exactly as designed there and still reclaims nothing. This is *not*
- caused by the FFI wrapper's per-call GIL scopes — that hypothesis was tested by holding the
+ caused by the FFI wrapper's per-call GIL scopes, that hypothesis was tested by holding the
checkpoint count fixed and widening the scope, and rejected. (§8)
The cause is that free-threaded, `_Py_RunGC` re-asks `gc_should_collect` *after* the checkpoint
has read the scheduled bit, and that check gates generation 0 on the growth of the **whole
- process's** memory footprint — the JVM's. Python-side allocation cannot move it, so the gate
+ process's** memory footprint, the JVM's. Python-side allocation cannot move it, so the gate
shuts one-way as soon as the JVM's footprint settles. Confirmed by opening and closing the gate
on demand, including by faulting in 400 MB of ballast and watching Python cycles get collected
with no threshold changed. (§8d)
@@ -87,7 +87,7 @@ thread is the object's owner, recorded in `ob_tid`:
- **Owner.** `ob_ref_local` is decremented non-atomically. If it reaches zero and the shared count
is zero too, `tp_dealloc` runs immediately, on this thread.
- **Not the owner.** The decrement goes to `ob_ref_shared` atomically. If that count was already at
- zero — i.e. this decrement is the one that takes the object to zero — `tp_dealloc` *cannot* run
+ zero, i.e. this decrement is the one that takes the object to zero, `tp_dealloc` *cannot* run
here, because the object belongs to another thread. It is pushed onto that thread's queue by
`_Py_brc_queue_object` (`Objects/object.c:411`, reached from the inline decref in
`Include/refcount.h:363`).
@@ -97,7 +97,7 @@ The queue is drained by `_Py_brc_merge_refcounts`, whose only caller is, again,
**And once a collection has run, a surviving object can become a non-owner case for everybody.**
On the way out, `gc_restore_tid` (`Python/gc_free_threading.c:325`) puts each survivor's owning
-thread id back — *except* when its shared refcount has been merged, in which case `ob_tid` is left
+thread id back, *except* when its shared refcount has been merged, in which case `ob_tid` is left
at 0 (`:330`) and no thread matches any more, so every further decrement of that object takes the
shared path. (The inherited citation for this was `:205`, which is not the mechanism; merging
itself is at `:264`. The behaviour is as described, but it applies to merged survivors rather than
@@ -105,14 +105,14 @@ to all of them.)
That is the mechanism behind the ROADMAP §9 measurement: 1000 wrappers released by the JVM cleaner,
2003 `Py_DecRef` calls demonstrably made, and `sys.getrefcount` frozen at 1002 through 50 forced
-JVM GCs, 500 further C API round trips and two seconds of wall clock — then one trivial bytecode
+JVM GCs, 500 further C API round trips and two seconds of wall clock, then one trivial bytecode
frame released all thousand at once.
## 3. `CycleCollectionTest`: two defects, and the one that is *not* a checkpoint problem
`CycleCollectionTest.testHandleReleasedWhenProxyDiesWithoutCycle` was the last free-threaded
-failure. It has two independent causes, and the guard assertion — "the type's count must rise by
-exactly `rounds` while the instances are alive, otherwise the probe is not reading a refcount" —
+failure. It has two independent causes, and the guard assertion, "the type's count must rise by
+exactly `rounds` while the instances are alive, otherwise the probe is not reading a refcount",
caught both, exactly as it was written to.
### 3a. The probe read the wrong bytes
@@ -135,7 +135,7 @@ struct _object {
```
`Py_REFCNT` there is `ob_ref_local + (ob_ref_shared >> 2)`. Reading eight bytes at offset 0 yields
-`ob_tid` — which is why the failure reported a ten-digit number that was *identical* before and
+`ob_tid`, which is why the failure reported a ten-digit number that was *identical* before and
after creating 100 instances: measured here, `6171668704` both times. The layout above was
confirmed against this build by dumping the header (see 3b); it is not taken from documentation.
@@ -187,14 +187,14 @@ Reading that off:
walks.
- **And then `tp_dealloc` gives back exactly 100.** Accounting for the flag bits moving from
`_Py_REF_MAYBE_WEAKREF` (1) to `_Py_REF_MERGED` (3) and `ob_ref_local` merging to zero, the total
- goes from `1152921504606847077` back to `1152921504606846977` — the pre-instantiation value,
+ goes from `1152921504606847077` back to `1152921504606846977`, the pre-instantiation value,
exactly.
So the original ROADMAP claim was right about the *mechanism* (heap types are deferred) and wrong
about the *consequence* (that the invariant does not exist and cannot be checked). The invariant
holds on both builds. Free-threaded it is merely not **observable** until a collection has
materialised the deferred references. The later trace was right that instances are ordinary
-reference-counted objects — but the test reads the *type's* count, not an instance's, so that
+reference-counted objects, but the test reads the *type's* count, not an instance's, so that
correction did not touch the failing assertion.
`CycleCollectionTest` therefore takes a `PyGC_Collect()` on either side of its instantiation loop,
@@ -203,8 +203,8 @@ exactly where it started" are asserted on both builds, and both now hold.
### What this means for `abi3t`
-The conclusion drawn from the wrong premise — "so `abi3t` making `PyObject` an incomplete type
-would sink this test" — does not follow either. The test does need to know the header layout, which
+The conclusion drawn from the wrong premise, "so `abi3t` making `PyObject` an incomplete type
+would sink this test", does not follow either. The test does need to know the header layout, which
is a real cost of a direct probe and the one place in this repo that reads inside a `PyObject`. But
that is a property of the probe, not of the invariant, and it would be equally solvable by counting
through `sys.getrefcount` at the price of the argument's own temporary reference. The library
@@ -216,7 +216,7 @@ proper stays clean: `ProxyTypeFactory` writes only into memory it obtained from
`Python3.drainPendingReleases()` reaches a checkpoint the cheapest way there is: it calls a cached,
already-compiled, zero-argument Python function whose body is `pass`, through
`PyObject_CallNoArgs`. That is one `RESUME`, hence one `_CHECK_PERIODIC`, hence one
-`_Py_HandlePending` — one BRC merge, one QSBR sweep, and one scheduled collection if one was
+`_Py_HandlePending`, one BRC merge, one QSBR sweep, and one scheduled collection if one was
scheduled.
It is deliberately **not** `PyRun_SimpleString("pass")` or `exec("pass")`:
@@ -224,7 +224,7 @@ It is deliberately **not** `PyRun_SimpleString("pass")` or `exec("pass")`:
- `PyRun_SimpleString` runs `PyErr_Print()` on failure, which clears the error indicator. This repo
has twice had unrelated tests die because a helper wiped an indicator its caller had not read
yet; `Python3.exec` is built on `PyRun_String` for exactly that reason.
-- `exec("pass")` recompiles a module on every call — 23× the cached call, §5.
+- `exec("pass")` recompiles a module on every call, 23× the cached call, §5.
`withGIL` takes one automatically at its outermost entry, behind two gates: at most one per
`Python3.autoDrainInterval` outermost scopes, and skipped entirely unless `ReleaseCounter.released`
@@ -240,7 +240,7 @@ ROADMAP §1 deadlock) in order to drain an empty queue.
`PyGC_Collect` is the one Stable ABI function that reclaims on behalf of *another* thread, because
`gc_collect_internal` stops the world and walks every thread state, calling
`_PyObject_MergePerThreadRefcounts` and `merge_queued_objects` for each. It costs a heap walk
-rather than a queue pop — §5 — and, as §3b shows, it is also the only thing that materialises a
+rather than a queue pop, §5, and, as §3b shows, it is also the only thing that materialises a
deferred reference.
## 5. Measurements
@@ -251,11 +251,11 @@ design and would break the moment the checkpoint started compiling or allocating
| | ns/op | |
|---|---:|---|
-| `withGIL { }` — attach and detach, nothing else | 170.51 | the floor |
+| `withGIL { }`, attach and detach, nothing else | 170.51 | the floor |
| `Python3.drainPendingReleases()` | 302.70 | the checkpoint, ~132 ns over the floor |
| `withGIL { Py_MakePendingCalls() }` | 197.34 | cheap, and does not merge |
-| `Python3.exec("pass")` | 7 037.04 | 23× the checkpoint — it recompiles |
-| `withGIL { PyGC_Collect() }` | 224 625.00 | 742× the checkpoint — heap walk, but drains every thread |
+| `Python3.exec("pass")` | 7 037.04 | 23× the checkpoint, it recompiles |
+| `withGIL { PyGC_Collect() }` | 224 625.00 | 742× the checkpoint, heap walk, but drains every thread |
At the default `autoDrainInterval = 32` the automatic checkpoint amortises to about 4 ns on a
~170 ns outermost scope, and a workload that drops no wrappers skips it on a field compare.
@@ -277,7 +277,7 @@ collections §3b requires: ~0.45 ms for the whole test, free-threaded only.
actually reaching zero. But note the asymmetry `ProxyTypeFactory` lives with: its hand-written
`tp_dealloc` releases the type through the public `Py_DecRef`, which is deferred-unaware, while
the matching increment went through the deferred-aware `_Py_INCREF_TYPE` and did not happen. That
- balances out once a collection has materialised the references — measured above — but it is
+ balances out once a collection has materialised the references, measured above, but it is
balanced by the collector, not by the two calls.
## 7. GC Accumulation and Reentrancy Measurement
@@ -285,7 +285,7 @@ collections §3b requires: ~0.45 ms for the whole test, free-threaded only.
Measurements taken to verify the consequences of §1 and the safety of the checkpoint confirm:
- **Cyclic Garbage Accumulation (GIL Build)**: A workload creating 10,000 cyclic object groups entirely through the C API (never running a Python bytecode evaluation loop) with `autoDrainInterval = 0` accumulates **20,000+** cyclic garbage objects indefinitely. Because no evaluation loop runs, `_Py_ScheduleGC`'s scheduled bit is never checked by `_CHECK_PERIODIC`, and cyclic GC never occurs.
-- **`autoDrainInterval` Effectiveness (GIL build)**: When running the exact same C API workload but with `autoDrainInterval = 32`, `python-multiplatform` evaluates a dummy function (`__pmp_eval_checkpoint__`) to force the evaluation loop to run periodically. This allows `_CHECK_PERIODIC` to see the scheduled bit, trigger `_Py_RunGC()`, and reclaim the cyclic garbage (leaving ~1,970 of ~20,000). **This result is specific to the build with the global lock.** Free-threaded, the same checkpoints frequently reclaim nothing at all — §8.
+- **`autoDrainInterval` Effectiveness (GIL build)**: When running the exact same C API workload but with `autoDrainInterval = 32`, `python-multiplatform` evaluates a dummy function (`__pmp_eval_checkpoint__`) to force the evaluation loop to run periodically. This allows `_CHECK_PERIODIC` to see the scheduled bit, trigger `_Py_RunGC()`, and reclaim the cyclic garbage (leaving ~1,970 of ~20,000). **This result is specific to the build with the global lock.** Free-threaded, the same checkpoints frequently reclaim nothing at all, §8.
- **`__del__` Reentrancy Risk**: Executing `PyGC_Collect()` or processing a checkpoint can invoke `__del__` methods. A scenario where `__del__` directly calls back into Kotlin (via Panama upcalls) was executed (`GCSchedulingMeasurementTest.testReentrancyDuringCheckpoint`). The reentrancy is safe on the GIL build: it does not deadlock, it does not loop infinitely, and it successfully executes the Kotlin upcall while the garbage collection is in progress.
## 8. Free-threaded: the checkpoint fires and reclaims nothing, and it is not the scope shape
@@ -306,7 +306,7 @@ which closes none of its temporaries. `gc.get_objects()` returns a list holding
reference to every tracked object in the interpreter**, so a leaked one roots the very garbage the
next measurement is trying to see.
-That leak is where §7's earlier free-threaded conclusion came from — the claim that "a trailing
+That leak is where §7's earlier free-threaded conclusion came from, the claim that "a trailing
explicit `gc.collect()` finds nothing further to reclaim, so the residue is not simply garbage
waiting for the next checkpoint," and the inference from it that free-threaded CPython was failing
to *reclaim* something. With the temporaries released, **an explicit `gc.collect()` reclaims the
@@ -322,7 +322,7 @@ text asserted about it.
The standing hypothesis was that because free-threading keeps `eval_breaker` per thread state, an
FFI wrapper that attaches and detaches around every C API call destroys and recreates the thread
-state, discarding the scheduled GC bit with it — and that widening the scope would therefore fix
+state, discarding the scheduled GC bit with it, and that widening the scope would therefore fix
collection.
**Measured and rejected.** Holding the checkpoint count fixed at 2,000 explicit
@@ -331,13 +331,13 @@ varying *only* the enclosing scope, free-threaded, residue out of 20,000:
| enclosing scope | reps | residue |
|---|---:|---|
-| none — one `withGIL` per C API call | 5 | 20,000 ×5 |
+| none, one `withGIL` per C API call | 5 | 20,000 ×5 |
| one per round (~12 C API calls) | 5 | 20,000 ×5 |
| one per 100 rounds | 10 | 20,000 ×10 |
| one for the whole 10,000-round workload | 10 | 11,562 / 1,280 / 1,280, then 20,000 ×7 |
-Widening the scope to 100 rounds — which gives the thread state a long life and hundreds of
-allocations before it is destroyed — changes nothing whatsoever. The hypothesis predicts the
+Widening the scope to 100 rounds, which gives the thread state a long life and hundreds of
+allocations before it is destroyed, changes nothing whatsoever. The hypothesis predicts the
opposite, so it is wrong.
The single-scope row does not rescue it either: **it is bimodal within one JVM.** Those ten
@@ -349,7 +349,7 @@ With the automatic checkpoint instead (`autoDrainInterval = 32`), free-threaded:
| enclosing scope | checkpoints taken | residue |
|---|---:|---|
-| none — one `withGIL` per C API call | 1,875 | 20,000 ×5; also 7,427 and 11,523 in two single-repetition JVMs |
+| none, one `withGIL` per C API call | 1,875 | 20,000 ×5; also 7,427 and 11,523 in two single-repetition JVMs |
| one per round | 312–313 | 20,000 ×5 |
| one per 100 rounds | 3 | 20,000 ×5 |
| one for the whole workload | **0** | 20,000 ×3 |
@@ -372,7 +372,7 @@ The same sweep on the build with the global lock is stable and near-complete in
| one per 100 rounds, `autoDrainInterval = 32` | **3** | 1,600–3,200 |
Three checkpoints reclaim ~90% on the GIL build. Two thousand reclaim nothing free-threaded. That
-gap — not the scope shape, which is irrelevant on both builds — is the whole finding.
+gap, not the scope shape, which is irrelevant on both builds, is the whole finding.
### 8d. Answered: the checkpoint is fine, the collector asks a second question
@@ -391,7 +391,7 @@ question, and it is that one that answers no.
| | global lock (`Python/gc.c`) | free-threaded (`Python/gc_free_threading.c`) |
|---|---|---|
| schedules on | `generations[0].count > threshold`, and nothing else (`gc.c:1866`) | `gc_should_collect` (`:2153`) |
-| re-checked when the checkpoint runs it | `gc_select_generation` — generation 0 on count alone (`gc.c:1258`) | `gc_should_collect` **again** (`:2328`) |
+| re-checked when the checkpoint runs it | `gc_select_generation`, generation 0 on count alone (`gc.c:1258`) | `gc_should_collect` **again** (`:2328`) |
| process-memory gate | **none**; `gc.c` has no `last_mem` field and never calls `get_process_mem_usage` | `gc_should_collect_mem_usage` (`:2080`) |
| `long_lived_total / 4` | oldest generation only (`gc.c:1300`) | **every** generation-0 decision (`:2131`) |
@@ -413,12 +413,12 @@ young.count = 0; deferred_count += young.count; return false; // :2109
```
`footprint` is `get_process_mem_usage()`, which on macOS is
-`task_info(TASK_VM_INFO).phys_footprint` (`:2010`) — **the whole process**, i.e. the JVM.
+`task_info(TASK_VM_INFO).phys_footprint` (`:2010`), **the whole process**, i.e. the JVM.
`last_mem` is written in exactly one place, after a collection (`:2301`).
So, in a JVM-hosted embedder: 20,000 small Python lists cannot move a JVM's physical footprint by
the required `last_mem / 10`, the gate returns false, and on the way out it **zeroes `young.count`
-into `deferred_count`** — which means the count has to climb from zero to 2,000 again before the
+into `deferred_count`**, which means the count has to climb from zero to 2,000 again before the
question is even asked again. The only escape is `deferred_count > threshold * 40` = 80,000
container allocations at the default threshold, and this workload makes ~20,000. Checkpoints are
irrelevant to all of it; the bit gets read and `gc_collect_main` declines at `:2328`.
@@ -435,7 +435,7 @@ The repo has been wrong before about causes derived from source reading, so each
turned into a prediction that could fail. All of it is in
`python-multiplatform/src/desktopTest/.../ref/FreeThreadedGCGateTest.kt`.
-**Which gate declines** — `gc_should_collect_mem_usage`'s failing branch zeroes `young.count`
+**Which gate declines**, `gc_should_collect_mem_usage`'s failing branch zeroes `young.count`
(`:2109`); the `long_lived_total/4` branch (`:2131`) does not. So the shape of `gc.get_count()[0]`
across the workload tells them apart. Free-threaded it is a **sawtooth**
(`[1728, 1488, 976, 464, 2464, 1488, 976, 464, 2464, 1488]`) with 0 collections and residue 20,000:
@@ -449,36 +449,36 @@ the memory gate, repeatedly.
> asked, and produced a perfectly linear trace (`226, 726, 1226, … 19726`) that reads exactly like
> proof of the *other* hypothesis. Sampling every 2,000 allocations instead gives the sawtooth.
-**The one-line falsifier** — `:2126` says `old[0].threshold == 0` short-circuits the whole chain.
-Nothing in `gc.c` reads that field while scheduling. So `gc.set_threshold(2000, 0, 0)` — a setting
-about *generation 1* — must switch *generation 0* collection on free-threaded and do nothing at all
+**The one-line falsifier**, `:2126` says `old[0].threshold == 0` short-circuits the whole chain.
+Nothing in `gc.c` reads that field while scheduling. So `gc.set_threshold(2000, 0, 0)`, a setting
+about *generation 1*, must switch *generation 0* collection on free-threaded and do nothing at all
with the global lock. Run A/B/A in one JVM so the latch cannot explain the difference; residue out
of 20,000, and the number of collections that actually ran:
| arm | free-threaded | global lock |
|---|---|---|
-| `(2000, 10, 10)` | 20,000 — 0 collections | 1,968 — 9 collections |
-| `(2000, 0, 0)` | **1,318 — 8 collections** | 1,966 — 5 collections |
-| `(2000, 10, 10)` again | 20,000 — 0 collections | 1,970 — 9 collections |
+| `(2000, 10, 10)` | 20,000, 0 collections | 1,968, 9 collections |
+| `(2000, 0, 0)` | **1,318, 8 collections** | 1,966, 5 collections |
+| `(2000, 10, 10)` again | 20,000, 0 collections | 1,970, 9 collections |
-**The `deferred_count` arithmetic** — the escape bar is `40 × threshold0`, and `deferred_count`
+**The `deferred_count` arithmetic**, the escape bar is `40 × threshold0`, and `deferred_count`
grows *only* in the memory gate's failing branch. Against a ~20,000-allocation workload that
predicts no collection at threshold0 = 2000 (bar 80,000) or 500 (bar 20,000), and collection at
100 (bar 4,000). Free-threaded: 20,000 / 20,000 / **1,466 with 3 collections**. The same sweep with
the global lock just scales smoothly with the threshold (9 / 36 / 172 collections), because there
-is no such bar. This also proves the `long_lived_total/4` gate at `:2131` is being *passed* —
+is no such bar. This also proves the `long_lived_total/4` gate at `:2131` is being *passed*,
nothing else feeds `deferred_count`.
-**The latch, driven directly** — if the gate is whole-process footprint, then faulting in 400 MB of
+**The latch, driven directly**, if the gate is whole-process footprint, then faulting in 400 MB of
ballast partway through the workload must open it, while changing no threshold, allocating no extra
Python container and touching nothing the collector tracks. Dropping the ballast must shut it
again. Residue out of 20,000:
| arm | free-threaded | global lock |
|---|---|---|
-| baseline | 20,000 — 0 collections | 1,972 — 9 collections |
-| 400 MB faulted in midway | **9,754 — 1 collection** | 1,988 — 9 collections |
-| ballast dropped | 20,000 — 0 collections | 1,968 — 9 collections |
+| baseline | 20,000, 0 collections | 1,972, 9 collections |
+| 400 MB faulted in midway | **9,754, 1 collection** | 1,988, 9 collections |
+| ballast dropped | 20,000, 0 collections | 1,968, 9 collections |
Reproduced identically to the object in three consecutive JVMs. **JVM memory growth, and nothing
else, collected Python cycles.** That is the finding: on a free-threaded build the Python cyclic
@@ -488,7 +488,7 @@ Python's memory at all.
One experiment did **not** confirm what it was built to test and is recorded as such: scaling the
workload (10,000 / 30,000 / 60,000 cycles) at default thresholds to cross the 80,000 bar gave
1 / 2 / 2 collections, but the 10,000-cycle arm should have given 0. It ran first in its JVM, while
-the footprint was still climbing, so the memory gate — the very thing being controlled for —
+the footprint was still climbing, so the memory gate, the very thing being controlled for,
supplied the collections. The scaling prediction is untested, not confirmed.
#### What this changes
@@ -508,8 +508,8 @@ supplied the collections. The scaling prediction is untested, not confirmed.
#### The advice does not change
-An embedder can open the gate deliberately — `gc.set_threshold(t0, 0, 0)` restores count-driven
-collection free-threaded, and a small `threshold0` lowers the `40 × threshold0` bar — but both
+An embedder can open the gate deliberately, `gc.set_threshold(t0, 0, 0)` restores count-driven
+collection free-threaded, and a small `threshold0` lowers the `40 × threshold0` bar, but both
trade throughput for it, and both are tuning of CPython internals that the Stable ABI does not
promise. The supported way to collect cycles in an embedder that runs no Python bytecode is still
the one that works identically on both builds, documented in §4: `PyGC_Collect()`, at a cost
diff --git a/docs/archive/investigations/gil-parking-investigation.md b/docs/archive/investigations/gil-parking-investigation.md
index 83709d809..f70a13dbe 100644
--- a/docs/archive/investigations/gil-parking-investigation.md
+++ b/docs/archive/investigations/gil-parking-investigation.md
@@ -1,4 +1,4 @@
-> **Superseded** by [docs/investigations/gil-parking-investigation.md](../../investigations/gil-parking-investigation.md) on 2026-10-03 — the conclusion, numbers and reproduction steps live there; this is the full original narrative, kept for history.
+> **Superseded** by [docs/investigations/gil-parking-investigation.md](../../investigations/gil-parking-investigation.md) on 2026-10-03, the conclusion, numbers and reproduction steps live there; this is the full original narrative, kept for history.
# Why enabling `PyEval_SaveThread()` in `Python3.initialize()` crashes desktopTest
@@ -10,7 +10,7 @@ below are cited from `python/cpython` at tag `v3.14.0` on GitHub, fetched read-o
**Ruled out, with source evidence:** the failure is not "`PyGILState_Ensure` creates a second,
competing `PyThreadState`" on the initialising thread. It reuses the *same* `PyThreadState*` that
`Py_Initialize()` created. This is CPython's documented, intended mechanism for exactly this
-pattern (park, then re-enter from a C thread later) — see §2 and §3.
+pattern (park, then re-enter from a C thread later), see §2 and §3.
**Established, with source evidence, but not sufficient by itself:** `PyEval_SaveThread()` leaves
the calling OS thread's slot in the GILState TSS (`autoTSSkey`) pointing at the parked
@@ -33,7 +33,7 @@ whichever thread first touches it. `desktopTest`'s Gradle configuration
(`python-multiplatform/build.gradle.kts:724`) sets no `maxParallelForks`, no
`junit.jupiter.execution.parallel.*` property exists anywhere in the repo, and no
`junit-platform.properties` exists. Gradle's `Test` task therefore runs the whole suite
-sequentially inside one worker JVM, on the JVM's single test-execution thread — which is named
+sequentially inside one worker JVM, on the JVM's single test-execution thread, which is named
`"Test worker"` by Gradle, matching the thread name in today's crash report verbatim. So
`Python3.initialize()` and `EmbedApiLowLevelTest.importReturnsANewReferenceAndMissingModulesReportFailure`
run on the **same OS thread**, in the same process, with nothing else attaching in between.
@@ -97,7 +97,7 @@ GIL is released, and the per-OS-thread "current thread state" pointer
own comment above states outright that detaching deliberately leaves it alone.
By the end of `Py_Initialize()` (before the parking call), the calling thread's GILState TSS slot
-is already bound to the main thread's `PyThreadState`, with `gilstate_counter == 1` — confirmed by
+is already bound to the main thread's `PyThreadState`, with `gilstate_counter == 1`, confirmed by
`_PyGILState_SetTstate()`'s debug assertions (`Python/pystate.c`):
```c
@@ -106,8 +106,8 @@ assert(gilstate_tss_get(runtime) == tstate);
assert(tstate->gilstate_counter == 1);
```
-(`bind_gilstate_tstate` is what performs that binding, called from `tstate_activate` — i.e. from
-inside `_PyThreadState_Attach`/`PyEval_RestoreThread` — the first time the main thread is
+(`bind_gilstate_tstate` is what performs that binding, called from `tstate_activate`, i.e. from
+inside `_PyThreadState_Attach`/`PyEval_RestoreThread`, the first time the main thread is
attached during startup.)
## 3. What `PyGILState_Ensure()` then does on that same thread
@@ -146,16 +146,16 @@ the "new thread" branch (which would create a genuinely second `PyThreadState`)
`tcur` is the exact same `PyThreadState*` `Py_Initialize()` created. `holds_gil(tcur)` compares
`tcur` against `current_fast_get()`, which is `NULL` after parking, so `has_gil = 0`, and
`PyEval_RestoreThread(tcur)` re-attaches that same object (`current_fast_set`, GIL re-acquired,
-`tstate_activate` — which is a no-op on the already-bound gilstate slot). `gilstate_counter` goes
+`tstate_activate`, which is a no-op on the already-bound gilstate slot). `gilstate_counter` goes
1 → 2. `PyGILState_Release()` mirrors this exactly: decrements 2 → 1, and because the saved
`oldstate` was `PyGILState_UNLOCKED`, it re-parks with another internal `PyEval_SaveThread()` call.
This is self-consistent and, cycled repeatedly, returns to the same `gilstate_counter == 1`,
detached baseline every time. **Nothing in this path frees, reallocates, or otherwise clears the
-frame stack, exception state, or recursion bookkeeping that the object already carried** — it is
+frame stack, exception state, or recursion bookkeeping that the object already carried**, it is
literally the same object, reused.
This rules out "two thread states corrupting each other's interpreter frame stack" as the
-mechanism, at least on the initialising thread — there is only ever one `PyThreadState` in play
+mechanism, at least on the initialising thread, there is only ever one `PyThreadState` in play
here. It does not, by itself, identify what *does* go wrong.
## 4. Whether `PyGILState_Release` is handed an invalid state
@@ -166,7 +166,7 @@ still-registered `tcur`. There is no thread-identity mismatch and no stale/delet
`Release` in this flow. (Contrast with the *different*, already-fixed bug documented in
`GCLeakTest.desktop.kt:6-11`: an earlier version called `PyEval_SaveThread()` from `forceGC()` on
a thread that was not attached at all. `PyEval_SaveThread`'s body dereferences
-`_PyThreadState_GET()` unconditionally — on a PGO/LTO release build, `assert()`s such as
+`_PyThreadState_GET()` unconditionally, on a PGO/LTO release build, `assert()`s such as
`detach_thread`'s `assert(tstate == current_fast_get())` compile out, so a `NULL` tstate reaches
`detach_thread` and is dereferenced (`tstate->critical_section`) with no check. That is a plain
null-pointer crash from calling `PyEval_SaveThread` while unattached, and it is a different bug
@@ -186,7 +186,7 @@ knowing before trusting older blog posts or Stack Overflow answers about this AP
Two candidates for what actually breaks, neither confirmed:
-**Candidate A — stale per-thread C-stack recursion limits.** Each `_PyThreadStateImpl` carries
+**Candidate A, stale per-thread C-stack recursion limits.** Each `_PyThreadStateImpl` carries
`c_stack_top` / `c_stack_soft_limit` / `c_stack_hard_limit`, computed **once** by
`_Py_InitializeRecursionLimits()` from the current machine stack pointer, and only if
`c_stack_hard_limit == 0`:
@@ -204,13 +204,13 @@ _PyThreadState_Attach(PyThreadState *tstate)
```
Because our thread reuses the same `PyThreadState` object across every park/re-enter cycle, this
-calibration happens exactly once — at whatever C call depth `Py_Initialize()`'s *first* internal
-attach happened at — and is **never** recomputed on later re-entries from a structurally different
+calibration happens exactly once, at whatever C call depth `Py_Initialize()`'s *first* internal
+attach happened at, and is **never** recomputed on later re-entries from a structurally different
call depth (the JVM/Panama boundary is a different C stack shape than CPython's own
`Py_InitializeFromConfig` call chain). `_Py_CheckRecursiveCall` compares the live stack pointer
against this stale `c_stack_hard_limit` on every recursive C call in the eval loop
(`if (here_addr < c_stack_hard_limit) Py_FatalError(...)`). This is a real, citable staleness bug
-in the reuse path — but by itself it explains a *miscalibrated guard*, and the failure it produces
+in the reuse path, but by itself it explains a *miscalibrated guard*, and the failure it produces
when it fires is a `RecursionError` or a `Py_FatalError` with the text "Unrecoverable stack
overflow", neither of which matches today's raw `SIGSEGV` (no such message was reported), and a
single-frame `PyImport_ImportModule("sys")` call is not deep enough to plausibly exhaust several
@@ -218,16 +218,16 @@ hundred KB to MB of C stack on its own. **I could not make this candidate fit th
without more evidence, and flag it mainly because it's a real, separate latent hazard in the reuse
pattern that the fix should be aware of regardless of whether it explains today's crash.**
-**Candidate B — something the eval loop touches on the initial (never-parked) startup path that
+**Candidate B, something the eval loop touches on the initial (never-parked) startup path that
`import sys`'s cache-hit-with-Python-level-bookkeeping path exercises differently.**
`PyImport_ImportModule("sys")` for an already-loaded module does not simply `PyDict_GetItemString`
-and return — `import_ensure_initialized()` (in `Modules/import.c`, not fetched in this
+and return, `import_ensure_initialized()` (in `Modules/import.c`, not fetched in this
investigation) can invoke Python-level machinery (attribute lookups on `__spec__`, method calls),
which is consistent with the reported crash frames (`PyObject_CallMethodObjArgs` →
`_PyFunction_Vectorcall` → `DICT_MERGE`) being genuine bytecode execution rather than a pure C
-lookup. I could not determine, from source reading alone, why *this* bytecode execution — on a
+lookup. I could not determine, from source reading alone, why *this* bytecode execution, on a
thread whose `PyThreadState` is otherwise identical to the one `Py_Initialize()` used successfully
-— would be unsafe. This is the open question the experiment in §6 is aimed at.
+would be unsafe. This is the open question the experiment in §6 is aimed at.
I was not able to reduce this to one explanation. Ranked: reuse of the same `PyThreadState` across
the park/re-enter boundary is established fact and is the right place to keep looking; which piece
@@ -236,7 +236,7 @@ failure shape and Candidate B unconfirmed.
## 6. Minimal experiment (does not require the full suite)
-Add a single desktop test — not to run today, but to hand to the next attempt — that escalates in
+Add a single desktop test, not to run today, but to hand to the next attempt, that escalates in
four steps on **one thread**, printing/asserting between each so a failure localizes to a specific
step instead of reporting "0 tests run":
@@ -295,7 +295,7 @@ fun parkThenReenterSameThreadIsolatesTheFailure() {
}
```
-This is deliberately not `@Test`-annotated as ready to run blind — the next attempt should run it
+This is deliberately not `@Test`-annotated as ready to run blind, the next attempt should run it
alone (`--tests` filter), on a quiet tree, and stop at the first step that crashes rather than
proceeding to the next. That directly gives a call-depth-bounded bisection of the failure, which
the "audit every call site" and "run the whole suite" attempts so far have not produced.
@@ -306,8 +306,8 @@ investigation (see §7).
## 7. What the fix would look like, under each candidate
-- **If Candidate A (stale C-stack calibration) turns out to matter**, no amount of call-site
- guarding fixes it — the fix has to either recalibrate `c_stack_hard_limit` on every reattachment
+- **If Candidate A (stale C-stack calibration) turns out to matter**: no amount of call-site
+ guarding fixes it, the fix has to either recalibrate `c_stack_hard_limit` on every reattachment
(not something this project's API surface exposes; would need a non-limited-API call or a
CPython-side change) or ensure the *first* attach happens at a call depth structurally
representative of where Python will actually run later. Concretely: call `Py_Initialize()`
@@ -317,14 +317,14 @@ investigation (see §7).
one-line fix.
- **If Candidate B (something Python-level import bookkeeping depends on) turns out to matter**,
- the fix likely isn't about *this* call site specifically — `PyImport_ImportModule("sys")` is
- representative of "any first bytecode execution after reattachment", not a special case — so
+ the fix likely isn't about *this* call site specifically, `PyImport_ImportModule("sys")` is
+ representative of "any first bytecode execution after reattachment", not a special case, so
narrowing further requires the bisection in §6 before a fix can be scoped at all.
- **If the experiment shows Step 1 or Step 2 already crashes** (i.e. even bare reattachment or a
pure-C call is unsafe), that overturns the "reuse is fine, something else breaks" framing of this
whole document, and the next place to look is the desktop Panama bindings for exactly these four
- functions (`bindings.kt:41-50`) — this project has a documented, repeated history of exactly this
+ functions (`bindings.kt:41-50`), this project has a documented, repeated history of exactly this
class of bug (`invoke` vs `invokeExact`, `ADDRESS` vs `JAVA_LONG`), and while a quick read during
this investigation found `PyEval_SaveThread`/`RestoreThread`/`PyGILState_Ensure`/`Release` all
using `invokeExact` and the project's `P = JAVA_LONG` pointer convention correctly, that read was
@@ -336,23 +336,23 @@ investigation (see §7).
fundamentally what's unsafe in this embedding (as opposed to something narrower), the design
question becomes whether the initialising thread should ever call `PyGILState_Ensure` again at
all. An alternative shape: run `Py_Initialize()` on a dedicated thread that parks once and is
- never reused for Python work afterward — every subsequent call, including from what is currently
+ never reused for Python work afterward, every subsequent call, including from what is currently
"the same thread" in tests, would then go through the "first time on this OS thread" branch of
`PyGILState_Ensure`, which creates a genuinely fresh `PyThreadState` rather than resurrecting the
init-time one. That is a bigger change than uncommenting the parking line (it changes where
`Py_Initialize()`/`Py_Finalize()` are allowed to be called from, and `Python3.finalize()`'s
`PyEval_RestoreThread(mainThreadState)` call would need to run on that same dedicated thread
too), and it should not be attempted until the experiment in §6 has actually localized the fault
- — building it on a guess would repeat the pattern this document was asked to stop.
+ , building it on a guess would repeat the pattern this document was asked to stop.
## 8. What could not be determined
- **The exact opcode-level or memory-level reason `DICT_MERGE` faults.** Both candidates in §5 are
plausible mechanisms consistent with *some* of the evidence, but neither was confirmed against
the specific crash. This needs the experiment in §6, or a debugger session (`lldb` on the core,
- or a build with `Py_DEBUG` to turn the compiled-out `assert()`s back on) — deliberately not
+ or a build with `Py_DEBUG` to turn the compiled-out `assert()`s back on), deliberately not
attempted here per the "prefer reading over running" instruction for this task.
-- **Whether `import_ensure_initialized`'s Python-level path (Candidate B) is real** — I did not
+- **Whether `import_ensure_initialized`'s Python-level path (Candidate B) is real**: I did not
fetch `Modules/import.c`, so the claim that a `sys.modules` cache hit still runs Python-level
code is inferred from general CPython import-machinery knowledge, not confirmed against 3.14
source in this session.
diff --git a/docs/archive/investigations/jni-call-convention-audit.md b/docs/archive/investigations/jni-call-convention-audit.md
index 874b23750..9163dbf47 100644
--- a/docs/archive/investigations/jni-call-convention-audit.md
+++ b/docs/archive/investigations/jni-call-convention-audit.md
@@ -1,4 +1,4 @@
-> **Superseded** by [docs/investigations/jni-call-convention-audit.md](../../investigations/jni-call-convention-audit.md) on 2026-10-03 — the conclusion, numbers and reproduction steps live there; this is the full original narrative, kept for history.
+> **Superseded** by [docs/investigations/jni-call-convention-audit.md](../../investigations/jni-call-convention-audit.md) on 2026-10-03, the conclusion, numbers and reproduction steps live there; this is the full original narrative, kept for history.
# JNI Calling Convention Audit
@@ -200,12 +200,12 @@ Based on CPython's documented semantics, the following rules apply when classify
Written after checking section 2 against this repository's own measurements and against the
registration table as it stands at 187 entries (the audit read 71).
-**The table holds 366 today**, so this resolution is itself a snapshot — roughly 180 registrations
+**The table holds 366 today**, so this resolution is itself a snapshot, roughly 180 registrations
have never been classified by any hand-written pass. That is why the classification moved into
`JniCallConventionClassificationTest`, which re-derives it from `bindings.kt`, `jni_onload.def` and
the bundled CPython headers on every desktop build. Its re-derivation agrees with the judgement
-recorded here — the eight GC-blocking call sites reproduce exactly, no function was wrongly
-promoted, and all 366 prologues match their convention — so what was missing was never correctness,
+recorded here, the eight GC-blocking call sites reproduce exactly, no function was wrongly
+promoted, and all 366 prologues match their convention, so what was missing was never correctness,
only a way for it to stay correct.
## The promotion to `@CriticalNative` is rejected
@@ -226,7 +226,7 @@ API 26-31, only ~2-4ns on API 34+". That is backwards. Measured here, net of the
`@CriticalNative` is a **loss** on the two newest levels measured. Taking section 2 as written
would slow every one of those eight functions down on any device from Android 14 onward.
-It could be had behind the existing `preferFastNative` branch — critical below 34, ordinary above.
+It could be had behind the existing `preferFastNative` branch, critical below 34, ordinary above.
That is not being done, for three reasons.
**It is not a relabel.** `@CriticalNative` receives no `JNIEnv` or `jclass`, so it cannot reuse the
@@ -240,11 +240,11 @@ registration name does not change.
| | audit says | actually |
|---|---|---|
| `PyGILState_Release` | "unlocks mutex (non-blocking)" | the release that drops the counter to zero deletes the thread state; clearing a thread state decrefs its dict and exception state, so `__del__` runs |
-| `PyThreadState_GetDict` | "reads struct pointer" | allocates the thread dict with `PyDict_New` on first call per thread — a GC-tracked allocation, so rule 2 applies |
+| `PyThreadState_GetDict` | "reads struct pointer" | allocates the thread dict with `PyDict_New` on first call per thread, a GC-tracked allocation, so rule 2 applies |
| `PyEval_InitThreads` | "initializes locking" | a no-op retained for the stable ABI (removed from the C API in 3.13; still exported by the bundled `libpython`, confirmed with `nm -D`). Promoting a no-op buys the convention delta and nothing else |
That leaves `Py_IncRef`, `Py_NewRef`, `Py_XNewRef`, `PyGILState_GetThisThreadState` and
-`PyEval_SaveThread` as genuine leaves — and of those, only the three refcount operations are on a
+`PyEval_SaveThread` as genuine leaves, and of those, only the three refcount operations are on a
path called often enough for tens of nanoseconds to accumulate.
**The audit's own confidence claim does not hold.** "Unresolved entries: none" is not available
@@ -256,12 +256,12 @@ The static sweep found one place where the current configuration contradicts the
it is a demotion rather than a promotion.
`PyList_GetItemRaw` was registered `@CriticalNative` with **no `@FastNative` twin and no
-`preferFastNative` branch** — the only declaration in `bindings.kt` that ignores the device axis.
+`preferFastNative` branch**, the only declaration in `bindings.kt` that ignores the device axis.
It is also the per-element call of bulk list iteration, so the penalty multiplies by N. ROADMAP §5
measured `list → LongArray`, 1000 elements, at 50065.89 ns on API 36 hardware: 50 ns per element,
against a `@CriticalNative` net cost of 44.05 ns on that device. That 50 ns also covers the loop and
the `toNativePointer` conversion, so 44.05 is an upper bound on the convention's share rather than
-an attribution — but it is an upper bound of 88%, which is enough to act on.
+an attribution, but it is an upper bound of 88%, which is enough to act on.
So a `@FastNative` twin was added (`f_PyList_GetItemRaw` in `jni_onload.def`,
`PyList_GetItemRawF` in `bindings.kt`) and the call site now branches on `preferFastNative`, the
@@ -279,7 +279,7 @@ composition was paying for a configuration mistake rather than for crossings.
## The registrations added since the audit: 116, all ordinary, no defects
-The audit read 71 entries. The table has 187 — 74 added at `9abc8edd`, 42 at `aea09585`, none
+The audit read 71 entries. The table has 187, 74 added at `9abc8edd`, 42 at `aea09585`, none
removed.
**Every one of the 116 is registered `N`-suffixed and declared plain `@JvmStatic external fun`.**
@@ -323,16 +323,16 @@ path and all eight can allocate a GC-tracked exception on the failure path:
| call site | success path | failure path |
|---|---|---|
-| `Py_IsInitialized` | reads a global | — |
-| `Py_GetVersion` | returns a static `const char*` | — |
-| `PyErr_Occurred` | reads the thread state, borrowed | — |
+| `Py_IsInitialized` | reads a global | - |
+| `Py_GetVersion` | returns a static `const char*` | - |
+| `PyErr_Occurred` | reads the thread state, borrowed | - |
| `PyLong_FromLongLong` | non-GC allocation | preallocated `MemoryError` |
| `PyUnicode_FromString` | non-GC allocation | `UnicodeDecodeError` on malformed UTF-8 |
| `PyUnicode_AsUTF8` | caches the UTF-8 form | `UnicodeEncodeError` on lone surrogates |
| `PyList_Size` | reads `ob_size` | `PyErr_BadInternalCall` on a non-list |
| `PyList_GetItem` | indexes the item array | `IndexError` out of range |
-That second column is second-order and conditional — the exception instance is GC-tracked, so it
+That second column is second-order and conditional, the exception instance is GC-tracked, so it
can cross the collection threshold, so the cyclic collector can run a `__del__`, which with upcalls
live can be Kotlin. It is not a live defect and it is not a reason to demote these eight, which are
the measured hot path. It is the reason step 4 of the decision procedure does not pretend "provably
diff --git a/docs/archive/kotlin-extensions-in-python-pyi.md b/docs/archive/kotlin-extensions-in-python-pyi.md
index dbf408b54..cec23e0ac 100644
--- a/docs/archive/kotlin-extensions-in-python-pyi.md
+++ b/docs/archive/kotlin-extensions-in-python-pyi.md
@@ -1,10 +1,10 @@
-# Kotlin extensions in Python — the original `.pyi` section (archived)
+# Kotlin extensions in Python, the original `.pyi` section (archived)
> **Superseded** by [`docs/design/pyi-generation-design.md`](../design/pyi-generation-design.md) and
> [SPEC.md](../SPEC.md) B-7 on 2026-10-03; kept for history.
>
> This is §4.5 of `docs/design/kotlin-extensions-in-python.md` as it stood before 2026-10-03. It
-> proposed that this repository's Gradle plugin emit a Pythonic stub — extension-as-method, the
+> proposed that this repository's Gradle plugin emit a Pythonic stub, extension-as-method, the
> `Modifier` metaclass, `@overload` sets, `Dp | float` for allow-listed value classes. The plugin now
> emits Kotlin-named stubs with boundary-type annotations only, and the Pythonic stub product belongs
> to pythonx-compose. The three measured bullets (`@overload` is mandatory for 33 of 130 `Modifier`
@@ -23,7 +23,7 @@ method, and the metaclass of §4.2 is expressible:
> instance method and rejects every class-object call; see §4.2's note and
> `docs/archive/pyi-generation-pythonic-stubs.md` §4.3. It is kept here because the three bullets that follow it are
> about `@overload`, `Dp | float` and defaults, and all three survive unchanged into the shape that
-> does work — `padding: ClassVar[_Modifier_padding]` where `_Modifier_padding` is a Protocol whose
+> does work, `padding: ClassVar[_Modifier_padding]` where `_Modifier_padding` is a Protocol whose
> `__call__` carries exactly these overloads (`docs/archive/pyi-generation-pythonic-stubs.md` §4.4). What changes is
> where the overloads are written, not which overloads there are.
@@ -47,7 +47,7 @@ Three things follow from the measurements:
- **`@overload` is mandatory, not optional.** 33 of 130 `Modifier` names carry more than one
overload, and they are the load-bearing ones (§3.1). A generator that emits one signature per
name will mistype `padding`, `size`, `background`, `border` and `clickable`.
-- **`Dp | float` is how the allowlist shows up in the stub**, and a type on the reject list is
+- **`Dp | float` is how the allowlist shows up in the stub**: and a type on the reject list is
stubbed as the proxy alone. The stub is then the documentation for §4.4's asymmetry.
- **Defaults must be carried.** 58% of `Modifier` extension parameters declare one; a stub with
everything required would be wrong about most of the API. Metadata's `declaresDefaultValue` is
diff --git a/docs/archive/pyi-generation-pythonic-stubs.md b/docs/archive/pyi-generation-pythonic-stubs.md
index 96fb668a2..58b2534b5 100644
--- a/docs/archive/pyi-generation-pythonic-stubs.md
+++ b/docs/archive/pyi-generation-pythonic-stubs.md
@@ -1,4 +1,4 @@
-# Generating `.pyi` — the Pythonic stub product (archived)
+# Generating `.pyi`, the Pythonic stub product (archived)
> **Superseded** by [`docs/design/pyi-generation-design.md`](../design/pyi-generation-design.md) and
> [SPEC.md](../SPEC.md) B-7 on 2026-10-03; the Pythonic stub product belongs to pythonx-compose; kept
@@ -26,7 +26,7 @@
`pythonx` adapts Kotlin generically rather than wrapping function by function (`docs/design/ecosystem.md`
§5b): a module `__getattr__` finds the corresponding binding, adapts it once and caches it. Nothing
-an editor can see is ever enumerated. `.pyi` is what pays that back — the stubs carry the fully
+an editor can see is ever enumerated. `.pyi` is what pays that back, the stubs carry the fully
enumerated Pythonic surface the adapter will produce, so an IDE sees everything while the runtime
enumerates nothing. Generation belongs to the Gradle plugin, the way PyREPL did it; that is settled
and this file does not revisit it.
@@ -57,7 +57,7 @@ descriptor, `list` = the hand-maintained allowlist of §3.4.
| `ByteArray` | `bytes` | Km/KS | |
| `Unit` | `None` | Km/KS | return position only |
| `Nothing` | `typing.Never` | Km/KS | a function returning it never returns; `Never` is the exact Python spelling |
-| `T?` | `T \| None` | `KmType.isNullable`, `KSType.isMarkedNullable` | **not bindable today** — `resolveKotlinType` declines nullable outright. §3.3 |
+| `T?` | `T \| None` | `KmType.isNullable`, `KSType.isMarkedNullable` | **not bindable today**, `resolveKotlinType` declines nullable outright. §3.3 |
| `List` `MutableList` | `list[T]` | Km/KS type args | not bindable today (the type gate) |
| `Set` | `set[T]` | | |
| `Map` | `dict[K, V]` | | |
@@ -66,17 +66,17 @@ descriptor, `list` = the hand-maintained allowlist of §3.4.
| `IntArray` `LongArray` `…` | `list[int]` etc. | desc/Km | `ByteArray` is the exception above |
| `() -> Unit` | `Callable[[], None]` | Km classifier `kotlin/Function0` | §3.5 |
| `(A) -> B` | `Callable[[A], B]` | `kotlin/FunctionN` type args | |
-| `suspend (…) -> T` | — | `KmFunction.isSuspend`, `KSType.isSuspendFunctionType` | declined by both producers; must not be stubbed |
+| `suspend (…) -> T` | - | `KmFunction.isSuspend`, `KSType.isSuspendFunctionType` | declined by both producers; must not be stubbed |
| value class on the allowlist | `Dp \| float` | Km + `list` | §3.4 |
| value class off the allowlist | `TextUnit` | Km | §3.4 |
-| any other class/interface | its own stub name | Km/KS | requires that class to have a stub in the same namespace, which for a `TypeTag.OBJECT` handle is a promise the runtime does not yet keep — §7 |
-| a generic type *parameter* (`fun f(x: T)`) | — | | `BindingPolicy` rejects these before the binder sees them; stubbing what cannot be called would be a lie. §7 |
+| any other class/interface | its own stub name | Km/KS | requires that class to have a stub in the same namespace, which for a `TypeTag.OBJECT` handle is a promise the runtime does not yet keep, §7 |
+| a generic type *parameter* (`fun f(x: T)`) | - | | `BindingPolicy` rejects these before the binder sees them; stubbing what cannot be called would be a lie. §7 |
> Archive note: this table maps the **declared Kotlin type**; it was implemented as `PythonTypes.kt`
> for the Pythonic product and removed with it. The current stubs annotate the **boundary** type
-> instead — see `docs/design/pyi-generation-design.md` §3.1.
+> instead, see `docs/design/pyi-generation-design.md` §3.1.
-### 3.4 Value classes — how the allowlist shows up
+### 3.4 Value classes, how the allowlist shows up
`docs/design/kotlin-extensions-in-python.md` §4.4 decided this and measured why a machine rule fails: the
rule "coerce iff the value class has a public constructor taking exactly its underlying type"
@@ -89,7 +89,7 @@ In a stub the distinction is one union member:
| Kotlin parameter | `.pyi` | why |
|---|---|---|
| `Dp` (on the allowlist) | `Dp \| float` | public constructor, wraps a plain `Float`, and it *is* the identity on that float. 62% of value-class parameter occurrences on `Modifier` |
-| `TextUnit` (rejected) | `TextUnit` | packed `Long`; raw `16` decodes as `Unspecified` — silently no value at all |
+| `TextUnit` (rejected) | `TextUnit` | packed `Long`; raw `16` decodes as `Unspecified`, silently no value at all |
| `Color` (rejected despite passing the constructor test) | `Color` | `Color(int)` is `ULong(value) shl 32`; raw `0xFFFF0000` is transparent black through the constructor and red through the factory |
| every `packedValue` class (`Offset`, `Size`, `DpSize`, `IntSize`, `IntOffset`, `DpOffset`, `TransformOrigin`, `CornerRadius`, `Constraints`) | the proxy alone | a user-meaningful number is not the stored number, and none of these failures raises |
@@ -99,7 +99,7 @@ In a stub the distinction is one union member:
error: Argument "font_size" to "Text" has incompatible type "int"; expected "TextUnit"
while `Modifier.padding(16)` in the same file checked clean. So the stub is not merely documentation
-of the asymmetry — a checker acts on it, and the user finds out at edit time rather than seeing a
+of the asymmetry, a checker acts on it, and the user finds out at edit time rather than seeing a
label render with no font size.
What a stub cannot carry is §4.4's *third* clause, the error message that names the constructor to
@@ -124,7 +124,7 @@ Two things the stub should do and one it should not:
`Column(modifier=Modifier.size(4), content=lambda: Text("x"))` resolves clean.
- **`@Composable` on a lambda parameter is not expressible and should not be faked.** A
`@Composable () -> Unit` and a `() -> Unit` are the same Python `Callable[[], None]`. Kotlin's
- restriction — a composable lambda may only be invoked in a composable context — has no Python
+ restriction, a composable lambda may only be invoked in a composable context, has no Python
counterpart, and inventing a distinct alias would give a checker a rule it cannot enforce.
`docs/design/ecosystem.md` §5b already decided the composer is threaded as an ordinary value.
- **Do not stub the synthetic parameters.** `$composer` and `$changed` are in the JVM descriptor and
@@ -159,12 +159,12 @@ listed. mypy issued **no** overlap diagnostic.
That last fact is the one to be careful about. mypy reports overlapping overloads only when their
*return types* are incompatible, and every `Modifier` extension returns `Modifier`. So variants 2 and
-3 genuinely overlap — `p(8)` matches `all` and also matches `horizontal` positionally — and the
+3 genuinely overlap, `p(8)` matches `all` and also matches `horizontal` positionally, and the
checker silently takes the **first** match.
**Judged: overload order is part of the generated output and must be deterministic and
arity-ascending.** In the run above, arity-ascending put `all: Dp` before `horizontal, vertical`, and
-`p(8)` selected `all` — the right answer, but only because of the order. Two further consequences:
+`p(8)` selected `all`, the right answer, but only because of the order. Two further consequences:
- The stub's order must be **the same order the Python-side dispatcher resolves in**. If the runtime
dispatcher (`docs/design/kotlin-extensions-in-python.md` §6, unsolved) picks differently, the stub lies
@@ -177,7 +177,7 @@ Kotlin parameter names go through the same snake_case conversion as everything e
(`paddingValues` → `padding_values`), which `pythonx-compose` already does by hand: `text.py` maps
`font_size` → `fontSize`, `letter_spacing` → `letterSpacing`, and 14 more.
-## 4. Extension functions — and the metaclass proposal does not survive contact
+## 4. Extension functions, and the metaclass proposal does not survive contact
This is the section the Compose surface turns on: 604 public top-level extension functions over 136
receivers, and 177 chainable ones on `Modifier` alone.
@@ -256,7 +256,7 @@ Modifier3().padding(16) # -> correct
```
But declaring the metaclass side as `@property -> _PaddingCall` in the stub changes nothing for
-mypy — it still resolves the class's own method and produces the same twelve errors. **mypy does not
+mypy, it still resolves the class's own method and produces the same twelve errors. **mypy does not
model metaclass data-descriptor precedence.**
### 4.4 Judged: callback-Protocol attributes, and the stub need not mirror the runtime mechanism
@@ -277,7 +277,7 @@ class Modifier:
background: ClassVar[_Modifier_background]
```
-**Measured** — the same twenty-line user file that produced ten errors against §4.2 produces exactly
+**Measured**, the same twenty-line user file that produced ten errors against §4.2 produces exactly
the two intended ones:
| expression | revealed / result |
@@ -290,14 +290,14 @@ the two intended ones:
| `Modifier.size(4).padding(2)` | `Modifier` |
| `m.padding(16)` where `m: Modifier` | `Modifier` |
| `takes_modifier(Modifier.padding(16))` where `def takes_modifier(m: Modifier)` | clean |
-| `Text("hi", font_size=16)` | **error** — `int` is not `TextUnit` (intended, §3.4) |
+| `Text("hi", font_size=16)` | **error**, `int` is not `TextUnit` (intended, §3.4) |
| `Modifier.padding("nope")` | **error**, with all overload variants listed (intended) |
-`ClassVar` is not load-bearing for inference — a plain `padding: _Modifier_padding` gives byte-identical
-results — but it is the honest annotation, and it stops a checker accepting `m.padding = something`.
+`ClassVar` is not load-bearing for inference, a plain `padding: _Modifier_padding` gives byte-identical
+results, but it is the honest annotation, and it stops a checker accepting `m.padding = something`.
The point that makes this legitimate rather than a trick: **a `.pyi` completely replaces the `.py`
-for a checker, so the stub's mechanism and the runtime's mechanism do not have to agree — only the
+for a checker, so the stub's mechanism and the runtime's mechanism do not have to agree, only the
types do.** The runtime uses the hybrid descriptor of §4.3, which binds `self` correctly for both
spellings; the stub says "a callable attribute", which is what a checker can act on. The one
inaccuracy is that a checker believes `Modifier.padding` and `m.padding` are the same object, and
@@ -308,7 +308,7 @@ nothing observable depends on that.
| alternative | why not |
|---|---|
| the metaclass of §4.2 as written | measured not to work, in both mypy and CPython (§4.3) |
-| metaclass whose members are `@property -> Protocol` | works at run time, does not work in mypy — the class's own member still wins. Would give a *correct runtime* with a *wrong IDE*, which is the worst of the two |
+| metaclass whose members are `@property -> Protocol` | works at run time, does not work in mypy, the class's own member still wins. Would give a *correct runtime* with a *wrong IDE*, which is the worst of the two |
| bind the module-level name `Modifier` to the companion instance | already rejected in §4.2 of the other document and still right: it destroys `Modifier` as an annotation, and the stubs exist so that annotation works |
| drop the `Modifier.padding(...)` spelling, require `Modifier().padding(...)` | changes the API away from Kotlin's for a reason that is an implementation detail of Python attribute lookup |
| emit both the metaclass **and** the Protocol attributes | the class attribute shadows the metaclass in both engines, so the metaclass is dead text that a reader will believe |
@@ -316,12 +316,12 @@ nothing observable depends on that.
**Cost, stated:** one Protocol class per `(receiver, name)` pair. For `Modifier` that is 130 extra
class definitions in `pythonx/compose/ui/__init__.pyi`; across the 507 distinct `(receiver, name)`
pairs in corpus A, ~507 if every receiver is stubbed. They are generated, never read by a human, and
-`.pyi` files are not executed — but a 500-class stub file is a real indexing cost for an IDE and it
+`.pyi` files are not executed, but a 500-class stub file is a real indexing cost for an IDE and it
has not been measured.
**Not verified: PyCharm.** Callback protocols are standard typing and PyCharm supports `Protocol`,
but whether its inference resolves `Modifier.padding(16)` through a `ClassVar[Protocol]` was not
-tested. **This is the single most load-bearing unverified claim in this document** — §4.4 is chosen
+tested. **This is the single most load-bearing unverified claim in this document**, §4.4 is chosen
over §4.2 on mypy evidence alone, and if PyCharm follows the runtime MRO rule the way mypy does then
§4.4 works there too, while if it has its own metaclass handling the conclusion could differ.
Verify before implementing.
@@ -335,7 +335,7 @@ Verify before implementing.
### 5.1 Observed: the shape of `pythonx-compose`
`/Volumes/macMini/thisisthepy/pythonx-compose/pythonx/` contains **only** `compose/`, and there is
-**no `pythonx/__init__.py`** — `pythonx` is an implicit namespace package, so several distributions
+**no `pythonx/__init__.py`**, `pythonx` is an implicit namespace package, so several distributions
can contribute subpackages under it. `pythonx/compose/` has `__init__.py`, `layout/`, `lite/`,
`material3/`, `native/`, `runtime/`, `test/`, `ui/`, `ui/unit/`, `wrapper/`.
@@ -358,7 +358,7 @@ both. Against the actual directories:
| `pythonx.compose.runtime` | `androidx.compose.runtime` | yes |
| `pythonx.compose.ui` | `androidx.compose.ui` | yes |
| `pythonx.compose.ui.unit` | `androidx.compose.ui.unit` | yes |
-| `pythonx.compose.layout` | `androidx.compose.foundation.layout` | **no — `foundation.` is dropped** |
+| `pythonx.compose.layout` | `androidx.compose.foundation.layout` | **no, `foundation.` is dropped** |
| `pythonx.compose.wrapper`, `.native`, `.lite`, `.test` | none | no counterpart |
The `layout` entry is not inferred. `pythonx/compose/layout/arrangement.py` line 7 reads
@@ -371,12 +371,12 @@ re-exports `Arrangement` from it.
belongs to the Python package, not to the plugin.**
The reason is ownership, not convenience. Which Kotlin package a `pythonx` module wraps is
-`pythonx-compose`'s design decision — the plugin has no basis on which to invent
+`pythonx-compose`'s design decision, the plugin has no basis on which to invent
`pythonx.compose.layout` for `androidx.compose.foundation.layout`, and hard-coding Compose's
particular renames into a general-purpose Gradle plugin would make every other library's mapping
unreachable.
-So: a data file inside the Python distribution — e.g. `pythonx/compose/pythonx-map.toml` — declaring
+So: a data file inside the Python distribution, e.g. `pythonx/compose/pythonx-map.toml`, declaring
[modules]
"pythonx.compose.layout" = "androidx.compose.foundation.layout"
@@ -408,14 +408,14 @@ description of anything.
---
-## 6. Where the files go — the parts about the Pythonic product
+## 6. Where the files go, the parts about the Pythonic product
From §6.1 (measurements 3 and 4 stayed in `docs/design/pyi-generation-design.md`):
Five mypy runs in `/Volumes/macMini/tmp/pyicheck`, each reported above or below:
1. **A `.pyi` beside a `.py` wins.** The fixture's `pythonx/compose/ui/__init__.py` contains nothing
- but `def __getattr__(name): raise AttributeError(name)` — the shape the real adapter will have —
+ but `def __getattr__(name): raise AttributeError(name)`, the shape the real adapter will have,
and every name in §4.4's table resolved from `__init__.pyi`. This is the deployment that matters:
the dynamic adapter ships, the stub ships beside it, and the checker never sees the dynamism.
2. **`py.typed` is mandatory once the package is installed.** With `pythonx/` copied into the venv's
@@ -426,13 +426,13 @@ Five mypy runs in `/Volumes/macMini/tmp/pyicheck`, each reported above or below:
stubs or py.typed marker [import-untyped]
note: Revealed type is "Any" (every line)
- The marker goes in the top-level *regular* package of the distribution — here `pythonx/compose/`,
+ The marker goes in the top-level *regular* package of the distribution, here `pythonx/compose/`,
because `pythonx` is a namespace package (§5.1). That placement was the one tested and it works.
From §6.2:
-- **For a published `pythonx` wheel the stubs belong inside the wheel** — `.pyi` beside `.py` plus
- `py.typed`, both verified in §6.1 — built by `pypackpack`'s bundle stage, not regenerated per
+- **For a published `pythonx` wheel the stubs belong inside the wheel**: `.pyi` beside `.py` plus
+ `py.typed`, both verified in §6.1, built by `pypackpack`'s bundle stage, not regenerated per
consumer build. The plugin's per-build generation covers the consumer's own Kotlin and the
third-party jars *its* build resolves; a shipped `pythonx-compose` covers Compose once.
@@ -440,22 +440,22 @@ Two audiences, two destinations, one generator.
---
-## 7. Open — the items that belonged to the Pythonic product
+## 7. Open, the items that belonged to the Pythonic product
- **PyCharm.** §4.4 rests entirely on mypy. Verify `ClassVar[Protocol]` resolution, `@overload`
ordering, and `.pyi`-beside-`.py` precedence in PyCharm before implementing. If PyCharm does model
metaclass data descriptors, §4.2's shape becomes viable again there and the two engines would want
- different stubs — which would be a genuinely new problem.
+ different stubs, which would be a genuinely new problem.
- **The 500-class stub cost.** ~507 callback Protocols for corpus A's `(receiver, name)` pairs, and
130 for `Modifier` alone in one file. Not measured for IDE indexing time or for mypy's own runtime.
- **Whether any real Compose overload set collapses.** §3.6 shows four `padding` overloads coexisting,
but two Kotlin overloads can map to identical Python signatures once `Dp | float` widening is
applied. The generator must detect that and drop or qualify, and how often it happens across the
- 33 multi-overload `Modifier` names is unknown — it needs the scan to be run.
+ 33 multi-overload `Modifier` names is unknown, it needs the scan to be run.
- **Stub order vs dispatcher order.** §3.6: the `@overload` order and the Python-side dispatcher's
resolution order must come from one place. The dispatcher does not exist
(`docs/design/kotlin-extensions-in-python.md` §6).
- *Archive note (2026-10-03): the dispatcher now exists — `_Overloads` in the binding layer
+ *Archive note (2026-10-03): the dispatcher now exists, `_Overloads` in the binding layer
`python_multiplatform.binding` (`PythonxAdapter.kt`) serves the base name of an overload set.*
- **klib.** §0. The `pythonx.*` surface on iOS and androidNative has no producer yet.
- **The manifest of §5.3** does not exist and has not been agreed with `pythonx-compose`.
diff --git a/docs/archive/pythonx-adapter-design.md b/docs/archive/pythonx-adapter-design.md
index 2a41f03f3..7d2761e3f 100644
--- a/docs/archive/pythonx-adapter-design.md
+++ b/docs/archive/pythonx-adapter-design.md
@@ -1,11 +1,11 @@
-# `pythonx` — adapting generated Kotlin bindings without wrapping them one at a time
+# `pythonx`, adapting generated Kotlin bindings without wrapping them one at a time
> **Superseded** by [SPEC.md](../SPEC.md) U-7/U-8,
> [`KotlinSurface.kt`](../../python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt)
> and [`docs/design/kotlin-extensions-in-python.md`](../design/kotlin-extensions-in-python.md) on
> 2026-10-03; kept for history.
>
-> **What is superseded:** the binder serving `pythonx` itself — it synthesised `pythonx.*` modules
+> **What is superseded:** the binder serving `pythonx` itself, it synthesised `pythonx.*` modules
> (with `__path__ = []`, so the real on-disk package could not load) and renamed members and
> parameters to snake_case (§3's rule). Since `238119b7` the binder serves Kotlin-named modules under
> Kotlin names only, with keyword arguments by Kotlin parameter name and Kotlin defaults; its layer is
@@ -15,10 +15,10 @@
> Wherever this file says `pythonx._adapt`, `pythonx._bind_composable` or "the `pythonx` adapter",
> read "the binding layer" for mechanics and "pythonx-compose" for naming.
>
-> **Still true, and where it lives now:** the binding-layer mechanics — lazy finder plus module
+> **Still true, and where it lives now:** the binding-layer mechanics, lazy finder plus module
> `__getattr__` (§2.3, §4.1), extension functions as receiver methods through a hybrid descriptor
> (§4.2), the value-class allowlist (§4.4, now an empty runtime seam `allow_raw_primitive` that
-> pythonx-compose fills), the composer stack and the `$default` mask computed per call (§5.6) — are
+> pythonx-compose fills), the composer stack and the `$default` mask computed per call (§5.6), are
> in `PythonxAdapter.kt`, whose KDoc still cites this file's section numbers. The `@Composable`
> measurements (§5.2, §5.6) are summarised in `docs/design/kotlin-extensions-in-python.md` §2.3 and
> §4.6 and pinned by `ComposableBindingTest` and `ksp-fixtures/compose`. The `Modifier` and material3
@@ -26,7 +26,7 @@
> naming conventions (§3) and the `Modifier` metaclass (§4.3) belong to pythonx-compose.
The 2024 `pythonx-compose` wrote a Python class per Compose component. 37 files, 28 of them empty,
-and the one that mattered most — `pythonx/compose/ui/modifier.py` — is a copy of the Button wrapper
+and the one that mattered most, `pythonx/compose/ui/modifier.py`, is a copy of the Button wrapper
in which `padding()` composes nothing and `fill_max_size()` returns `self`. The chain was never
built. That is the cost this document exists to remove.
@@ -53,7 +53,7 @@ Sources read for this document:
---
-## 1. Read — what the 2024 code did, and the part of it that cannot have run
+## 1. Read, what the 2024 code did, and the part of it that cannot have run
Two conventions coexist in `pythonx-compose`, and they disagree.
@@ -61,7 +61,7 @@ Two conventions coexist in `pythonx-compose`, and they disagree.
`pythonx/compose/material3/text.py` declares `font_size`, `letter_spacing`, `on_text_layout`,
`soft_wrap`, `max_lines`, `min_lines` and then rebuilds a kwargs dict spelling them `fontSize`,
`letterSpacing`, `onTextLayout`, `softWrap`, `maxLines`, `minLines`. `buttons.py` does the same for
-`on_click`, `content_padding`, `interaction_source`, five times over — the five button variants are
+`on_click`, `content_padding`, `interaction_source`, five times over, the five button variants are
five copies of one 60-line body differing only in the name they look up.
**The notebook is not that code.** `docs/design/ecosystem.md` §3 established that `UI.ipynb` runs against
@@ -77,7 +77,7 @@ self.__kotlin_composable(**kwargs, c=self.composer, changed=1)
`c` and `changed` are the names PyREPL's `Material3.kt` gave its own explicit parameters. A real
Compose composable's synthetic parameters are named `$composer` and `$changed`, which are not Python
identifiers, so no keyword call can address them. Whatever `pythonx/compose/material3/*.py` was
-tested against, it was not `androidx.compose.material3`. (Read, not run — no chaquopy runtime was
+tested against, it was not `androidx.compose.material3`. (Read, not run, no chaquopy runtime was
available here.)
One more piece is worth keeping. `pythonx/compose/wrapper/__init__.py` is four lines:
@@ -90,13 +90,13 @@ class KotlinWrapper:
```
It reclasses a live Java object onto a Python class to give it methods. That trick is chaquopy-only
-and does not survive the move to this repository's binder — a Kotlin object reaches Python as an
+and does not survive the move to this repository's binder, a Kotlin object reaches Python as an
`ObjectReference` integer (`HandleTable`), and an `int` has no assignable `__class__`. Every place
`alignment.py` and `arrangement.py` use it needs a different mechanism.
---
-## 2. Read — what `PythonProxySource` already does, and what it does not
+## 2. Read, what `PythonProxySource` already does, and what it does not
### 2.1 Injection into `sys.modules` is why no import hook is needed *after* `install()`
@@ -105,13 +105,13 @@ name, and recursively creates and links its parents. CPython's import machinery
`sys.modules` hit before it consults any finder, so `from fixture.library import greet` works with no
`sys.meta_path` entry at all.
-Entry names come from the producers as Kotlin fully-qualified names — `ArtifactScanner`
+Entry names come from the producers as Kotlin fully-qualified names, `ArtifactScanner`
builds `"$owner.${function.kotlinName}"`, where `owner` is the *package* for a file facade or
multi-file part. So `androidx.compose.material3.Text` is the name a bound Compose composable would
carry, and `import androidx.compose.material3` is what it makes importable. That is
`docs/design/ecosystem.md` §5b's "a Kotlin FQN means the original Kotlin", already implemented.
-### 2.2 Judged — what the `pythonx` prefix adds: nothing, and that is the point
+### 2.2 Judged, what the `pythonx` prefix adds: nothing, and that is the point
`pythonx` is an ordinary Python package. It does not need `sys.modules` injection, a finder, or any
generation, because it is source we write. The two namespaces do not mix (§5b), and mechanically they
@@ -120,14 +120,14 @@ do not even use the same publication route: `androidx.*` is injected, `pythonx.*
What `pythonx` *does* need from `PythonProxySource` is that the Kotlin FQN it delegates to is
reachable at the moment `pythonx` asks. Which is §2.3.
-### 2.3 Judged — laziness needs two mechanisms, and the existing measurement does not forbid either
+### 2.3 Judged, laziness needs two mechanisms, and the existing measurement does not forbid either
`PythonProxySource.install()` is eager and whole-table: `render` walks every entry, emits one
`_pm_h_N = _pm_lookup(...)` and one `def` per entry, and the caller `exec`s the lot. For the fixture
tables this repository has, that is right. For Compose it is not: `docs/design/kotlin-extensions-in-python.md`
§2.1 counts 1,411 public top-level functions in the desktop corpus alone, and §2.6 counts 500 public
top-level composables. Every one would be a `_pm_resolve` string lookup and a Python function object
-built before the first frame renders. (The install cost has **not** been measured — `GeneratedProxyCostTest`
+built before the first frame renders. (The install cost has **not** been measured, `GeneratedProxyCostTest`
prices per-call and per-read, not install. That measurement is missing.)
Two different things have to become lazy, and they need different hooks:
@@ -140,12 +140,12 @@ Two different things have to become lazy, and they need different hooks:
**The 551–587 ns figure in `PythonProxySource`'s KDoc does not argue against this.** That measurement
is about a `__getattr__` that answers *every* read of a live Kotlin property, where the raised-and-discarded
`AttributeError` is paid per access. A lazy binder's `__getattr__` fires once, `setattr`s the result into the
-module dict, and is never consulted for that name again — every later read is an ordinary dict hit. The
+module dict, and is never consulted for that name again, every later read is an ordinary dict hit. The
descriptor-versus-hook decision recorded there is about live properties and should not be transplanted.
(Judged from the mechanism; the one-time cost of a lazy `__getattr__` on this repository's own boundary
has not been measured.)
-### 2.4 Read — the metadata the Python side cannot obtain
+### 2.4 Read, the metadata the Python side cannot obtain
This is the finding that constrains everything in §4. The two names a host must bind are
`_pm_resolve(name_bytes) -> handle` and `_pm_invoke(handle, args_tuple) -> result`. That is the whole
@@ -156,10 +156,10 @@ And `ExposedCallable` does not carry it either:
| what an adapter needs | in `ExposedCallable`? | where it is lost |
|---|---|---|
-| parameter **names** | **no** — the class has `name`, `arity`, `paramTypes`, `returnType`, `kind`, `isSuspend`, `callable` | never collected; `kotlin-metadata-jvm` has `KmValueParameter.name`, KSP has it too |
-| whether a parameter is an **extension receiver** | **no** | `ResolvedFunction.isExtension` exists and is used to pick the call syntax, then dropped — `ArtifactCallable` has no such field |
+| parameter **names** | **no**, the class has `name`, `arity`, `paramTypes`, `returnType`, `kind`, `isSuspend`, `callable` | never collected; `kotlin-metadata-jvm` has `KmValueParameter.name`, KSP has it too |
+| whether a parameter is an **extension receiver** | **no** | `ResolvedFunction.isExtension` exists and is used to pick the call syntax, then dropped, `ArtifactCallable` has no such field |
| the receiver's **type** | **no** | same place |
-| the declared **type name** of a parameter (`Dp` vs `Color`) | **no** — collapsed to a `TypeTag` | `resolveKotlinType` resolves a value class to its *underlying* `BoundaryType`, so a `Dp` parameter is `TypeTag.FLOAT` |
+| the declared **type name** of a parameter (`Dp` vs `Color`) | **no**, collapsed to a `TypeTag` | `resolveKotlinType` resolves a value class to its *underlying* `BoundaryType`, so a `Dp` parameter is `TypeTag.FLOAT` |
| whether a parameter **has a default** | **no** | `declaresDefaultValue` is readable from metadata and is not read |
| the **overload set** for a name | **no** | `ArtifactScanner` drops a name entirely when more than one binding would carry it (`groupBy { it.name }.filterValues { it.size == 1 }`) |
@@ -167,15 +167,15 @@ Consequences, in order of how much they hurt:
1. **`Text(text=..., color=...)` cannot be assembled.** Mapping keyword arguments onto positional
slots requires parameter names, and nothing on either side of the boundary has them. This is not a
- `pythonx` design choice — it is arithmetic. Without parameter names the Pythonic API is positional
+ `pythonx` design choice, it is arithmetic. Without parameter names the Pythonic API is positional
only, which for a 16-parameter composable is not an API.
2. **"An extension function becomes a method on its receiver's proxy"** (`kotlin-extensions` §4.1)
has no input. The table cannot say which entries are extensions or on what.
3. **The value-class allowlist** (`kotlin-extensions` §4.4) cannot be enforced in Python, because
- Python cannot see that a parameter is a `Dp` rather than a `Float`. See §4.4 — it turns out the
+ Python cannot see that a parameter is a `Dp` rather than a `Float`. See §4.4, it turns out the
allowlist mostly does not need to be enforced there.
-### 2.5 Open — where `pythonx`'s own `.py` files live on iOS, androidNative and wasm
+### 2.5 Open, where `pythonx`'s own `.py` files live on iOS, androidNative and wasm
`PythonProxySource`'s KDoc gives, as its first reason for rendering Python at run time rather than
shipping a `.py`, that **there is no resource path to put a `.py` on**: Kotlin/Native has no
@@ -191,7 +191,7 @@ the Kotlin sources; the only hits are benchmarks reading `sys.path` as a test fi
Candidates, none chosen and none tried:
- `pypackpack`'s `bundle` type `resource`, which its SPEC defines as the handoff format to this
- repository — and which `docs/design/ecosystem.md` §4 item 5 records as a three-line placeholder.
+ repository, and which `docs/design/ecosystem.md` §4 item 5 records as a three-line placeholder.
- Freezing `pythonx` into the CPython trees the way the stdlib already is.
- Embedding the source as Kotlin string constants and `exec`ing it, i.e. the route
`PythonProxySource` took, applied to hand-written code. This works everywhere today and is
@@ -202,7 +202,7 @@ Until one exists, `pythonx` is a desktop-and-Android library whatever else this
---
-## 3. Measured — the name conventions `UI.ipynb` actually uses
+## 3. Measured, the name conventions `UI.ipynb` actually uses
Every identifier in the notebook's 57 cells, classified. This is the empirical basis for §4.1's rule,
and it is worth reading before assuming "snake_case everything".
@@ -218,18 +218,18 @@ and it is worth reading before assuming "snake_case everything".
| `verticalArrangement`, `horizontalArrangement`, `horizontalAlignment` | `vertical_arrangement`, `horizontal_arrangement`, `horizontal_alignment` | parameter → snake_case |
| `onClick` | `on_click` in `pythonx`, **`onclick`** in the notebook | parameter → snake_case; **`pythonx` is the reference, not the notebook** |
| `State.getValue` / `setValue` | `getValue()` / `setValue()` | method on a Kotlin object → **camelCase kept** |
-| — | `corner_radius`, `text_state` | **invented**; `material3.Card` has no `corner_radius` in either overload |
+| - | `corner_radius`, `text_state` | **invented**; `material3.Card` has no `corner_radius` in either overload |
Five Kotlin-derived multi-word parameters are snake_case; one (`onclick`) is not. The library source
in `pythonx-compose` spells that same parameter `on_click` in all five button wrappers.
**Decided: `pythonx` is the reference, and the notebook is not.** `on_click` is what the rule
produces and what the library already writes, so `onclick` is not a spelling to support, alias or
-carry forward — it is one cell of a teaching notebook disagreeing with the library it teaches. A
+carry forward, it is one cell of a teaching notebook disagreeing with the library it teaches. A
table derived from `pythonx` needs no exception list here, which is the point: an exception that
exists only in prose is one the generator and the adapter cannot both honour.
-**Judged — the rule.**
+**Judged, the rule.**
type, object, enum entry, composable PascalCase, unchanged
function, method, property, parameter snake_case
@@ -243,10 +243,10 @@ The composable exception is not a special case for Compose. A `@Composable` is P
is a *declaration of a UI element*, and both Kotlin and the notebook treat it as a type-like name;
PEP 8 has no opinion that outranks matching the documentation of the library being wrapped. The cost
is that `pythonx` has two casing rules keyed on a property of the declaration, and the adapter must
-know which — `@Composable` is an annotation the walker can read, so this is answerable, but it is one
+know which, `@Composable` is an annotation the walker can read, so this is answerable, but it is one
more thing the table would have to carry (§2.4).
-**Judged — the conversion runs Kotlin → Python, and the runtime runs it backwards.**
+**Judged, the conversion runs Kotlin → Python, and the runtime runs it backwards.**
The `.pyi` generator (plugin, build time) converts Kotlin → Python: it has the Kotlin name and emits
the Python one. The adapter (runtime) is handed the Python name by the caller and must find the
@@ -267,12 +267,12 @@ an API the runtime does not have, which is the failure mode that makes stubs wor
---
-## 4. Judged — the adaptation layer
+## 4. Judged, the adaptation layer
### 4.1 What a module `__getattr__` looks up and what it caches
```python
-# pythonx/_adapt.py — one implementation, no per-declaration code
+# pythonx/_adapt.py, one implementation, no per-declaration code
def __getattr__(name): # in pythonx/compose/material3/__init__.py
kotlin = _kotlin_name(name, _MODULE) # 'androidx.compose.material3.Text'
obj = _adapt(kotlin) # resolve + build the callable
@@ -298,7 +298,7 @@ always available as an escape hatch. That is the property that keeps `pythonx` f
extensions returns `Modifier`, so `Modifier.padding(16).background(...)` is ordinary Python method
chaining with no combinator machinery.
-The mechanism in Python is unremarkable — a proxy class per receiver type, and a `__getattr__` on
+The mechanism in Python is unremarkable, a proxy class per receiver type, and a `__getattr__` on
*its type* that resolves `receiver_type + '.' + kotlin_name`, binds, and installs the method on the
class so the second instance pays nothing.
@@ -313,7 +313,7 @@ constructs the call expression and discards them one line later.
`kotlin-extensions` §4.2 established the shape and the reason: `Modifier$Companion` *implements*
`androidx.compose.ui.Modifier`, so one Python class whose class object also behaves like an instance
-covers all three spellings — `Modifier.padding(16)`, `m.padding(16)`, `def f(m: Modifier)`.
+covers all three spellings, `Modifier.padding(16)`, `m.padding(16)`, `def f(m: Modifier)`.
Two additions from reading the 2024 code:
@@ -325,7 +325,7 @@ Two additions from reading the 2024 code:
interface whose companion implements it. Whether any *other* Compose type has that shape was not
counted.
-### 4.4 Value classes — the coercion already happens, and it happens in Kotlin
+### 4.4 Value classes, the coercion already happens, and it happens in Kotlin
**Read.** `resolveKotlinType` (`KotlinMetadata.kt`) resolves a value-class parameter to the
`BoundaryType` of its *underlying* type, and `valueClassBoundaryType` renders the read as
@@ -334,28 +334,28 @@ already generated Kotlin, and `TypeTag` for a `Dp` parameter is `FLOAT`.
This changes `kotlin-extensions` §4.4's conclusion less than it changes where it is enforced:
-- **A raw Python number reaching a `Dp` parameter is already correct**, with no Python-side rule, and
+- **A raw Python number reaching a `Dp` parameter is already correct**: with no Python-side rule, and
`Modifier.padding(16)` needs nothing built for it.
-- **`TextUnit` and every `packedValue` class is not a hazard on this path — it is unbound.** Their
+- **`TextUnit` and every `packedValue` class is not a hazard on this path, it is unbound.** Their
constructors are `INTERNAL` (`kotlin-extensions` §2.4), and `resolveKotlinType` returns `null` for a
parameter whose value class has no public constructor. The silent-`Unspecified` failure §4.4 warns
about cannot occur through a walked binding, because there is no walked binding.
- **`Color` is unbound too, for a second reason.** Its constructor is public but takes `ULong`, and
`kotlinPrimitiveBoundaryTypeOf` has no `kotlin/ULong` entry, so the underlying type resolves to
`null`. The "constructor is public" test that §4.4 worried would admit `Color` does not admit it
- here. (Read from source. Not re-run over Compose — `kotlin-extensions` §3 already reports the
+ here. (Read from source. Not re-run over Compose, `kotlin-extensions` §3 already reports the
walker binds zero from these jars, for reasons upstream of this.)
**Judged.** The allowlist stays a build-time concept, not a Python one. The Python side gets a
-`dp(16)`-style proxy only where a *proxy object* is genuinely needed — that is, where a value class
-appears in **return** position, or where a Python user wants to hold one — and unwraps it to the
+`dp(16)`-style proxy only where a *proxy object* is genuinely needed, that is, where a value class
+appears in **return** position, or where a Python user wants to hold one, and unwraps it to the
underlying primitive before the call. The asymmetry §4.4 accepted (`padding(16)` fine,
`font_size=16` rejected) arrives for free: `padding` binds and `Text`'s `fontSize` does not.
-The residual risk §4.4 names — a future Compose release adding a public-constructor float wrapper
-that packs — is unchanged and still lives at the plugin.
+The residual risk §4.4 names, a future Compose release adding a public-constructor float wrapper
+that packs, is unchanged and still lives at the plugin.
-### 4.5 Open — defaults, and why they are the load-bearing blocker
+### 4.5 Open, defaults, and why they are the load-bearing blocker
**Measured** (`javap -p` over `material3-desktop-1.6.11.jar` and `foundation-layout-desktop-1.6.11.jar`
from the Gradle cache, i.e. `kotlin-extensions-in-python.md`'s corpus A):
@@ -371,7 +371,7 @@ from the Gradle cache, i.e. `kotlin-extensions-in-python.md`'s corpus A):
parameters (58%) declare a default**, and 82 of the 177 functions have at least one.
A Python caller writing `Text("hi", color=red)` is skipping fourteen parameters, and the values it
-would have to supply instead are `TextStyle.Default`, `TextDecoration` and friends — objects the type
+would have to supply instead are `TextStyle.Default`, `TextDecoration` and friends, objects the type
gate does not bind, so Python cannot construct them to pass them. **"Supply every argument" is not
available.** Defaults are therefore not an ergonomic nicety here; without them the bound surface is
unusable even where it binds.
@@ -382,17 +382,17 @@ unusable even where it binds.
|---|---|
| one entry per **arity prefix** (Kotlin defaults are trailing, so calling with the first *k* arguments is always legal) | *N*+1 entries instead of 2^*N*, and it cannot express "pass `text` and `font_size`, skip `modifier` and `color`", which is how Compose is written |
| **exhaustive presence branching** in the generated lambda | 2^15 branches for `Text` |
-| calling the compiler's **`$default` synthetic** from generated Kotlin | it is `ACC_SYNTHETIC`, so Kotlin source cannot name it; and `javap -p` over `ButtonKt` shows **no `Button$default` exists at all** — a `@Composable` carries its `$default` mask as a declared trailing parameter instead (§5.2), which is a different mechanism |
+| calling the compiler's **`$default` synthetic** from generated Kotlin | it is `ACC_SYNTHETIC`, so Kotlin source cannot name it; and `javap -p` over `ButtonKt` shows **no `Button$default` exists at all**, a `@Composable` carries its `$default` mask as a declared trailing parameter instead (§5.2), which is a different mechanism |
| a **generated Kotlin wrapper per function** that restates the defaults | the defaults are expressions in the callee's scope; metadata carries the *flag*, never the value (`kotlin-extensions` §4.5) |
-The `@Composable` case may be luckier than the general case precisely because of §5.2 — the mask is a
+The `@Composable` case may be luckier than the general case precisely because of §5.2, the mask is a
real parameter there. Whether that is reachable is §5.4.
---
## 5. `@Composable`
-### 5.1 Read — what the 2024 `Composable` class does
+### 5.1 Read, what the 2024 `Composable` class does
`pythonx/compose/runtime/__init__.py` is a decorator class holding a single `__composer` registered
from Kotlin (`ComposeApp.__init__(composer)`), and `__call__` refuses to run if it is unset:
@@ -410,17 +410,17 @@ through. `KotlinComposable.compose` is the interesting line:
ComposableWrapper(self.content, args, self.composer, 1)
```
-`ComposableWrapper` is `io.github.thisisthepy.pycomposeui.RuntimeKt.composableWrapper` — a
+`ComposableWrapper` is `io.github.thisisthepy.pycomposeui.RuntimeKt.composableWrapper`, a
hand-written Kotlin function that takes a `ComposableLambda`, the argument list, a `Composer` **as an
ordinary value**, and a `changed` int. That is the shape `docs/design/ecosystem.md` §5b describes as "the
Python wrapper passes the composer as a value".
So the 2024 design has exactly one composer for the whole application, set once, and every composable
call uses it with `changed = 1`. Whether that produces correct recomposition was not verified here
-and is not obviously true — `$changed` is a per-call-site bitmask about which arguments are known
+and is not obviously true, `$changed` is a per-call-site bitmask about which arguments are known
static, and a constant `1` tells the runtime something specific about slot 0.
-### 5.2 Measured — the synthetic parameters, and a correction to `kotlin-extensions` §2.3
+### 5.2 Measured, the synthetic parameters, and a correction to `kotlin-extensions` §2.3
`javap -p`, corpus A jars, top-level parameter counts split at the top level (generics ignored):
@@ -437,19 +437,19 @@ static, and a constant `1` tells the runtime something specific about slot 0.
| `Row` | 4 | **7** | 6 |
Every one of the nine rows is one higher than recorded. The trailing parameters of `Button` are
-`Composer, int, int` and of `Text-fLXpl1I` are `Composer, int, int, int` — that is `$composer`, one or
+`Composer, int, int` and of `Text-fLXpl1I` are `Composer, int, int, int`, that is `$composer`, one or
two `$changed` masks, and **`$default`**. The distribution in §2.6 (`+2: 80, +3: 349, +4: 62, +5: 9`)
was not re-derived; if it is shifted the same way, `+2` would mean "composable with no defaulted
parameter" and the nine functions above would all be `+3` or `+4`, which is what was measured. The
-design conclusion §4.6 draws — *never derive arity from the descriptor; metadata arity is the source*
-— is unaffected and strengthened.
+design conclusion §4.6 draws, *never derive arity from the descriptor; metadata arity is the source*
+is unaffected and strengthened.
Reproduce with:
unzip -o -q "$GRADLE_CACHE/.../material3-desktop-1.6.11.jar" 'androidx/compose/material3/ButtonKt.class'
javap -p androidx/compose/material3/ButtonKt.class
-### 5.3 Read — the contradiction inside `docs/design/ecosystem.md`
+### 5.3 Read, the contradiction inside `docs/design/ecosystem.md`
§4 item 1 says: *"`@Composable` cannot be an exposed callable … a generated entry for a widget would
not compile … This is the smallest and most blocking item."*
@@ -462,7 +462,7 @@ the Kotlin side needs a `@Composable` callable type."*
source* and let `kotlinc` compile it (agent-rules §12, and `ArtifactRendering.renderEntry` shows the
generated lambda body verbatim). Kotlin source cannot:
-- call a `@Composable` from a non-`@Composable` lambda — the Compose plugin rejects it; and
+- call a `@Composable` from a non-`@Composable` lambda, the Compose plugin rejects it; and
- name `$composer`, `$changed` or `$default`, which are not parameters of the Kotlin declaration at
all. They exist only in the compiled form, which is the form §5b's argument reasons about and the
form the generator does not emit.
@@ -470,10 +470,10 @@ generated lambda body verbatim). Kotlin source cannot:
Both facts are properties of the generator, not of Compose, which is why §5b's reading of the
bytecode is correct and its conclusion still does not follow. The 2024 code got away with it because
chaquopy calls *JVM methods by name and descriptor*, where the synthetic parameters are ordinary
-arguments — the very approach `kotlin-extensions` §2.3 retires, since 108 mangled JVM names are
+arguments, the very approach `kotlin-extensions` §2.3 retires, since 108 mangled JVM names are
ambiguous within their own class.
-### 5.4 Open — the one candidate that might reconcile them
+### 5.4 Open, the one candidate that might reconcile them
`androidx.compose.runtime.internal.composableLambdaInstance` builds a `ComposableLambda` from a
`@Composable` block, and `ComposableLambda.invoke(p1, composer, changed)` is an ordinary
@@ -489,12 +489,12 @@ private val bindButton = composableLambdaInstance(/* key */ 0, /* tracked */ tru
```
which would keep `ExposedCallable.callable` unchanged, let `kotlinc` supply `$composer`, `$changed`
-**and `$default`** at a real call site — closing §4.5 for composables specifically — and let Python
+**and `$default`** at a real call site, closing §4.5 for composables specifically, and let Python
pass the composer as a value exactly as §5b says.
**Not verified. Nothing here was compiled.** What has to be checked, in order:
-1. That `composableLambdaInstance` is callable from generated code at all — it is in an `internal`
+1. That `composableLambdaInstance` is callable from generated code at all, it is in an `internal`
package and annotated `@ComposeCompilerApi`; whether that is an opt-in warning or a hard error for
a consumer's generated source is unknown.
2. That the group key can be generated stably. Compose identifies slots by key; a colliding or
@@ -510,7 +510,7 @@ pass the composer as a value exactly as §5b says.
Until (1) is answered, `docs/design/ecosystem.md` §4 item 1 remains the blocking item it says it is, and
§5b's paragraph asserting otherwise should be treated as unresolved rather than as settled.
-### 5.5 Judged — arity is where the real risk sits, and it is not a collision risk
+### 5.5 Judged, arity is where the real risk sits, and it is not a collision risk
`kotlin-extensions` §2.6 measured that of 500 composables, 41 are extension functions and exactly one
is a `Modifier` extension. Composables and chainable modifiers are two disjoint problems, so the
@@ -518,12 +518,12 @@ adapter can treat them as two code paths with one shared name resolver and no ar
them. The `Modifier`-extension composable (`ui.ExternalDrag_desktopKt.onExternalDrag`) is a single
name and can be excluded by name if it is ever in the way.
-### 5.6 Closed — Python calls a real composable, and §5.4's candidate was not what did it
+### 5.6 Closed, Python calls a real composable, and §5.4's candidate was not what did it
**`Text('hi')` written in Python draws pixels through `androidx.compose.material3.Text`.**
`:ksp-fixtures:compose`'s `ComposableRenderTest` renders a 200×60 `ImageComposeScene`: the Python
body drew **71** non-background pixels, an empty body drew **0**, and `Text('hi hi hi hi hi')` drew
-**376**. Nothing is stubbed — `material3-desktop-1.6.11.jar` is walked, the composer is real, the
+**376**. Nothing is stubbed, `material3-desktop-1.6.11.jar` is walked, the composer is real, the
composition is real.
Four things had to be settled, and three of them contradict what was written above.
@@ -533,8 +533,8 @@ Four things had to be settled, and three of them contradict what was written abo
It is not: `androidx.compose.runtime.Composable` is `@Retention(AnnotationRetention.BINARY)`, and
`javap -v androidx/compose/material3/TextKt.class` shows every composable carrying
`RuntimeInvisibleAnnotations: androidx.compose.runtime.Composable`. So the predicate answered
-`false` for all 500 of them and `DeclarationModel.isComposable` — which drives §3.6's PascalCase
-rule — had never once been true. It reads both lists now.
+`false` for all 500 of them and `DeclarationModel.isComposable`, which drives §3.6's PascalCase
+rule, had never once been true. It reads both lists now.
**2. The trailing shape is derivable, and was checked rather than assumed.** §5.2 measured nine
signatures by hand; `ComposableBindingTest.everyComposableJvmSignatureIsKotlinParamsThenComposerThenInts`
@@ -546,7 +546,7 @@ contradicted it.
**3. The mask encoding, read out of the callee.** `javap -c` on `Text-fLXpl1I` shows the prologue
testing `$default & 2` before `modifier = Modifier.Companion`, `& 4` before
-`color = Color.Unspecified`, `& 8` before the next — so **bit *i* is parameter *i***, with a bit
+`color = Color.Unspecified`, `& 8` before the next, so **bit *i* is parameter *i***, with a bit
reserved for every parameter including ones that default nothing (`text` owns bit 0 and nothing sets
it). `$changed` is passed as **0**, the conservative value: it is a claim about argument staticness
that this caller cannot make, and the callee's own `composer.changed(...)` branch is what runs when
@@ -558,7 +558,7 @@ longer substituted reached Compose as `null`.
**4. §5.4's `composableLambdaInstance` route was not needed, and a plainer obstacle than the one
§5.3 named is what decided it.** §5.3 says generated Kotlin cannot name `$composer`, which is true.
-But the *fallback* it assumes — call the JVM method directly — does not fail on the hyphen or on the
+But the *fallback* it assumes, call the JVM method directly, does not fail on the hyphen or on the
synthetic parameters. It fails earlier:
probe.kt:2:35: error: unresolved reference 'TextKt'.
@@ -568,25 +568,25 @@ real jar), so there is nothing to write. Java cannot spell `Text-fLXpl1I` either
instead is **one `.class` per artefact fragment** (`ComposableThunks.kt`): a
`public static Object t(Object[])` that unboxes the boundary's argument array and does one
`INVOKESTATIC` with the name and descriptor copied verbatim from the `MethodNode` being walked. That
-is not "looking a method up by name" in the sense agent-rules §12 retires — there is no runtime
+is not "looking a method up by name" in the sense agent-rules §12 retires, there is no runtime
lookup, no reflection, and the JVM's own linker resolves it at class-load time exactly as `kotlinc`'s
own `INVOKESTATIC` would.
So §4.5's table gains a row it did not have: **pass the mask as a value.** It is available because
-§5.2's finding — the mask is a *declared* parameter — means it never had to be a compile-time
+§5.2's finding, the mask is a *declared* parameter, means it never had to be a compile-time
constant. Which arguments a call wrote is something Python knows, so the arithmetic is one integer
per call in `pythonx._bind_composable`, for a declaration of any width. `MAX_OMITTABLE_PARAMETERS`
and its 2^n cost apply to the sentinel mechanism and are untouched; a composable uses neither.
**The composer still needs one hand-written `@Composable`**, and exactly one:
`:ksp-fixtures:compose`'s `PythonComposition`. It names no composable and takes no
-composable-specific parameter — it registers `currentComposer` as an ordinary object handle, pushes
-it onto `pythonx`'s stack and runs the Python body — so it is O(1) in the number of bound
+composable-specific parameter, it registers `currentComposer` as an ordinary object handle, pushes
+it onto `pythonx`'s stack and runs the Python body, so it is O(1) in the number of bound
composables, unlike 2024's wrapper-per-widget. `docs/design/ecosystem.md` §5b's "the Python wrapper passes
the composer as a value" survives intact; §4 item 1's "this is the smallest and most blocking item"
is closed.
-**What is still open.** Recomposition and skipping (§5.4 item 4, §6) — the Python body is re-`exec`ed
+**What is still open.** Recomposition and skipping (§5.4 item 4, §6), the Python body is re-`exec`ed
on every composition pass and nothing has measured it. Extension composables are declined: Compose
counts receivers in `$changed`'s slots but assigns `$default` bits over value parameters, and which
numbering a receiver shifts has not been measured (§2.6 counts one `Modifier`-extension composable in
@@ -595,18 +595,18 @@ exercise the second `$default` word, which the arithmetic writes and nothing has
---
-## 6. Open — the lifetime of a Python callable across recomposition
+## 6. Open, the lifetime of a Python callable across recomposition
`docs/design/ecosystem.md` records this as unverified. This section says what "verify" means, because the
mechanism turns out to be half-present already.
-**Read — a Python callable can already cross into Kotlin.** `UpcallTrampoline.toKotlinObject` handles
+**Read, a Python callable can already cross into Kotlin.** `UpcallTrampoline.toKotlinObject` handles
`TypeTag.OBJECT` in the *parameter* direction by testing whether Python sent an integer: if it did,
it is a `HandleTable` handle for a Kotlin object; if it did not, the value is wrapped as
`PyObject(value, borrowed = true)` and the wrapper takes its own reference. So a `content=lambda: …`
reaches a Kotlin parameter declared `PyObject` today, with a reference of its own.
-**Read — the walker will not produce such a parameter.** `resolveKotlinType` has no `OBJECT`
+**Read, the walker will not produce such a parameter.** `resolveKotlinType` has no `OBJECT`
fallback, so an artefact-walked binding can never take one; only a KSP-generated entry from the
consumer's own Kotlin can (KSP has `isPyObjectType`). A `content:` parameter on a walked
`androidx.compose.material3.Button` is therefore unreachable regardless of everything else in §5.
@@ -617,13 +617,13 @@ this repository has measured separately and never together:
1. **Release.** When Compose drops the slot, does anything tell the Kotlin holder? `RememberObserver.onForgotten`
is the only hook that reports it. A `PyObject` held by an object that does not implement it is
- leaked for the life of the process — the same class of defect
+ leaked for the life of the process, the same class of defect
`ProxyHandleLifetimeTest` was written for, in the opposite direction (Kotlin object held by
Python). A test of the same shape is needed: compose, drop, recompose, assert the Python refcount
came back.
2. **The GIL on the release path.** `Py_DecRef` requires the GIL. `onForgotten` runs on whatever
- thread Compose is applying changes on. Whether that thread holds the GIL — and if not, whether the
- decref can be queued to one that does — is not answered anywhere in `docs/design/object-lifetime.md` or
+ thread Compose is applying changes on. Whether that thread holds the GIL, and if not, whether the
+ decref can be queued to one that does, is not answered anywhere in `docs/design/object-lifetime.md` or
`docs/investigations/gil-parking-investigation.md` for this direction.
3. **Re-entrancy.** The call is Kotlin composition → Python callable → Kotlin composable → Python
`content` lambda. The GIL is held across a Kotlin composition that may itself block or hop
@@ -638,22 +638,22 @@ Item 1 is the one that fails silently and should be tested first.
---
-## 7. Judged — what is Python code and what is a generated artefact
+## 7. Judged, what is Python code and what is a generated artefact
The dividing rule: **if it differs per Kotlin declaration it is generated or resolved at run time; if
it is the same rule for every declaration it is `pythonx` Python source.** Everything the 2024 attempt
-got wrong is on the wrong side of that line — a file per component is per-declaration code written by
+got wrong is on the wrong side of that line, a file per component is per-declaration code written by
hand.
| product | who makes it | when | where it lives | consumer |
|---|---|---|---|---|
| `FunctionTableFragment` (consumer's own source) | KSP processor | build | generated Kotlin, compiled into the app | `UpcallTable` |
| `ArtifactTable` fragments (resolved jars/klibs, AndroidX included) | Gradle plugin artefact walker | build | generated Kotlin, compiled into the app | `UpcallTable` |
-| `.pyi` stubs, carrying the Pythonic names §3 produces | Gradle plugin (long term: `toolchain`) | build | `src//generated/meta/` | the IDE only — never imported |
+| `.pyi` stubs, carrying the Pythonic names §3 produces | Gradle plugin (long term: `toolchain`) | build | `src//generated/meta/` | the IDE only, never imported |
| proxy modules under Kotlin FQNs (`androidx.compose.material3`) | `PythonProxySource.render` | run time, `exec` | `sys.modules` | `pythonx`, and any user who wants the original |
| `_pm_settle` / `_pm_watch` / `_pm_module` / `_pm_static_property` support | `PythonProxySource.support` | run time, `exec` | `__main__` | the generated proxies |
| `_pm_resolve` / `_pm_invoke` | per-platform bootstrap (Kotlin/C) | run time | `__main__` | everything above |
-| **`pythonx/`** — name conversion, module `__getattr__`, receiver-method attachment, the `Modifier` metaclass, value-class proxies, the composable call convention, the `content=` adapter | **written by hand, once** | — | a shipped Python package (§2.5: no route on 3 of 5 targets) | the application author |
+| **`pythonx/`**, name conversion, module `__getattr__`, receiver-method attachment, the `Modifier` metaclass, value-class proxies, the composable call convention, the `content=` adapter | **written by hand, once** | - | a shipped Python package (§2.5: no route on 3 of 5 targets) | the application author |
Two consequences worth stating explicitly.
@@ -664,7 +664,7 @@ symptom to watch for.
**`pythonx` may contain genuinely non-mechanical code, and that is where the line actually bends.**
The notebook needs `main.App`, `App.update(NewComposable)` for hot-swapping a composable from a
-Jupyter cell, and `App.messages` as live state. None of that is a mapping of a Kotlin declaration —
+Jupyter cell, and `App.messages` as live state. None of that is a mapping of a Kotlin declaration,
it is an application-lifecycle facility. It belongs in `pythonx` as hand-written Python (over a small
hand-written Kotlin surface), and it should be kept visibly separate from the adapter so that "wrote
a wrapper by hand" stays a visible event rather than a habit.
@@ -691,14 +691,14 @@ Collected, in the order that unblocks the most.
The argument for laziness is currently structural, not measured.
8. **`kotlin-extensions` §2.3's JVM parameter column** (§5.2) is one low on all nine rows; whether
§2.6's `+2/+3/+4/+5` distribution shares the error was not re-derived.
-9. **Scoped modifiers** — inherited unchanged from `kotlin-extensions` §6. 74 public member
+9. **Scoped modifiers**: inherited unchanged from `kotlin-extensions` §6. 74 public member
extensions on `Modifier` need a dispatch receiver Python has no implicit form for. No shape chosen.
-10. **Overload dispatch** — inherited unchanged. The walker currently drops 33 of 130 `Modifier`
+10. **Overload dispatch**: inherited unchanged. The walker currently drops 33 of 130 `Modifier`
names. A Python-side dispatcher needs (1) to exist first, since it selects on argument names.
---
-## 9. Measured — the `Modifier` extensions still declined, and `pointerInput` opened
+## 9. Measured, the `Modifier` extensions still declined, and `pointerInput` opened
`ArtifactScannerTest.composeModifierExtensionsSurviveBothGates` and
`FunctionSlotBindingTest.mostFunctionTypedModifierExtensionsAreNoLongerDeclinedForBeingFunctionTyped`
@@ -707,43 +707,43 @@ were run against the real Compose 1.7.0 desktop jars (`ANDROID_HOME=... ./gradle
by name rather than assume the count in either test's own KDoc is still current. It is not: **nine**
public top-level `Modifier` extensions have zero binding overload today, not the "44"/"8" figures
recorded when those tests were written. One more name (`pullRefresh`) has
-a declined *overload* beside a bound one and so is not in this list — a caller can already reach
+a declined *overload* beside a bound one and so is not in this list, a caller can already reach
`pullRefresh(modifier, state, enabled)`, just not the callback-based overload.
| name | JVM signature (`javap -p`, Compose 1.7.0) | decline reason (`ArtifactScanner`, measured) |
|---|---|---|
-| `pointerInput` | `Modifier.pointerInput(Any?, suspend PointerInputScope.() -> Unit)` → `Function2, Any?>` | suspend function-typed parameter — **now bound**, see below |
+| `pointerInput` | `Modifier.pointerInput(Any?, suspend PointerInputScope.() -> Unit)` → `Function2, Any?>` | suspend function-typed parameter, **now bound**, see below |
| `dragAndDropSource` | `dragAndDropSource(Modifier, (DrawScope)->Unit, suspend DragAndDropSourceScope.() -> Unit)` → `Function2<..., Continuation, Any?>` | suspend function-typed parameter, same family as `pointerInput` |
| `draggable` | `draggable(Modifier, DraggableState, ..., onDragStarted: suspend CoroutineScope.(Offset)->Unit, onDragStopped: suspend CoroutineScope.(Float)->Unit, ...)` → both `Function3, Any?>` | suspend function-typed parameter |
| `composed` | `composed(Modifier, (InspectorInfo)->Unit, factory: @Composable Modifier.() -> Modifier)` → `factory` compiles to `Function3` | `@Composable` function-typed parameter of a declaration that is not itself `@Composable`: `ArtifactScanner`'s generated fragment is compiled without the Compose plugin |
| `swipeable` | `swipeable-pPrIpRY(Modifier, SwipeableState, ..., thresholds: (T, T) -> ThresholdConfig, ...)` | the `thresholds` slot's declared type argument is the type parameter `T`, which has no name a cast could spell |
| `modifierLocalProvider` | `modifierLocalProvider(Modifier, ProvidableModifierLocal, () -> T)` | same: `() -> T`'s `T` has no name |
-| `anchoredDraggable` | `anchoredDraggable(Modifier, AnchoredDraggableState, ...)` | not function-typed at all — the *required* `state` parameter's own type carries the unnameable `T`, so `resolveKotlinType` answers "no boundary type" before the function-slot grammar is ever asked |
-| `layoutId` | `layoutId(Modifier, layoutId: Any)` | not function-typed — `resolveKotlinType` has no boundary type for a bare `kotlin.Any`/`java.lang.Object` parameter (no hint of which `TypeTag` an arbitrary Python value crossing there should marshal as) |
-| `contextMenuOpenDetector` | `contextMenuOpenDetector(Modifier, key: Any?, enabled: Boolean, onOpen: (Offset)->Unit)` | not function-typed — same as `layoutId`, `resolveKotlinType` has no boundary type for `kotlin.Any` |
+| `anchoredDraggable` | `anchoredDraggable(Modifier, AnchoredDraggableState, ...)` | not function-typed at all, the *required* `state` parameter's own type carries the unnameable `T`, so `resolveKotlinType` answers "no boundary type" before the function-slot grammar is ever asked |
+| `layoutId` | `layoutId(Modifier, layoutId: Any)` | not function-typed, `resolveKotlinType` has no boundary type for a bare `kotlin.Any`/`java.lang.Object` parameter (no hint of which `TypeTag` an arbitrary Python value crossing there should marshal as) |
+| `contextMenuOpenDetector` | `contextMenuOpenDetector(Modifier, key: Any?, enabled: Boolean, onOpen: (Offset)->Unit)` | not function-typed, same as `layoutId`, `resolveKotlinType` has no boundary type for `kotlin.Any` |
So the nine split into three unrelated limits, not one:
- **suspend lambda** (`pointerInput`, `dragAndDropSource`, `draggable`, and `pullRefresh`'s declined
- overload) — §9.1 opens one of these.
+ overload), §9.1 opens one of these.
- **generic type parameter with no spellable name** (`swipeable`, `modifierLocalProvider`,
- `anchoredDraggable`) — a walker limit, not a suspend one; see §9.2.
-- **compile-model mismatch** (`composed`) and **unconstrained parameter type** (`layoutId`, `contextMenuOpenDetector`) — see §9.2.
+ `anchoredDraggable`), a walker limit, not a suspend one; see §9.2.
+- **compile-model mismatch** (`composed`) and **unconstrained parameter type** (`layoutId`, `contextMenuOpenDetector`), see §9.2.
-### 9.1 `Modifier.pointerInput` — judged reachable, and reached
+### 9.1 `Modifier.pointerInput`, judged reachable, and reached
The blocking claim in `FunctionSlotBindingTest`'s KDoc is narrower than it reads: "a Python callable
cannot answer `COROUTINE_SUSPENDED`" is true of a *bare* Python callable substituted for the whole
-suspend lambda, but `pointerInput`'s block does not need to *be* the suspend function — it needs to
+suspend lambda, but `pointerInput`'s block does not need to *be* the suspend function, it needs to
*call into* one, once per event, and the suspension all happens inside `awaitPointerEventScope {
awaitPointerEvent() }`, which is ordinary Kotlin the compiler already knows how to build a state
machine for. Nothing about Python has to suspend at all if the loop that suspends is hand-written
-Kotlin and Python is only ever asked a synchronous question at each iteration — exactly the shape
+Kotlin and Python is only ever asked a synchronous question at each iteration, exactly the shape
`PythonCallables.PythonFunction` already crosses for every *other* `Function0..Function5` slot in this
codebase (§4.2's `content=` in this file, among others).
That is `fixture.compose.pythonPointerInput` (`ksp-fixtures/compose/src/desktopMain/kotlin/fixture/
-compose/PythonPointerInput.kt`): a hand-written, ordinarily-compiled `suspend` Kotlin function —
+compose/PythonPointerInput.kt`): a hand-written, ordinarily-compiled `suspend` Kotlin function,
fun pythonPointerInput(modifier: Modifier, onEvent: PyObject): Modifier =
modifier.pointerInput(Unit) {
@@ -758,7 +758,7 @@ compose/PythonPointerInput.kt`): a hand-written, ordinarily-compiled `suspend` K
}
bound by KSP exactly the way `fixture.compose.emptyModifier` already is (`FragmentScanner
-.topLevelFunctionEntry`, a plain top-level function, no artefact walker involved — `Modifier.
+.topLevelFunctionEntry`, a plain top-level function, no artefact walker involved, `Modifier.
pointerInput` itself is untouched and still declines, correctly, for the reason in the table above).
It is a plain parameter rather than an extension receiver on purpose:
`FragmentScanner.topLevelFunctionEntry` calls a declaration by its qualified name over
@@ -769,28 +769,28 @@ shape that works today.
**Proven, not asserted.** `ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/
PointerInputRenderTest.kt` drives a real `Move`/`Press`/`Release` sequence through
-`ImageComposeScene.sendPointerEvent` — the same entry point `CallbackDrivenRenderTest` uses for
-`Checkbox` — at a `Text` sized by the walked `size__Dp`, whose displayed string is
+`ImageComposeScene.sendPointerEvent`, the same entry point `CallbackDrivenRenderTest` uses for
+`Checkbox`, at a `Text` sized by the walked `size__Dp`, whose displayed string is
`str(len(_tap_events))`. The test asserts, in Python, that at least one `press` event arrived with
coordinates inside the 48×48 target box (not a stub value), and separately that a *fresh* scene shows
-a different digit once the callback has run — the same two-part shape (`_events` list, then a second
+a different digit once the callback has run, the same two-part shape (`_events` list, then a second
scene) `CallbackDrivenRenderTest` uses for `Checkbox`/`Switch`, for the same reason: the scene that
received the tap does not itself recompose from a Python list mutation Compose's snapshot system
cannot see. Confirmed red first: with `PythonPointerInput.kt` removed, both tests fail with
`PyException: cannot import name 'pythonPointerInput' from 'fixture.compose'`; restored, both pass
(`:ksp-fixtures:compose:desktopTest`, 50 tests / 0 failures, up from 48 before this file).
-### 9.2 `draggable` and `dragAndDropSource` — reached, the same way `pointerInput` was
+### 9.2 `draggable` and `dragAndDropSource`, reached, the same way `pointerInput` was
-Both measured against the real Compose 1.7.0 desktop jars (`javap -p`, and — since there is no sources
-jar for this Compose version — bytecode disassembly of the internal node classes where the declared
+Both measured against the real Compose 1.7.0 desktop jars (`javap -p`, and, since there is no sources
+jar for this Compose version, bytecode disassembly of the internal node classes where the declared
signature alone did not settle how the slot is actually driven), 2026-08-17.
- **`draggable`** (`androidx.compose.foundation.gestures.DraggableKt.draggable`) is not one suspend
slot but two different shapes: `state: DraggableState`'s own `onDelta` (built by the top-level
`DraggableState(onDelta: (Float) -> Unit): DraggableState` factory, itself a plain function, not
`@Composable`) is an *ordinary, non-suspend* `Function1` the internal drag node calls
- directly, once per raw pointer delta — the same shape every other plain callback in this codebase
+ directly, once per raw pointer delta, the same shape every other plain callback in this codebase
already crosses. `onDragStarted: suspend CoroutineScope.(Offset) -> Unit` and
`onDragStopped: suspend CoroutineScope.(Float) -> Unit` are `draggable`'s own parameters and are the
`pointerInput` shape: each invoked once per gesture, by a coroutine whose body never actually
@@ -799,7 +799,7 @@ signature alone did not settle how the slot is actually driven), 2026-08-17.
`DraggableState` from one `PyObject` callback and passes two hand-written trivial-suspend lambdas for
the other two, calling all three synchronously. **Lifetime is not solved here and the file says so**:
unlike `pointerInput`'s single suspend loop, there is no one coroutine whose `finally` covers this
- function's whole lifetime — `onDelta` is held by the `DraggableState` for as long as the internal drag
+ function's whole lifetime, `onDelta` is held by the `DraggableState` for as long as the internal drag
node stays attached, which this function has no hook into without wrapping itself in a `@Composable`
(a different fix; see `composed` below). The three `PyObject` references are never closed; each call
to `pythonDraggable` leaks them. Measured, not guessed: `DraggableRenderTest` composes it once per
@@ -819,17 +819,17 @@ signature alone did not settle how the slot is actually driven), 2026-08-17.
- **`dragAndDropSource`** (`androidx.compose.foundation.draganddrop.DragAndDropSourceKt
.dragAndDropSource`) turns out to be the *same* mechanism `pointerInput` uses, not merely the same
declared shape: disassembling `DragAndDropSourceNode`'s constructor shows it `delegate`s a
- `SuspendingPointerInputFilterKt.SuspendingPointerInputModifierNode(dragAndDropSourceHandler)` — the
+ `SuspendingPointerInputFilterKt.SuspendingPointerInputModifierNode(dragAndDropSourceHandler)`, the
identical node `Modifier.pointerInput` itself delegates to. `DragAndDropSourceScope` (the `block`
parameter's receiver) extends `PointerInputScope`, so `fixture.compose.pythonDragAndDropSource`'s body
- is `pythonPointerInput`'s loop, verbatim, on a different receiver type — including the same
+ is `pythonPointerInput`'s loop, verbatim, on a different receiver type, including the same
`finally`-based lifetime, since it is the same underlying coroutine. `drawDragDecoration` (the other,
non-suspend parameter) is a hardcoded no-op here; this proves the suspend slot's events reach Python,
not that `startTransfer`/`drawDragDecoration` do anything real (a `DragAndDropTransferData` needs a
`java.awt.datatransfer.Transferable`, a real drag payload and a different, unmeasured claim). Required
`@OptIn(ExperimentalFoundationApi::class)`, and one thing surfaced only at runtime, not in `javap`:
Compose Multiplatform desktop prints `"Compose Multiplatform doesn't support Modifier
- .dragAndDropSource yet"` (once per composition of the modifier) — a warning from elsewhere in
+ .dragAndDropSource yet"` (once per composition of the modifier), a warning from elsewhere in
`dragAndDropSource`'s own implementation, not an exception, and it does not stop the delegated
`SuspendingPointerInputModifierNode` from receiving real events: the render test's ink still moves
(58→59px, 68px changed) and Python's assertions on real (kind, x, y) values still pass.
@@ -839,23 +839,23 @@ signature alone did not settle how the slot is actually driven), 2026-08-17.
Confirmed red first (`cannot import name 'pythonDragAndDropSource'` with the file removed); green
restored.
-### 9.3 `layoutId` — reached, and one more limit found along the way
+### 9.3 `layoutId`, reached, and one more limit found along the way
`layoutId(Modifier, id: Any)` declines for the reason §9's table gives: its one non-`Modifier`
parameter is `kotlin.Any`, which `resolveKotlinType` has no `TypeTag` for. `fixture.compose
.pythonLayoutIdString(modifier: Modifier, id: String): Modifier` fixes the parameter's tag exactly the
-way the table's KDoc predicted — one concrete-`String` wrapper, no suspend or generic-type problem
+way the table's KDoc predicted, one concrete-`String` wrapper, no suspend or generic-type problem
involved at all.
Proving what it tagged is where a second, previously unmeasured limit showed up. A first version paired
it with a hand-written `@Composable fun pythonLayoutIdProbe(...)`, meant to read `Measurable.layoutId`
-back and place a child accordingly — bound as a plain top-level function the way `pythonPointerInput`
+back and place a child accordingly, bound as a plain top-level function the way `pythonPointerInput`
is. **It does not bind, confirmed by running it**: `cannot import name 'pythonLayoutIdProbe'`, silently
-(no compile error, no KSP warning surfaced by the "e: "-line check) — `BindingPolicy.isComposable`
+(no compile error, no KSP warning surfaced by the "e: "-line check), `BindingPolicy.isComposable`
excludes *every* `@Composable` top-level function from `FragmentScanner`'s plain-function path on
purpose, per its own KDoc: a `@Composable` in the consumer's own source cannot be called from the
generated, non-composable fragment source, and KSP must not try. `PythonComposition` is not a
-counterexample to this — Python never calls it; `ImageComposeScene`'s own composable content lambda
+counterexample to this, Python never calls it; `ImageComposeScene`'s own composable content lambda
does, from a context that already has a composer. This is a more definite answer than `composed`'s
"unverified whether a composer is admitted at all" below: for a plain hand-written composable in this
module's own source, the answer is no, independent of any per-declaration contract question.
@@ -866,38 +866,38 @@ whatever single `Text` Python's body composed. It places that child at x=0 if th
equals an `expectTag` known only to the Kotlin test (never crossed to Python) or at x=60 otherwise.
Positive case (`aMatchingLayoutIdPlacesTheChildAtTheLeft`): tag and probe both `"python-tag"`, ink at
x∈[0,20). Negative control (`aMismatchedLayoutIdPlacesTheChildAtTheRight`): tag `"python-tag"`, probe
-`"a-different-tag"`, ink at x∈[60,80) instead — ruling out a stub that always reports a match or drops
+`"a-different-tag"`, ink at x∈[60,80) instead, ruling out a stub that always reports a match or drops
the string Python actually sent. Confirmed red first (`cannot import name 'pythonLayoutIdString'` with
`PythonLayoutId.kt` removed); green restored. `pythonLayoutIdInt` was judged not worth building
alongside it: the `String` case is what needed a second crossing path to be found at all, and `Int`
would exercise nothing new.
-### 9.4 `pullRefresh`'s callback overload and the generic-type group — still not attempted; `composed` — reached
+### 9.4 `pullRefresh`'s callback overload and the generic-type group, still not attempted; `composed`, reached
- **`pullRefresh`'s callback overload** (`androidx.compose.material.pullrefresh.PullRefreshKt
.pullRefresh(Modifier, onPull: (Float) -> Float, onRelease: suspend (Float) -> Float, enabled:
Boolean)`) turns out **not** to be the same shape §9's table assumed. Disassembling it (no sources jar
for this Compose version) shows it builds a `PullRefreshNestedScrollConnection(onPull, onRelease,
- enabled)` and installs it with `Modifier.nestedScroll` — `onPull`/`onRelease` are driven by
+ enabled)` and installs it with `Modifier.nestedScroll`, `onPull`/`onRelease` are driven by
`NestedScrollConnection` callbacks from a *scrollable descendant's* pre-scroll/pre-fling reports, not
by pointer events this modifier's own suspend body awaits directly the way `pointerInput`,
`draggable` and `dragAndDropSource` all do. `onPull` also has to *return* the `Float` amount consumed,
which `pythonDraggable`'s `onDelta` (returning `Unit`) does not. Reaching it the way §9.2 reaches
- `draggable` is very likely still possible — `onPull`'s marshalling is one more step than a `Unit`
- callback, and `onRelease` is the same trivial-suspend-lambda shape as `onDragStopped` — but proving it
+ `draggable` is very likely still possible, `onPull`'s marshalling is one more step than a `Unit`
+ callback, and `onRelease` is the same trivial-suspend-lambda shape as `onDragStopped`, but proving it
needs a real nested-scroll parent/child composed inside the test (a scrollable container whose
over-scroll at rest actually reaches `PullRefreshNestedScrollConnection`), which is a materially larger
test harness than a bare tagged `Modifier` under a pointer. Not attempted here for that reason, not a
suspend-shape reason.
-- **`composed`** — **attempted, reached, and proven.** Its `factory` compiles to the *same* lowered
+- **`composed`**: **attempted, reached, and proven.** Its `factory` compiles to the *same* lowered
shape (`Function3`, confirmed by `javap -p
androidx.compose.ui.ComposedModifierKt`) `PythonCallables` already crosses for every composable
- `content=` slot, so the missing piece was never the crossing but a call site — which
+ `content=` slot, so the missing piece was never the crossing but a call site, which
`ksp-fixtures/compose` already has the Compose plugin to compile. The verdict this section left open
- — "still unverified whether `composed`'s own contract... admits a composer at all" — is answered
+ , "still unverified whether `composed`'s own contract... admits a composer at all", is answered
**yes**, and the premise in that sentence was backwards: `composed` itself is not `@Composable` (the
same `javap` output shows no synthetic `$composer`/`$changed` on the `composed` static method), which
- does not make a composer *unavailable* to `factory` — it means `BindingPolicy.isComposable` (which
+ does not make a composer *unavailable* to `factory`, it means `BindingPolicy.isComposable` (which
excludes only a declaration *itself* annotated `@Composable`) never sees `pythonComposed` and does not
exclude it, exactly as this paragraph predicted. `fixture.compose.pythonComposed`
(`ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt`) is a plain,
@@ -905,12 +905,12 @@ would exercise nothing new.
composable-typed lambda literal is where `currentComposer` resolves, exactly as `PythonComposition`'s
does. What "admits a composer" turned out to mean, and what needed proving rather than inferring from
bytecode, is whether that composer is wired into a *real, positionally-stable slot table* rather than
- a placeholder that merely satisfies the type — `ComposedRenderTest
+ a placeholder that merely satisfies the type, `ComposedRenderTest
.theSameCompositionReusesTheRememberedSlotAcrossRecompositions` measures it: a `remember { }` inside
the `composed { }` lambda allocates once (`remembersAllocated == 1`) and is *reused* across six
forced recompositions of the same node, even though `PythonComposition` re-`exec`s the Python source
on every pass and so calls `pythonComposed` itself afresh each time, building a brand-new
- `ComposedModifier` Kotlin object — the slot survives because `Modifier.composed`'s own
+ `ComposedModifier` Kotlin object, the slot survives because `Modifier.composed`'s own
materialize-time group is positional, not object-identity-based, which is the mechanism that lets a
real `Modifier.clickable`'s internal `remember(interactionSource) { ... }` survive a modifier chain
rebuilt from scratch every recomposition. A placeholder composer could not have produced that
@@ -918,16 +918,16 @@ would exercise nothing new.
proof that the `Modifier` `factory` returns is the one actually measured, not a stub: a Python
`factory` calling the walked `size__Dp(receiver, 70.0)` measures a 70px-wide placeable, a second one
with `33.0` measures 33px, and a negative-control identity factory (`return receiver`, no resize)
- measures 8px (`Text("x")`'s own intrinsic width) — ruling out both "the result is ignored" and "any
+ measures 8px (`Text("x")`'s own intrinsic width), ruling out both "the result is ignored" and "any
factory produces the same fixed size." Not solved: `factory`'s one Python reference is captured by
the closure and never released (no `RememberObserver` hook exists for a bare captured `PyObject` the
- way `content=`'s `PythonCallableScope` has one) — the same documented, bounded shape as
+ way `content=`'s `PythonCallableScope` has one), the same documented, bounded shape as
`pythonDraggable`'s leak above, out of scope for this question.
- **`swipeable`, `modifierLocalProvider`, `anchoredDraggable`** share a limit `pythonPointerInput`'s
technique does not reach: the unspellable part is a **type parameter** (`T` in `SwipeableState`,
`ProvidableModifierLocal`, `AnchoredDraggableState`), not a suspend modifier. A hand-written
wrapper can dodge this the same way `PyObject`-typed parameters dodge every other type problem at
- this boundary, but only per concrete `T` — `pythonSwipeableFloat(modifier, state:
+ this boundary, but only per concrete `T`, `pythonSwipeableFloat(modifier, state:
SwipeableState, ...)`, one declaration per instantiation a caller wants, not one wrapper that
covers the whole generic surface the way `pythonPointerInput` covers every `pointerInput` call site.
Not attempted, and not the same shape of fix.
diff --git a/docs/archive/threading-and-abi-3.15t-gate.md b/docs/archive/threading-and-abi-3.15t-gate.md
index aad63ff2b..a95c28c53 100644
--- a/docs/archive/threading-and-abi-3.15t-gate.md
+++ b/docs/archive/threading-and-abi-3.15t-gate.md
@@ -9,10 +9,10 @@
Free-threaded builds **do not support the Limited API or the Stable ABI** in 3.13 or 3.14. All ~330
bindings here target the Stable ABI, so a free-threaded 3.14 build cannot be used without abandoning
-it and recompiling per Python version — which would defeat the version-parameterised acquisition
+it and recompiling per Python version, which would defeat the version-parameterised acquisition
pipeline.
-[PEP 803](https://peps.python.org/pep-0803/) — **Final**, targeting 3.15 — introduces `abi3t`, a
+[PEP 803](https://peps.python.org/pep-0803/), **Final**, targeting 3.15, introduces `abi3t`, a
Stable ABI variant for free-threaded builds, with `abi3t` / `abi3.abi3t` wheel tags and forward
compatibility across all later versions. 3.15 is therefore the first release where free-threading
and the Stable ABI coexist.
@@ -22,7 +22,7 @@ and the Stable ABI coexist.
| Requirement | Our status |
|---|---|
| `PyObject` / `PyVarObject` become incomplete types; no field access | **Compatible.** Verified: `PyObject` appears only as `CPointer` and is never dereferenced. The only `.pointed` uses in the tree are commented-out `JNIEnv` code in `JniExport.kt`. |
-| Extensions may not embed `PyObject` in their own structs | Compatible — nothing does. |
+| Extensions may not embed `PyObject` in their own structs | Compatible, nothing does. |
| `PyModExport` hook (PEP 793) instead of static `PyModuleDef` | Not yet relevant. We embed CPython rather than building an extension module. **It becomes relevant for the upcall work**, where Kotlin classes are exposed to Python. |
| No backwards compatibility with 3.14 or earlier | Was accepted at the time; no longer applies, 3.14t is used on desktop (ROADMAP §9). |
@@ -39,7 +39,7 @@ and the Stable ABI coexist.
Desktop free-threaded builds exist today, including 3.15 release candidates. **Mobile has none**, so
enabling free-threading before mobile artefacts appear would split the threading model across
-platforms — and with it the object-lifetime and thread-state design layered on top.
+platforms, and with it the object-lifetime and thread-state design layered on top.
Switching the default (originally) required all three of the following; ROADMAP §9 shows 1 is not needed for 3.14t on desktop, while 2 still holds:
diff --git a/docs/archive/wasm-design-experiment-log.md b/docs/archive/wasm-design-experiment-log.md
index e78d3d494..132c2a6c8 100644
--- a/docs/archive/wasm-design-experiment-log.md
+++ b/docs/archive/wasm-design-experiment-log.md
@@ -2,7 +2,7 @@
> This is the chronological lab notebook of the wasm experiment (feasibility analysis, "Why WASM is
> parked", then a series of corrections as each hypothesis was measured). Its early sections say wasm
> is **parked / not implemented / blocked on Kotlin's toolchain**, that CPython is pinned to 3.13 and
-> Kotlin to 2.0.20, and that a C shim and an `EXPORTED_FUNCTIONS` list are needed — **all of that was
+> Kotlin to 2.0.20, and that a C shim and an `EXPORTED_FUNCTIONS` list are needed, **all of that was
> later disproved or overtaken**; read the sections in order and treat any "unverified" or "parked"
> claim as superseded by the sections after it and by the integrated implementation
> (`python-multiplatform/src/wasmJsMain/`, `wasmJsTest/`, `tools/wasm/build-cpython.sh`; SPEC §0, C-6, M-3, U-5).
@@ -67,7 +67,7 @@ Currently, Kotlin/Wasm cannot dynamically link directly to an arbitrary C WebAss
## 7. Recommendation and Staged Plan
-**Recommendation:** Pursue **Approach (b) — Raw C Symbols via JS Bridge** for the `wasmJs` target. It is the only path that preserves the multiplatform object model. Ignore `wasmWasi` until Wasm Component Model linking matures in Kotlin.
+**Recommendation:** Pursue **Approach (b), Raw C Symbols via JS Bridge** for the `wasmJs` target. It is the only path that preserves the multiplatform object model. Ignore `wasmWasi` until Wasm Component Model linking matures in Kotlin.
**Caveat:** WASM will have permanently higher per-call overhead than Native/JVM due to the WasmGC ↔ JS ↔ Emscripten boundary crossings.
@@ -87,7 +87,7 @@ The analysis above assumed Kotlin/Wasm is the only route. That is true today, bu
fundamental reason.
Kotlin/Native used to ship a `wasm32` target built through LLVM, producing linear-memory
-WebAssembly with a C ABI — the same memory model an Emscripten build of CPython uses. That target
+WebAssembly with a C ABI, the same memory model an Emscripten build of CPython uses. That target
could in principle have used `cinterop`, exactly as the iOS target does today. JetBrains
**deprecated it in Kotlin 1.8.20 and removed it in 1.9.20**, in favour of the Kotlin/Wasm
toolchain, which skips LLVM and is built on WasmGC.
@@ -98,7 +98,7 @@ The consequence is that the JS bridge is forced by *tooling*, not by WebAssembly
- Kotlin/Wasm has no `cinterop` at all. `wasmJs` offers JS interop; `wasmWasi` offers WASI syscalls.
This project targets Kotlin 2.0.20, so the `wasm32` target is long gone and pinning to 1.9.10 is
-not viable — the rest of the toolchain requires 2.0.x.
+not viable, the rest of the toolchain requires 2.0.x.
### The dependency is CPython's own WASM support, not Pyodide
@@ -121,7 +121,7 @@ Tier 3. Three of its details bear directly on this design:
dynamic builds. CPython therefore cannot be dynamically loaded into another WebAssembly module.
2. **The build produces an `.mjs` plus `.wasm` pair.** A JavaScript runtime layer is intrinsic to
the Emscripten build; it is not something an integration can route around.
-3. **No official python.org distribution is mandated** — binaries continue to come from downstream,
+3. **No official python.org distribution is mandated**: binaries continue to come from downstream,
i.e. Pyodide. Avoiding Pyodide as a *dependency* therefore means building CPython from source
ourselves, not that an upstream binary exists to use instead.
@@ -148,20 +148,20 @@ Recorded so the reasoning survives. Nothing here is implemented.
## The blocker is Kotlin's toolchain, not WebAssembly
-Every other platform calls CPython directly — cinterop on iOS and androidNative, Panama on
+Every other platform calls CPython directly, cinterop on iOS and androidNative, Panama on
desktop, JNI on Android. Kotlin/Wasm cannot: `wasmJs` offers only JS interop and `wasmWasi`
only WASI syscalls, and neither can link a C library.
Kotlin/Native once had a `wasm32` target that went through LLVM and emitted linear-memory
-wasm — the same memory model Emscripten's CPython uses, which is exactly what would have made
+wasm, the same memory model Emscripten's CPython uses, which is exactly what would have made
cinterop work. It was deprecated in 1.8.20 and removed in 1.9.20. So the JS bridge is a
consequence of a toolchain decision, not of WebAssembly.
**The deeper barrier is memory, not calls.** Two wasm modules can call each other directly if
-the host wires module B's exports to module A's imports at instantiation — JS is needed once,
+the host wires module B's exports to module A's imports at instantiation, JS is needed once,
not per call. But Kotlin/Wasm is WasmGC: its objects live on a managed heap, and it has no way
to read another module's linear memory. CPython's entire API is pointers into exactly that
-memory. So JS is not a convenience in the call path — it is where the marshalling has to
+memory. So JS is not a convenience in the call path, it is where the marshalling has to
happen.
JetBrains has a **Multiple Memory** proposal under consideration that would address this, and a
@@ -173,12 +173,12 @@ committed date, and neither appears on the published roadmap, so neither can be
| | `wasm32-emscripten` | `wasm32-wasi` |
|---|---|---|
| CPython support | Tier 3 from 3.14 (PEP 776) | **Tier 2** |
-| bridge between the two modules | **JS exists** | none — needs Component Model, which Kotlin does not emit |
+| bridge between the two modules | **JS exists** | none, needs Component Model, which Kotlin does not emit |
| C extension modules | **dlopen works** | no dlopen in preview1; extensions must be linked in |
| matches this project | **browser; the sample already targets `wasmJs`** | server/edge |
The decisive row is the third. This project exists to share libraries, and on WASI a user could
-not add a compiled package after the fact — the interpreter would have to be rebuilt. That
+not add a compiled package after the fact, the interpreter would have to be rebuilt. That
guts the goal on that platform, which outweighs WASI's better support tier.
The second row is nearly as decisive: WASI has no JS host, so there is no mechanism at all to
@@ -212,7 +212,7 @@ composed operations, the same ones measured on Android:
int pmp_scratch(void); // reusable buffer address, once
**Interning applies here and matters more.** A Kotlin string lives on the WasmGC heap and a C
-string in linear memory, so copying costs a JS hop — but attribute and module names are
+string in linear memory, so copying costs a JS hop, but attribute and module names are
repeated literals, so caching the linear-memory address makes every call after the first cost
nothing. This is the same fix measured on Android at 2238 ns → 148 ns, against a hop that is
more expensive here than JNI's.
@@ -227,7 +227,7 @@ Nothing above changes the layering: `wasmJsMain` supplies `actual`s for the same
## What survives if the toolchain improves
-If Multiple Memory lands, the C shim stays — composition is wanted for its own sake — and only
+If Multiple Memory lands, the C shim stays, composition is wanted for its own sake, and only
the JS glue is replaced by direct memory access. The `actual` signatures do not change. That
bounds the throwaway work to a few dozen lines of glue, which is the argument for this shape
over waiting indefinitely.
@@ -245,7 +245,7 @@ over waiting indefinitely.
## Start condition
-Not now. `wasmJsMain`'s `actual`s cannot be written until it is settled what they implement —
+Not now. `wasmJsMain`'s `actual`s cannot be written until it is settled what they implement,
§1/§4 (GIL and automatic release) and §7 (upcall shape) are both in flight, and building against
them now means building twice.
@@ -253,9 +253,9 @@ them now means building twice.
Two independent choices, easily conflated:
-1. **How our C code is packaged** — statically linked into CPython, or a side module loaded by
+1. **How our C code is packaged**: statically linked into CPython, or a side module loaded by
`dlopen` at import time.
-2. **Where CPython comes from** — built by us with Emscripten, or Pyodide's distribution.
+2. **Where CPython comes from**: built by us with Emscripten, or Pyodide's distribution.
### Side module, on both counts of its own
@@ -267,7 +267,7 @@ Two independent choices, easily conflated:
| runtime | no indirection | dynamic-link indirection (small) |
Having to rebuild the interpreter to change a binding is the same trap this project already hit
-elsewhere — a `.def` edit needing a forced link and staging pass before it reached a device. An
+elsewhere, a `.def` edit needing a forced link and staging pass before it reached a device. An
extension module is what CPython already has a mechanism for; we are not doing anything unusual.
### Our own CPython, and no attempt at Pyodide compatibility
@@ -286,7 +286,7 @@ The loss from building our own is narrower than it first appears:
| | on our own build |
|---|---|
-| pure-Python wheels (`py3-none-any`) — no ABI tag | work unchanged |
+| pure-Python wheels (`py3-none-any`), no ABI tag | work unchanged |
| C extensions (numpy, pandas, lxml, cryptography) | must be built for our ABI |
And the second row is true of Pyodide too, for anything outside the list they happen to have
@@ -302,7 +302,7 @@ The section above is right about the decision and wrong about its cost. It was w
`pyodide_2024_0_wasm32` tag and concluded that any wheel compatibility means being locked to a
Pyodide release. That stopped being true.
-[PEP 783 — Emscripten Packaging](https://peps.python.org/pep-0783/) is **Accepted**. It replaces
+[PEP 783, Emscripten Packaging](https://peps.python.org/pep-0783/) is **Accepted**. It replaces
the Pyodide-owned tag with a standard platform tag series that PyPI accepts:
```
@@ -314,8 +314,8 @@ Three things follow, and they change the trade-off rather than the conclusion.
**The platform is versioned per Python feature release, not per distribution release.** The
current series is `pyemscripten_2024_0` (3.12), `pyemscripten_2025_0` (3.13), `pyemscripten_2026_0`
-(3.14). The earlier worry — "the web platform tracks Pyodide's cadence while every other platform
-is pinned to 3.14" — does not apply: the platform version *is* the Python version. Pinning to 3.14
+(3.14). The earlier worry, "the web platform tracks Pyodide's cadence while every other platform
+is pinned to 3.14", does not apply: the platform version *is* the Python version. Pinning to 3.14
from source and claiming `pyemscripten_2026_0` are the same act. The patch component exists as
an escape hatch the PEP hopes never to use.
@@ -351,7 +351,7 @@ no longer *owns* the tag. That is a real residual coupling, but it binds a flag
| Pyodide as a dependency | the price of wheels | not required |
| pure-Python wheels | work unchanged | work unchanged |
-The decision stands — our own Emscripten CPython 3.14 — but the recorded limitation was
+The decision stands, our own Emscripten CPython 3.14, but the recorded limitation was
overstated. Compiled extensions are not lost by building our own; they are lost only if we build
with flags that diverge from `pyemscripten_2026_0`. **Matching that flag set should be a build
constraint from the start**, because retrofitting it means rebuilding the interpreter.
@@ -365,8 +365,8 @@ backend, and [pydantic publishes Emscripten wheels to PyPI under it](https://pyd
`pyodide.org/en/stable/development/abi.html` still returns 403 to automated fetching. The same
documents are in the repository and `raw.githubusercontent.com` serves them, which is how the
-following was read. Sources are `github.com/pyodide/pyodide` at tag **`314.0.4`** —
-`docs/development/abi/314.md`, `docs/development/abi/flags.md`, `Makefile.envs` — and
+following was read. Sources are `github.com/pyodide/pyodide` at tag **`314.0.4`**,
+`docs/development/abi/314.md`, `docs/development/abi/flags.md`, `Makefile.envs`, and
`github.com/pyodide/pyodide-build` `pyodide_build/config.py`.
`Makefile.envs` at that tag pins the whole platform in four lines:
@@ -382,16 +382,16 @@ PYODIDE_EMSCRIPTEN_VERSION ?= 5.0.3
|---|---|---|
| Emscripten | **5.0.3** | 4.0.9 |
| unwinding | `-fwasm-exceptions -sSUPPORT_LONGJMP=wasm`, at **compile and link** | same |
-| `-sWASM_BIGINT` | required, but **default since Emscripten 4.0.0** — not typed explicitly | same |
+| `-sWASM_BIGINT` | required, but **default since Emscripten 4.0.0**, not typed explicitly | same |
| `-pthread` | prohibited; "if `-pthread` is used, the resulting libraries will not load" | same |
| dependency lookup | full `RPATH` support, and `RPATH` is the *only* mechanism | same |
| Rust | stable `1.93.0`; no nightly, no custom sysroot | nightly + custom sysroot |
-| new static libs | `sqlite3` 3.39.0, `lzma`/xz 5.2.2 | — |
+| new static libs | `sqlite3` 3.39.0, `lzma`/xz 5.2.2 | - |
Statically linked into the main binary: `zlib`, `bzip2`, `sqlite3`, `lzma`, `zstd`, `libffi`,
`libhiwire`, and the Emscripten JS libraries (`libGL`, `libegl.js`, `libwebgl.js`, `libhtml5*.js`,
`libsdl.js`, `libwebsocket.js`, `libeventloop.js`, `liblz4`, the filesystem backends). **OpenSSL is
-not** — Pyodide builds it as a shared side module (`libssl.so`/`libcrypto.so`, OpenSSL 1.1.1w)
+not**, Pyodide builds it as a shared side module (`libssl.so`/`libcrypto.so`, OpenSSL 1.1.1w)
found through `RPATH`.
Extension modules are side modules. Pyodide documents `-sSIDE_MODULE=2` plus an explicit
@@ -403,7 +403,7 @@ shim.
defers normatively to Pyodide's documentation for the values. It does specify how the tag is
derived, and that is the part that bites: `packaging` reads
`sysconfig.get_config_var("PYEMSCRIPTEN_PLATFORM_VERSION")`. **That variable does not exist
-anywhere in CPython 3.14.2** — `grep -r PYEMSCRIPTEN` over the whole checkout returns nothing. So a
+anywhere in CPython 3.14.2**, `grep -r PYEMSCRIPTEN` over the whole checkout returns nothing. So a
stock CPython Emscripten build cannot advertise the platform even if it matches the flags; the
variable is something Pyodide's build injects.
@@ -411,7 +411,7 @@ variable is something Pyodide's build injects.
## Correction: the JS layer is not mandatory
-Everything above assumes Kotlin/Wasm can only reach CPython through JavaScript — that crossings
+Everything above assumes Kotlin/Wasm can only reach CPython through JavaScript, that crossings
cost a JS hop and that string data must be copied by JS between two isolated memories. Reading
the Kotlin compiler shows both halves of that are wrong on the `wasmJs` target. This section
records what was checked and where it stops.
@@ -429,7 +429,7 @@ outright:
So `pmp_getattr(i32, i32) -> i32` can be a direct wasm-to-wasm call. JS is still involved in
*instantiating* the modules and wiring the import, but not in the call. This is exactly the
-narrow integer interface the shape above already proposes — it just costs less than assumed.
+narrow integer interface the shape above already proposes, it just costs less than assumed.
The constraint is the one that makes it work: no type adapters means wasm primitive types only.
No strings, no objects. Which is what the composed shim signature already looks like.
@@ -441,16 +441,16 @@ No strings, no objects. Which is what the composed shim signature already looks
| target | what the module does with its memory |
|---|---|
-| `wasmWasi` | **defines and exports** it — `WasmExport.Memory("memory", …)`, and the source comments that the name is a WASI ABI convention |
+| `wasmWasi` | **defines and exports** it, `WasmExport.Memory("memory", …)`, and the source comments that the name is a WASI ABI convention |
| `wasmJs` | **imports** it, as `WasmImportDescriptor("intrinsics", "memory")`, declared with 0 initial pages |
That inverts the premise. A `wasmJs` module does not own a memory it defines; the host hands one
in. And `kotlin.wasm.unsafe.Pointer` is an `i32` into precisely that memory.
So the question "can Kotlin read CPython's `char*`" reduces to "can the host supply Emscripten's
-memory as that import" — at the wasm level, both are just a `WebAssembly.Memory` in an import
+memory as that import", at the wasm level, both are just a `WebAssembly.Memory` in an import
object, and Emscripten exposes its own as `Module.wasmMemory`. **If that works, string
-marshalling does not merely get cheaper, it disappears** — which matters more here than anywhere
+marshalling does not merely get cheaper, it disappears**, which matters more here than anywhere
else, because every measurement on Android and desktop found marshalling, not crossings, to be
the dominant cost.
@@ -465,8 +465,8 @@ the heap.
This does not sink the idea, but it bounds it:
-- **Reading and writing CPython-owned buffers** — addresses that CPython allocated and handed
- back as `i32` — is safe.
+- **Reading and writing CPython-owned buffers**: addresses that CPython allocated and handed
+ back as `i32`, is safe.
- **Allocating from Kotlin inside the shared memory** is not, unless the allocator can be given
a base address it does not currently accept.
@@ -475,7 +475,7 @@ only from `allocate()`, has not been checked and decides whether the safe half i
### 4. The impossible direction stays impossible, and is already handled
-None of this changes that a WasmGC reference cannot be stored in linear memory — that is a
+None of this changes that a WasmGC reference cannot be stored in linear memory, that is a
spec-level guarantee, not a maturity gap, so CPython can never hold a Kotlin object pointer in a
`PyObject` field. The answer there remains an index into a Kotlin-side table, which is what
`docs/design/object-lifetime.md` already specifies for Python→Kotlin. Kotlin/Wasm even blesses the
@@ -486,13 +486,13 @@ mechanism: `JsReference` passes a Kotlin object as an opaque reference.
| | as recorded above | after reading the compiler |
|---|---|---|
| Kotlin → CPython call | JS hop | **direct wasm call** (`@WasmImport`) |
-| string marshalling | JS copies between two memories | **possibly none** — if the memory is shared |
-| Kotlin allocating in that memory | assumed fine | **unsafe** — allocator starts at 0 |
+| string marshalling | JS copies between two memories | **possibly none**, if the memory is shared |
+| Kotlin allocating in that memory | assumed fine | **unsafe**, allocator starts at 0 |
| CPython holding a Kotlin object | impossible | impossible (unchanged) |
The composed-shim design does not change shape. What changes is its cost, and the reason to build
it: composition was proposed here to amortise JS hops, and if the hops are gone it has to justify
-itself on crossing count alone — the same argument that closed desktop composition in ROADMAP §6.
+itself on crossing count alone, the same argument that closed desktop composition in ROADMAP §6.
### Unverified, in the order that decides the design
@@ -524,7 +524,7 @@ released version**. Parsing the 2.4.10 output: the only length-prefixed `intrins
So Kotlin does not import a memory today. It exports one.
-**That inverts the plan, and the inverted plan is better** — because it needs nothing unreleased
+**That inverts the plan, and the inverted plan is better**, because it needs nothing unreleased
on either side. Emscripten has `-sIMPORTED_MEMORY`, which makes the CPython module import
`env.memory` rather than define it. Instantiate Kotlin first, take `exports.memory`, grow it to
Emscripten's initial size, hand it over as `env.memory`. Both halves are shipped features.
@@ -541,7 +541,7 @@ pages after exception 0
pages after explicit allocate 2 (allocated at 0x0)
```
-The module declares `min_pages = 0`, and all 40 of its data segments are **passive** — they
+The module declares `min_pages = 0`, and all 40 of its data segments are **passive**, they
initialise WasmGC arrays through `array.new_data` and are never placed in linear memory. String
interop was the obvious suspect for a hidden consumer and it is not one; Kotlin strings are
WasmGC arrays and cross to JS as `externref`.
@@ -557,7 +557,7 @@ memory this corrupts the heap on first use.
The mitigation is not a workaround, it is the natural design: **CPython allocates, Kotlin only
reads.** `Pointer(addr)` has a public constructor and its `loadInt`/`storeByte` members compile to
-plain `i32.load`/`i32.store` with no allocator involvement — confirmed by compiling and running
+plain `i32.load`/`i32.store` with no allocator involvement, confirmed by compiling and running
it. Every address we would ever dereference comes back from a C call.
`withScopedMemoryAllocator` must therefore never appear in `wasmJsMain`. That belongs in the
@@ -571,11 +571,11 @@ source-set README as a hard rule, in the same category as desktop's "always `inv
| who owns the shared memory | CPython, imported by Kotlin (master-only) | **Kotlin exports it, Emscripten imports it** |
| Kotlin reading `char*` | impossible | `Pointer(addr).loadByte()`, no JS, no allocator |
| string marshalling | dominant cost | **none, if the memory is shared** |
-| CPython holding a Kotlin object | impossible | impossible (unchanged — handle index) |
+| CPython holding a Kotlin object | impossible | impossible (unchanged, handle index) |
If this holds end to end, WASM stops being the outlier and lands close to the iOS shape: no
boundary for data, a direct call for control, and a handle table for the one direction WasmGC
-forbids. The composition argument weakens accordingly — it was proposed here to amortise JS hops,
+forbids. The composition argument weakens accordingly, it was proposed here to amortise JS hops,
and without them it must justify itself on crossing count alone, which is what closed desktop
composition in ROADMAP §6.
@@ -597,15 +597,15 @@ composition in ROADMAP §6.
The section above listed three unverified items and said the first needed `emsdk`. It is
installed, both experiments are built and run, and both pass. `wasm-experiment/` reproduces them.
-### Test A — `@WasmImport` binds to an Emscripten export
+### Test A, `@WasmImport` binds to an Emscripten export
Kotlin called `add_two` compiled by `emcc` and got 42. The compiler emits a real wasm import,
`(import "./probeA-wrapper.mjs" "add_two" (func …))`, and wires the ES module into the import
-object. The value supplied prints as `function 2() { [native code] }` — a raw wasm export, not a
-JS wrapper — so the call is wasm-to-wasm with no JS frame, which is what the compiler's own
+object. The value supplied prints as `function 2() { [native code] }`, a raw wasm export, not a
+JS wrapper, so the call is wasm-to-wasm with no JS frame, which is what the compiler's own
`callingWasmDirectly.kt` test intends.
-### Test B — Emscripten imports the memory Kotlin exports
+### Test B, Emscripten imports the memory Kotlin exports
```
C: get_static_message() -> 0x400
@@ -626,14 +626,14 @@ Emscripten refused Kotlin's memory at first:
LinkError: memory import has no maximum limit, expected at most 4294967295
```
-Kotlin emits `WasmLimits(0, null)` — no maximum — and wasm requires a supplied memory's limits to
+Kotlin emits `WasmLimits(0, null)`, no maximum, and wasm requires a supplied memory's limits to
sit inside the importer's, so an unbounded memory can never satisfy any Emscripten import.
Patching section 5 to `{min: 0, max: 32768}` made everything link and both directions work.
Nothing else about the pairing needed changing.
**That is a compiler-chosen constant, not a design problem.** It is worth a YouTrack issue; a
`-Xwasm-memory-maximum`-style knob, or simply emitting the wasm32 ceiling, would remove the need
-to post-process the binary. Until then a build step can patch it, which is ugly but bounded — the
+to post-process the binary. Until then a build step can patch it, which is ugly but bounded, the
memory section is six bytes and nothing in the format holds an absolute offset.
### The constraint that shapes the design
@@ -647,12 +647,12 @@ Emscripten -sIMPORTED_MEMORY needs Kotlin's memory before that
Wasm imports are supplied up front, so that is a cycle. Three ways out:
-1. **Kotlin imports the memory instead of exporting it.** No cycle — Emscripten instantiates
+1. **Kotlin imports the memory instead of exporting it.** No cycle, Emscripten instantiates
first, then Kotlin receives both the memory and the functions. This is
`importWasmMemoryInsteadOfExport = isWasmJsTarget`, present at master and in no release. It is
the configuration this design wants, and the second reason to talk to JetBrains.
2. **JS trampolines for calls, shared memory for data.** Available today. Costs a JS hop per
- call and keeps the data path free — and the data path is the half that dominated every
+ call and keeps the data path free, and the data path is the half that dominated every
measurement on Android and desktop, so this captures most of the win.
3. Copy through JS, as originally designed.
@@ -661,10 +661,10 @@ Wasm imports are supplied up front, so that is a cycle. Three ways out:
| | original design | proven |
|---|---|---|
| control path | JS hop per call | direct wasm call, **or** JS trampoline if sharing memory |
-| data path | JS copies between two memories | **none — one memory** |
+| data path | JS copies between two memories | **none, one memory** |
| string marshalling | dominant cost, needs interning | **absent** |
-| composition | needed to amortise JS hops | **weakly motivated** — same argument that closed §6 |
-| CPython holding a Kotlin object | impossible | impossible — handle index, per `object-lifetime.md` |
+| composition | needed to amortise JS hops | **weakly motivated**, same argument that closed §6 |
+| CPython holding a Kotlin object | impossible | impossible, handle index, per `object-lifetime.md` |
WASM stops being the outlier. With option 2 it is roughly Android's shape with a cheaper data
path; with option 1 it is close to iOS.
@@ -693,7 +693,7 @@ path; with option 1 it is close to iOS.
The result above was nearly reported wrong. `runPromise` resolves after `main()` returns, so the
coroutine's page count was taken *after* the `withScopedMemoryAllocator` demo had already grown
the memory to 2 pages. The probe printed `pages inside coroutine 2` and that number said nothing
-about coroutines — it was the allocator's growth, observed late.
+about coroutines, it was the allocator's growth, observed late.
Gating the allocator demo off and re-running gives the reading that means something:
@@ -742,7 +742,7 @@ intrinsics: {
```
Replacing that one expression with Emscripten's `Module.wasmMemory` is the whole integration. No
-binary patching, no compiler flag, no `max` limit to negotiate — `patch-memory-max.py` becomes
+binary patching, no compiler flag, no `max` limit to negotiate, `patch-memory-max.py` becomes
history rather than a build step.
### It also removes the constraint that shaped the design
@@ -754,7 +754,7 @@ trampolines for the calls, measured at 2.73x a direct call.
With Kotlin importing, the order resolves: instantiate Emscripten first, then hand Kotlin both the
memory and the functions. **Direct wasm-to-wasm calls and a shared linear memory at the same
-time** — which is the configuration this design wanted and had written off as unavailable.
+time**, which is the configuration this design wanted and had written off as unavailable.
### What that leaves
@@ -764,8 +764,8 @@ cost), build CPython for `pyemscripten_2026_0`, write the composed shim, and sup
`Module.wasmMemory` in place of the placeholder above.
Two facts from earlier still hold and still constrain the shape. Kotlin never touches its linear
-memory on its own — measured across strings, collections, exceptions, a 10 MiB `ByteArray`, a
-100k-object graph, the `ArrayBuffer` bridge and coroutines — so handing it to Emscripten costs
+memory on its own, measured across strings, collections, exceptions, a 10 MiB `ByteArray`, a
+100k-object graph, the `ArrayBuffer` bridge and coroutines, so handing it to Emscripten costs
nothing. And `withScopedMemoryAllocator` must never appear in `wasmJsMain`, because it allocates
from address 0 upward, on top of Emscripten's static data.
@@ -790,7 +790,7 @@ non-function exports none
```
`max=none` on the *import* is what makes this direction work. A wasm import must be satisfied by
-a memory whose limits sit inside the declared ones, so an unbounded import accepts anything —
+a memory whose limits sit inside the declared ones, so an unbounded import accepts anything,
including Emscripten's bounded memory. The old direction was unsatisfiable for exactly the
mirror-image reason, and `patch-memory-max.py` existed to force it. It is no longer used.
@@ -805,7 +805,7 @@ intrinsics: { memory: new WebAssembly.Memory({ initial: 0 }), tag: wasmTag }
Ordering comes free from ES modules. `wasm-experiment.import-object.mjs` imports the wrapper, the
wrapper has a top-level `await factory()`, so Emscripten is fully instantiated before Kotlin's
-import object is built. Emscripten is compiled exactly as it would be alone — no
+import object is built. Emscripten is compiled exactly as it would be alone, no
`-sIMPORTED_MEMORY`, no binary patching. Kotlin is the side that adapts.
### Both halves hold at once
@@ -820,8 +820,8 @@ Kotlin: writeCStringAt(malloc'd addr, "written-by-kotlin")
C: str_len -> 17, UTF8ToString -> "written-by-kotlin" PASS
```
-The value handed to the import still prints as `function 2() { [native code] }` — a raw wasm
-export, no JS wrapper — so the control path is wasm-to-wasm with the memory shared.
+The value handed to the import still prints as `function 2() { [native code] }`, a raw wasm
+export, no JS wrapper, so the control path is wasm-to-wasm with the memory shared.
### And it survives the memory growing
@@ -849,7 +849,7 @@ C: malloc(64 MiB) -> 0x10638, 258 pages -> 1026 pages
So removing the cycle is worth about 9–12 ns per crossing. That is the whole benefit of option 1
over option 2, and it is now available.
-### Measured: the data path — and the earlier conclusion was wrong
+### Measured: the data path, and the earlier conclusion was wrong
Everything above said string marshalling "does not merely get cheaper, it disappears". **It does
not.** Both loops below run in Kotlin and end with a Kotlin `String` built from the same C
@@ -870,7 +870,7 @@ Two things account for it, and neither is the boundary:
`builtins: ['js-string']`. `UTF8ToString` produces a JS string, and handing that to Kotlin is
not a conversion. The "copy through JS" path was assumed to pay for a copy that no longer
exists.
-2. **`ByteArray.decodeToString()` is pathological on long input** — 53 µs of the 58 µs figure at
+2. **`ByteArray.decodeToString()` is pathological on long input**: 53 µs of the 58 µs figure at
4000 bytes, about 13 ns per byte, against 0.2 ns per byte at 27 bytes. `concatToString()` on a
`CharArray` costs a tenth of it. Worth a separate report upstream.
@@ -894,7 +894,7 @@ The honest rule that comes out of this is not "marshalling disappears" but:
So the composition arithmetic is now fully determined. Walking a list without a shim means N
direct `PyList_GetItem` calls at **5.0 ns** of crossing each. With a shim it is one crossing plus
-N shared reads at **0.7 ns**. **Composition buys ~4.3 ns per element and nothing else** — the
+N shared reads at **0.7 ns**. **Composition buys ~4.3 ns per element and nothing else**, the
CPython-side work is identical either way.
ROADMAP §6 closed desktop composition at a 10% saving. Test D below measures the same question
@@ -905,7 +905,7 @@ against the real interpreter and answers it more sharply.
## Test D: it works against real CPython, and it closes the composition question
Tests A and C run against a 4 KB toy compiled by `emcc`. Test D runs against **CPython 3.14.2
-built for `wasm32-emscripten`** — 10 MB of wasm, 8191 exports, `-sMAIN_MODULE`, a growable memory,
+built for `wasm32-emscripten`**, 10 MB of wasm, 8191 exports, `-sMAIN_MODULE`, a growable memory,
instantiated by Emscripten's own glue. The build is `Tools/wasm/emscripten` (PEP 776, Tier 3 from
3.14) under Emscripten 5.0.3; see the build notes below.
@@ -929,7 +929,7 @@ survives the interpreter growing its own memory by 600 pages underneath.
### The one build change CPython needed, and it is not ABI-sensitive
-The Stable ABI symbols are all there — `-sMAIN_MODULE` is `LINKABLE`, so Emscripten exports
+The Stable ABI symbols are all there, `-sMAIN_MODULE` is `LINKABLE`, so Emscripten exports
everything and emits `EXPORTED_FUNCTIONS is not valid with LINKABLE set` for CPython's own list.
**This retires the recorded worry that "CPython must be built with an explicit
`EXPORTED_FUNCTIONS` list; a stock Pyodide build strips the C API by dead-code elimination."** The
@@ -944,7 +944,7 @@ interesting ones, so there is nothing to hand to `@WasmImport`. One flag fixes i
```
After a relink, `Module.wasmExports.PyRun_SimpleString` prints as `function 4426() { [native
-code] }` — a raw wasm export — and `Module.wasmMemory` is the `WebAssembly.Memory` Kotlin's
+code] }`, a raw wasm export, and `Module.wasmMemory` is the `WebAssembly.Memory` Kotlin's
`intrinsics.memory` needs. Both are JS-glue settings and neither appears in PEP 783's
ABI-sensitive list, so this costs nothing against the platform tag.
@@ -956,29 +956,29 @@ Each row adds a pure-Kotlin optimisation:
| | ns | |
|---|---|---|
| naive | 259.5 | 2 `malloc` + 2 `free` + 2 string writes + 4 API calls |
-| + interned C strings | 185.4 | names allocated once — the Android fix, for free here |
-| + module/dict hoisted | **65.2** | 2 API calls — the floor |
+| + interned C strings | 185.4 | names allocated once, the Android fix, for free here |
+| + module/dict hoisted | **65.2** | 2 API calls, the floor |
| one crossing (`PyErr_Occurred`) | **2.9** | direct `@WasmImport`, real interpreter |
**75% of the naive cost is removable without shipping a single line of C.** What is left is 65 ns
-of two CPython calls, of which the crossings are 5.8 ns — 9% of the floor, 2% of the naive figure.
+of two CPython calls, of which the crossings are 5.8 ns, 9% of the floor, 2% of the naive figure.
A composed `pmp_getattr` could merge those two calls into one and save **2.9 ns**.
The bulk case ends the same way. Composition there buys the difference between a crossing and a
shared-memory read, 2.9 ns against 0.7 ns per element, and CPython does identical work either way.
-The constraint that kept the case alive — `PySequence_Fast_ITEMS` is a macro over
+The constraint that kept the case alive, `PySequence_Fast_ITEMS` is a macro over
`PyListObject->ob_item` and `abi3t` makes `PyObject` incomplete, so shared memory does *not* let
-Kotlin walk a list's storage — turns out not to matter, because N calls at 2.9 ns is cheap.
+Kotlin walk a list's storage, turns out not to matter, because N calls at 2.9 ns is cheap.
**Verdict: no composed shim on WASM.** Same answer as ROADMAP §6 gave desktop, reached the same
way, and with a wider margin: desktop's composition was worth 10%, this is worth 1–2% after the
-free wins are taken. The signatures sketched earlier — `pmp_getattr`, `pmp_exec`, `pmp_list_items`,
-`pmp_str_utf8`, `pmp_decref`, `pmp_scratch` — were motivated by amortising JS hops, and the hops
+free wins are taken. The signatures sketched earlier, `pmp_getattr`, `pmp_exec`, `pmp_list_items`,
+`pmp_str_utf8`, `pmp_decref`, `pmp_scratch`, were motivated by amortising JS hops, and the hops
are gone. What replaces them is ordinary `wasmJsMain` Kotlin: `@WasmImport` per Stable ABI
function, an interning cache for repeated names, and `Pointer` reads for everything else.
That also removes the second build pipeline. No extension module to build, version and ship
-against a specific interpreter — which was the largest recurring cost in the plan.
+against a specific interpreter, which was the largest recurring cost in the plan.
### What is still not answered
@@ -1007,15 +1007,15 @@ Read off the actual command lines, not the documentation:
| | stock CPython 3.14.2 | `pyemscripten_2026_0` |
|---|---|---|
-| Emscripten | 5.0.3 (we pinned it) | **5.0.3** — matches |
-| `-sWASM_BIGINT` | passed explicitly in `LDFLAGS_NODIST` | required; default since Emscripten 4.0 — matches |
-| `-pthread` | absent; `configure` even reports `emcc accepts -pthread... no` | prohibited — matches |
-| `-fPIC` | yes (required by `MAIN_MODULE`) | yes — matches |
-| dynamic linking | `--enable-wasm-dynamic-linking` → `-sMAIN_MODULE`; extensions built `-shared -sSIDE_MODULE=1` | `MAIN_MODULE=1` / side modules — matches |
-| **unwinding** | **nothing** — no `-fwasm-exceptions`, no `-sSUPPORT_LONGJMP=wasm` | **required at compile and link** — **DIVERGES** |
-| **static libs** | zlib, bzip2, sqlite3, libffi, mpdecimal; **no lzma, no zstd** | + lzma 5.2.2, zstd — **DIVERGES** |
-| **OpenSSL** | **absent** — "Could not build the ssl module" | shared side module, OpenSSL 1.1.1w — **DIVERGES** |
-| platform variable | `PYEMSCRIPTEN_PLATFORM_VERSION` is **not defined anywhere in CPython 3.14.2** | `packaging` reads it to emit the tag — **DIVERGES** |
+| Emscripten | 5.0.3 (we pinned it) | **5.0.3**, matches |
+| `-sWASM_BIGINT` | passed explicitly in `LDFLAGS_NODIST` | required; default since Emscripten 4.0, matches |
+| `-pthread` | absent; `configure` even reports `emcc accepts -pthread... no` | prohibited, matches |
+| `-fPIC` | yes (required by `MAIN_MODULE`) | yes, matches |
+| dynamic linking | `--enable-wasm-dynamic-linking` → `-sMAIN_MODULE`; extensions built `-shared -sSIDE_MODULE=1` | `MAIN_MODULE=1` / side modules, matches |
+| **unwinding** | **nothing**, no `-fwasm-exceptions`, no `-sSUPPORT_LONGJMP=wasm` | **required at compile and link**, **DIVERGES** |
+| **static libs** | zlib, bzip2, sqlite3, libffi, mpdecimal; **no lzma, no zstd** | + lzma 5.2.2, zstd, **DIVERGES** |
+| **OpenSSL** | **absent**, "Could not build the ssl module" | shared side module, OpenSSL 1.1.1w, **DIVERGES** |
+| platform variable | `PYEMSCRIPTEN_PLATFORM_VERSION` is **not defined anywhere in CPython 3.14.2** | `packaging` reads it to emit the tag, **DIVERGES** |
So the stock Tier 3 build is close but cannot claim the tag, and the gap is real work rather than a
flag flip: the unwinding ABI has to be added at both compile and link, `lzma`/`zstd`/OpenSSL have to
@@ -1023,7 +1023,7 @@ be built as ports or side modules, and something has to define `PYEMSCRIPTEN_PLA
A practical trap for whoever does it: the driver hardcodes `CFLAGS=-DPY_CALL_TRAMPOLINE
-sUSE_BZIP2` in its `configure` argv and appends user arguments *after*, and autoconf takes the
-last assignment — so passing `CFLAGS=…` to add `-fwasm-exceptions` silently drops
+last assignment, so passing `CFLAGS=…` to add `-fwasm-exceptions` silently drops
`-DPY_CALL_TRAMPOLINE`. The full string has to be repeated.
**Not verified:** that the ABI-matching build succeeds, or that a PyPI `pyemscripten_2026_0` wheel
@@ -1031,13 +1031,13 @@ loads into it. Only the stock build was run.
### Environment note
-Installing Emscripten 5.0.3 through `emsdk install` **replaces `~/emsdk/upstream` in place** — the
+Installing Emscripten 5.0.3 through `emsdk install` **replaces `~/emsdk/upstream` in place**, the
previously active 6.0.6 now reports as not installed and would have to be re-downloaded. The active
version is a global, shared setting, not per-project.
---
-## Proven: the tag claim is real — a PyPI `pyemscripten_2026_0` wheel loads
+## Proven: the tag claim is real, a PyPI `pyemscripten_2026_0` wheel loads
The section above listed four divergences between the stock Tier 3 build and
`pyemscripten_2026_0` and marked the whole thing "not verified". It has now been built and
@@ -1063,8 +1063,8 @@ linked.
| | matched? | did it gate the wheel? |
|---|---|---|
-| unwinding ABI | **yes, this build** | **yes — this was the whole gate** |
-| `PYEMSCRIPTEN_PLATFORM_VERSION` | **yes, via a one-line CPython patch** | no — gates *claiming* the tag, not loading |
+| unwinding ABI | **yes, this build** | **yes, this was the whole gate** |
+| `PYEMSCRIPTEN_PLATFORM_VERSION` | **yes, via a one-line CPython patch** | no, gates *claiming* the tag, not loading |
| lzma, zstd | still missing | no |
| OpenSSL | still missing | no |
@@ -1092,7 +1092,7 @@ cpython314/python.wasm no tag section, exports neither
```
So `-fwasm-exceptions -sSUPPORT_LONGJMP=wasm` is not a nice-to-have that affects code
-generation quality — it is a link-level contract, and it is checked. `lzma`/`zstd`/OpenSSL are
+generation quality, it is a link-level contract, and it is checked. `lzma`/`zstd`/OpenSSL are
ABI-sensitive in the Pyodide flag list but only bind wheels that link *them*; they cost the
stdlib modules `_lzma`, `_zstd`, `_hashlib`, `_ssl` and nothing else here.
@@ -1115,7 +1115,7 @@ def _emscripten_platforms():
yield f"pyemscripten_{v}_wasm32"
```
-Before defining it, the interpreter loads the wheel but cannot claim the tag — its platform tag
+Before defining it, the interpreter loads the wheel but cannot claim the tag, its platform tag
is `emscripten_5_0_3_wasm32` and no `pyemscripten_*` tag is produced at all.
The obvious way to define it is a Makefile variable, because that is what `sysconfig
@@ -1127,10 +1127,10 @@ int('2026_0') == 20260 int('2025_0') == 20250 int('2024_0') == 20240
```
so the config var comes out as the integer `20260` and `packaging` emits
-`pyemscripten_20260_wasm32` — a plausible-looking tag that matches nothing. Every version in
+`pyemscripten_20260_wasm32`, a plausible-looking tag that matches nothing. Every version in
the PEP's series has this shape, so it is not specific to 2026.
-`sysconfig` already has the opt-out — `_ALWAYS_STR`, which holds the two Apple deployment
+`sysconfig` already has the opt-out, `_ALWAYS_STR`, which holds the two Apple deployment
targets for the same reason. Adding one name to it is the whole fix:
```python
@@ -1154,7 +1154,7 @@ mechanism a packager would naturally reach for corrupts it without any error.
### What this changes for the plan
-The earlier decision — our own Emscripten CPython 3.14, no Pyodide dependency — stands, and the
+The earlier decision, our own Emscripten CPython 3.14, no Pyodide dependency, stands, and the
recorded limitation shrinks again. "Compiled third-party extensions are a known limitation on
this platform" is now false for anything published under the PEP 783 tag. The remaining
limitation is narrower and ordinary: packages that need `lzma`, `zstd` or OpenSSL need those
@@ -1164,8 +1164,8 @@ built, and packages not published for the tag still have to be built.
## Upcalls: measured, and better than every prediction in this document
-§5 and §7 step 3 specify one mechanism — `@JsExport` the trampoline, wrap it in a JS closure,
-hand it to `addFunction` — and "Unresolved" writes it off as "goes through JS, unmeasured, and
+§5 and §7 step 3 specify one mechanism, `@JsExport` the trampoline, wrap it in a JS closure,
+hand it to `addFunction`, and "Unresolved" writes it off as "goes through JS, unmeasured, and
likely worse than every other platform". Tests E and F measure it, and measure an alternative
this document does not consider.
@@ -1180,8 +1180,8 @@ String(raw.kotlin_upcall_add) -> function 5097() { [native code] }
`WebAssembly.Table.prototype.set` accepts any exported wasm function whatever instance produced
it, so it can go straight into Emscripten's `__indirect_function_table`, and Emscripten's
-`call_indirect` reaches it directly. JS appears **once, at registration** — to put a funcref in
-a table — and never in a call.
+`call_indirect` reaches it directly. JS appears **once, at registration**, to put a funcref in
+a table, and never in a call.
```
table.grow(1) -> 2; table.set(2, kotlin_upcall_add) accepted
@@ -1192,16 +1192,16 @@ Test E, 10,000,000 indirect calls issued from inside C so no JS is in the timed
| | ns/call | crossing |
|---|---|---|
-| C → C (control, same module) | 0.7 | — |
+| C → C (control, same module) | 0.7 | - |
| **C → Kotlin, `table.set`** | **3.1** | **2.4 ns** |
| C → Kotlin, `addFunction` + JS closure (the design's mechanism) | 10.9 | 10.2 ns |
The design's mechanism is **3.5x** the direct one. And the direct upcall at ~3 ns is the mirror
-of the direct downcall at ~5 ns — the two directions cost the same order, which is the thing
+of the direct downcall at ~5 ns, the two directions cost the same order, which is the thing
this document assumed could not be true.
`addFunction` handed the wasm export directly works too, and deduplicates onto the entry
-`table.set` already installed — so it is the same path, not a third one.
+`table.set` already installed, so it is the same path, not a third one.
### Re-entrancy holds: the WasmGC heap is live inside a frame CPython pushed
@@ -1244,7 +1244,7 @@ type(kotlin_cb).__name__ = "builtin_function_or_method"
Python sees an ordinary builtin. Variadic arguments arrive intact, 1000 calls in a Python loop
all return correctly, and a bad argument raises rather than corrupting anything. The return
-value is built by Kotlin calling `PyLong_FromLong` — **a downcall issued from inside an
+value is built by Kotlin calling `PyLong_FromLong`, **a downcall issued from inside an
upcall**, so both directions are live on one stack.
### But CPython's own wasm trampoline never installs, and that is an upstream defect
@@ -1269,11 +1269,11 @@ It was called. **The JS fallback is live**, and instrumenting the swallowed `cat
```
The `EM_JS` initialiser runs while `python.mjs` is still evaluating, and needs `wasmTable` and
-`wasmMemory` — which in this configuration are **exports** of `python.wasm`, assigned only after
+`wasmMemory`, which in this configuration are **exports** of `python.wasm`, assigned only after
instantiation. The `LinkError` is swallowed by a bare `catch (e) {}` and the JS fallback is kept
silently.
-This is an instantiation cycle, not an ordering slip — the same shape as the one Kotlin's memory
+This is an instantiation cycle, not an ordering slip, the same shape as the one Kotlin's memory
import dissolved, pointing the other way. The trampoline module imports `env.memory` and
`env.__indirect_function_table` from the very module whose import object needs the trampoline.
`-sIMPORTED_MEMORY` was tried: it fixes the memory half and the failure moves to the table,
@@ -1288,22 +1288,22 @@ and Emscripten has no imported-table setting to finish the job. So on this toolc
wasm trampoline cannot install at all.
**The consequence is symmetric, which is why it does not change the design.** Every
-`PyCFunction` call in the build crosses a JS frame — a C extension's exactly as much as
+`PyCFunction` call in the build crosses a JS frame, a C extension's exactly as much as
Kotlin's. Measured like for like, `abs()` (a C builtin declared `METH_O`) against a Kotlin
`METH_O` function with the identical wasm signature:
| | ns/call |
|---|---|
| pure Python `def py_cb(x): return x+1` | 59 |
-| C builtin `abs(x)` — `METH_O` | 73 |
-| **Kotlin `kotlin_cb_o(x)` — `METH_O`** | **87** |
+| C builtin `abs(x)`, `METH_O` | 73 |
+| **Kotlin `kotlin_cb_o(x)`, `METH_O`** | **87** |
| | **14 ns attributable to the crossing** |
| Kotlin, `METH_VARARGS` | 161 |
| Kotlin, `METH_VARARGS` + tuple read | 183 |
The `METH_VARARGS` rows carry CPython's argument-tuple construction; that cost is identical for
a C extension and is not the crossing. 14 ns against `abs` matches Test E's `addFunction`
-figure, as it should — both are the JS-frame path.
+figure, as it should, both are the JS-frame path.
### What this means for §7
@@ -1312,8 +1312,8 @@ figure, as it should — both are the JS-frame path.
| iOS | a plain function call |
| Android | a JNI callback |
| desktop | a Panama upcall stub |
-| **WASM, slot reached by `call_indirect`** | **a plain `call_indirect` — ~3 ns** |
-| **WASM, `PyMethodDef` today** | the same JS trampoline every C extension pays — ~14 ns over C |
+| **WASM, slot reached by `call_indirect`** | **a plain `call_indirect`, ~3 ns** |
+| **WASM, `PyMethodDef` today** | the same JS trampoline every C extension pays, ~14 ns over C |
**§7's design holds on WASM, and the caveat it carries is wrong.** "WASM will have permanently
higher per-call overhead than Native/JVM due to the WasmGC ↔ JS ↔ Emscripten boundary" describes
@@ -1342,11 +1342,11 @@ Nothing structural. `wasmJsMain` can be written against §7 as amended. What is
toolchain one.
- ~~Whether Kotlin/Wasm offers a GC hook equivalent to `Cleaner`/`createCleaner`.~~ **Answered.**
The stdlib does not, but the *host* does, and `toJsReference()` reaches it without pinning the
- object it hands over — measured at `alive 0 / 200` with JS holding only a `WeakRef` and a
+ object it hands over, measured at `alive 0 / 200` with JS holding only a `WeakRef` and a
`FinalizationRegistry` entry. `registerCleaner` is built on that. The second finding cost more:
a collection is observable only across a **macrotask** boundary, because a `WeakRef` outlives the
job that created it and the registry callback is delivered as a task, and
- `FinalizationRegistry.prototype.cleanupSome()` — which would have made it synchronous — has been
+ `FinalizationRegistry.prototype.cleanupSome()`, which would have made it synchronous, has been
removed from V8. See `src/wasmJsMain/README.md` and ROADMAP §10 for the numbers.
diff --git a/docs/design/architecture.md b/docs/design/architecture.md
index db08d8c25..a2bbb3ca4 100644
--- a/docs/design/architecture.md
+++ b/docs/design/architecture.md
@@ -31,7 +31,7 @@ cinterop 어느 쪽과도 겹치지 않는다), 바로 아래에 적힌 `expect
크래시가 난다.** 마이그레이션 대상 함수에서 `inline` 을 떼면 해결된다. `EmbedAPI.kt` 의 `inline` 중
다수는 컴파일러가 이미 "인라인 이득 없음" 경고를 내고 있어, 떼는 데 실질적 손해가 없다. (크래시는
Kotlin 2.0.20 에서 관찰됐다. 빌드는 이후 Kotlin 2.4.20-Beta2 로 올라갔고(`gradle/libs.versions.toml`),
-이 크래시를 그 버전에서 다시 확인하지는 않았다 — 그래서 AGENTS.md §16 은 여전히 JVM 계열 통합 전에
+이 크래시를 그 버전에서 다시 확인하지는 않았다, 그래서 AGENTS.md §16 은 여전히 JVM 계열 통합 전에
확인하라고 한다.)
## FFI 계층
@@ -58,7 +58,7 @@ desktop·native 와 동일하다.
### CPython C 매크로는 바인딩할 수 없다
`PyLong_Check` 계열은 헤더의 `#define` 이라 심볼로 export 되지 않는다. `nm` 으로 확인했다. 그래서
-`ffi/PyTypeChecks.kt` 가 common Kotlin 으로 11종을 구현한다 — `PyObject_IsInstance` 와 캐시된 타입
+`ffi/PyTypeChecks.kt` 가 common Kotlin 으로 11종을 구현한다, `PyObject_IsInstance` 와 캐시된 타입
객체를 쓰며, `expect`/`actual` 분기가 없으므로 네 플랫폼 동작이 구조적으로 동일하다.
## 타입 래퍼 계층
@@ -100,8 +100,8 @@ Kotlin 제약 때문에 다이어그램대로 갈 수 없던 지점들:
## 검증 수단
명령 목록과 어떤 소스셋을 덮는지는 AGENTS.md §15 가 기준이다 (`compileKotlinAndroidNativeArm64`,
-`compileKotlinIosSimulatorArm64`, `compileKotlinDesktop`, 그리고 `ksp-fixtures` 세 모듈을 각각 따로).
-테스트는 컴파일 검증만이 아니다 — 데스크톱(`desktopTest`), iOS 시뮬레이터, 안드로이드 기기, wasm(Node)
+`compileKotlinIosSimulatorArm64`, `compileKotlinDesktop`, 그리고 `fixtures/` 아래 세 모듈을 각각 따로).
+테스트는 컴파일 검증만이 아니다, 데스크톱(`desktopTest`), iOS 시뮬레이터, 안드로이드 기기, wasm(Node)
에서 실제 CPython 을 구동하는 스위트가 있다 (SPEC §0 의 "Test path" 열).
`Python.framework` 에는 표준 라이브러리가 없어 `Py_Initialize()` 가
@@ -128,7 +128,7 @@ libpython3.14
**The object model must never reference `bindings`.** `PyObject` and friends call the EmbedAPI
functions in `python.native.ffi` and nothing below them. `bindings` is an implementation
-detail that only `EmbedAPI..kt` is allowed to know about — it does not even exist on
+detail that only `EmbedAPI..kt` is allowed to know about, it does not even exist on
the native targets, which reach CPython through cinterop instead.
Concretely, this is correct:
@@ -149,8 +149,8 @@ fun getAttr(name: String) = PyObject(bindings.asmGetAttr(pointer.raw(), name))
### Consequence for composed operations
-A composed call — one that does a whole binder operation natively and crosses the boundary
-once — is still a platform implementation difference, so it belongs in the FFI layer, not in
+A composed call, one that does a whole binder operation natively and crosses the boundary
+once, is still a platform implementation difference, so it belongs in the FFI layer, not in
the object model:
```kotlin
@@ -206,7 +206,7 @@ expect abstract class PyAutoCloseable(pointer: NativePointer) {
the base held a hardcoded (and commented-out) `//PyDecRef(this.pointer)`; after, it calls
`clean()` and `PyObject` supplies it.
-**3. Split the single `jvmMain` implementation into per-platform leaves** — and this fixed a
+**3. Split the single `jvmMain` implementation into per-platform leaves**, and this fixed a
latent crash. `jvmMain`'s version called `Cleaner.create()` unconditionally, and Android
inherited it while minSdk was 24. `java.lang.ref.Cleaner` does not exist below API 33, so
every `PyObject` construction on API 24-32 would have thrown `NoClassDefFoundError`. The new
@@ -215,7 +215,7 @@ every `PyObject` construction on API 24-32 would have thrown `NoClassDefFoundErr
**4. As a consequence, `PyObject` stopped being `expect`/`actual`.** Once the base class was
expressible in `commonMain`, `PyObject` could inherit it there and finally have real method
-bodies — which is how the commented logic became code.
+bodies, which is how the commented logic became code.
| platform | lifetime mechanism after the split |
|---|---|
@@ -229,17 +229,17 @@ bodies — which is how the commented logic became code.
The split is sound and stays. What was lost was incidental: `PyObject.android.kt` had been the
only place an `actual external fun` could go, and that was the hook for routing an operation
-through a single composed JNI call. Measurement later put a number on it — 5.5x on one
+through a single composed JNI call. Measurement later put a number on it, 5.5x on one
`getAttr`, 11x on a 1000-element list conversion. Recovering it does not mean reverting the
commit; it means expressing composed operations in the FFI layer, as above. (The `PyObject_GetAttrComposed`
snippet is illustrative, not a function in the tree. For string-carrying calls, interning later beat
-composition and replaced it — `marshalling-design.md`; composition remains only where crossing count
+composition and replaced it, `marshalling-design.md`; composition remains only where crossing count
scales with N.)
## Test layering: low-level and assembled
Tests are split by which layer they exercise, and both layers must be covered on every
-platform. This is not organisational tidiness — the gap between them hid a defect for the whole
+platform. This is not organisational tidiness, the gap between them hid a defect for the whole
of the object model's life.
**Low-level** tests call EmbedAPI functions one at a time: does this function return what its C
@@ -254,7 +254,7 @@ Android, everything under `androidInstrumentedTest` that reaches through `bindin
### Why both, everywhere
At the time, `commonTest` was entirely assembled and ran only on iOS and desktop. Android's
-instrumented tests were entirely low-level (today `commonTest` also runs on Android and wasm; SPEC §0). So no test anywhere called the real object model on a device —
+instrumented tests were entirely low-level (today `commonTest` also runs on Android and wasm; SPEC §0). So no test anywhere called the real object model on a device,
and it turned out `Python3.exec` crashes the process on its first call there, because several
functions it reaches still take a Kotlin `String` straight across JNI.
@@ -276,5 +276,5 @@ afterwards. Go through `PythonTestFixture`, which initialises once per process.
**Read test counts from a cleaned results directory.** Gradle leaves XML from previous runs in
place, so a crashed run can report the previous run's numbers. A suite that "passes 111 tests"
-while crashing is what stale XML looks like — delete `build/test-results//` when a
+while crashing is what stale XML looks like, delete `build/test-results//` when a
count needs to be trusted.
diff --git a/docs/design/binding-policy.md b/docs/design/binding-policy.md
index 9603149ec..299aa210e 100644
--- a/docs/design/binding-policy.md
+++ b/docs/design/binding-policy.md
@@ -1,4 +1,4 @@
-# 노출 정책 — 무엇이 Python 에서 보이는가
+# 노출 정책, 무엇이 Python 에서 보이는가
Kotlin 의 무엇을 함수 테이블에 등록해 Python 에 노출할지에 대한 규칙. 테이블 자체의 구조는
[`upcall.md`](upcall.md).
@@ -6,7 +6,7 @@ Kotlin 의 무엇을 함수 테이블에 등록해 Python 에 노출할지에
이 문서의 표는 **KSP 프로세서가 소스에서 읽는 선언**(`python-multiplatform-ksp` 의 `BindingPolicy.kt`)에
대한 규칙이다. 미리 컴파일된 jar 를 읽는 아티팩트 워커(`python-multiplatform-gradle-plugin`)는
같은 블랙리스트 정신을 따르되, 소스 없이 바이트코드/Kotlin 메타데이터에서 읽으므로 일부 행이
-다르다 — 아래 해당 행과 §"결정 이력" 에 표시했다. Kotlin 이름은 어느 쪽이든 그대로 노출되며
+다르다, 아래 해당 행과 §"결정 이력" 에 표시했다. Kotlin 이름은 어느 쪽이든 그대로 노출되며
(AGENTS.md §12.1) 이 정책에는 이름을 바꾸는 규칙이 없다.
## 원칙: 블랙리스트
@@ -44,15 +44,15 @@ class InternalUtility { } // 클래스 통째로 제외
| **companion object 멤버** | ✅ 클래스의 정적 메서드처럼 노출 | companion 객체 자체를 따로 노출할 필요는 없다 |
| **`object` 선언** | ✅ companion 과 같은 모양 | 인스턴스가 하나뿐이므로 수신자가 없다. 생성자는 없다 |
| **`interface`** | ✅ 멤버만 | 인스턴스는 없지만, 핸들로 건너온 Kotlin 객체를 인터페이스를 통해 호출할 수 있다. 생성자 없음 |
-| **`enum class`** | ✅ 엔트리마다 정적 접근자 + `name`/`ordinal`/`valueOf` | 엔트리 이름은 `ReflectedClass.enumEntryNames` 로 함께 나간다 — Python 쪽 `enum.Enum` 미러의 재료 |
+| **`enum class`** | ✅ 엔트리마다 정적 접근자 + `name`/`ordinal`/`valueOf` | 엔트리 이름은 `ReflectedClass.enumEntryNames` 로 함께 나간다, Python 쪽 `enum.Enum` 미러의 재료 |
| **중첩 선언** | ✅ `Outer.Inner` 이름으로 | 노출되지 않는 바깥 선언에서 탐색이 멈춘다 |
| **`annotation class`** | ❌ | 애노테이션을 읽으려면 런타임 리플렉션이 필요하고, Python 이 만든 인스턴스는 붙일 곳이 없다 |
| **enum 의 `values()` / `entries`** | ❌ | 컬렉션을 돌려주는데 경계에 컬렉션 마샬링이 없다 |
| **제네릭 선언** (`class Box`, `fun f`) | ❌ | 생성된 캐스트가 이름 붙일 타입이 없다: `args[0] as T` 는 unresolved reference |
-| **abstract / sealed 클래스의 생성자** | ❌ | "Cannot create an instance of an abstract class" — 생성된 파일이 컴파일되지 않는다 |
+| **abstract / sealed 클래스의 생성자** | ❌ | "Cannot create an instance of an abstract class", 생성된 파일이 컴파일되지 않는다 |
| `private` / `protected` | ❌ | |
| **`internal`** | ❌ | JVM 바이트코드에서는 name-mangling 된 public 이지만 의미론적으로는 비공개다. KSP 의 `Modifier.INTERNAL` 로 걸러낸다 |
-| **확장 함수 (KSP, 소스)** | ❌ | 생성되는 엔트리는 `Array` 위의 람다이고 수신자를 둘 자리가 없다 (`BindingPolicy.isExposedFunctionShape` 이 `extensionReceiver != null` 을 거른다). 아티팩트 워커는 다르다 — 아래 |
+| **확장 함수 (KSP, 소스)** | ❌ | 생성되는 엔트리는 `Array` 위의 람다이고 수신자를 둘 자리가 없다 (`BindingPolicy.isExposedFunctionShape` 이 `extensionReceiver != null` 을 거른다). 아티팩트 워커는 다르다, 아래 |
| **확장 함수 (아티팩트 워커, 컴파일된 jar)** | ✅ 수신자 프록시의 메서드로 | `Modifier.padding(16).size(24)` 처럼 체인이 이어진다 (SPEC U-7, `docs/design/kotlin-extensions-in-python.md`) |
| **소스 안의 `@Composable`** | ❌ (KSP) | 생성된 호출이 `@Composable` 컨텍스트 밖이라 컴파일되지 않는다. 컴파일된 jar 의 composable 은 워커가 바인딩한다 (SPEC B-6, `docs/design/ecosystem.md` §4) |
@@ -68,8 +68,8 @@ class InternalUtility { } // 클래스 통째로 제외
| 대상 | 처리 | 근거 |
|---|---|---|
-| **`suspend` 함수** | **노출한다** — 비동기 호출 규약으로 | 생성 본문이 `PendingCall.start { }` 로 코루틴을 시작하고, 경계는 값(중단 없이 끝남) 또는 `asyncio.Future`(중단됨)를 돌려준다. Python 에서 `await` 한다 (SPEC U-5, [`upcall.md`](upcall.md)). 처음에는 "자동 제외"였다 — 아래 정정 참조. `suspend` **함수 타입** 값(`suspend (Long) -> Long`)은 불투명 `OBJECT` 핸들로 나간다 |
-| **`inline` + `reified`** | 자동 제외 | 컴파일 후 reified 타입 정보가 사라진다. **실제 규칙은 이보다 넓다** — 타입 파라미터가 있는 선언은 `reified` 여부와 무관하게 전부 제외한다 |
+| **`suspend` 함수** | **노출한다**, 비동기 호출 규약으로 | 생성 본문이 `PendingCall.start { }` 로 코루틴을 시작하고, 경계는 값(중단 없이 끝남) 또는 `asyncio.Future`(중단됨)를 돌려준다. Python 에서 `await` 한다 (SPEC U-5, [`upcall.md`](upcall.md)). 처음에는 "자동 제외"였다, 아래 정정 참조. `suspend` **함수 타입** 값(`suspend (Long) -> Long`)은 불투명 `OBJECT` 핸들로 나간다 |
+| **`inline` + `reified`** | 자동 제외 | 컴파일 후 reified 타입 정보가 사라진다. **실제 규칙은 이보다 넓다**, 타입 파라미터가 있는 선언은 `reified` 여부와 무관하게 전부 제외한다 |
| **값 클래스(value class)** | KSP: 별도 규칙 없음(`BindingPolicy.kt` 에 값 클래스 분기가 없다). 아티팩트 워커: 왕복을 지원하고, 허용 목록의 것(예: `Dp`)만 원시 값으로 쓸 수 있다 | JVM 에서 언박싱된 형태로 노출되어 케이스가 갈린다 (SPEC B-4, `ComposableValueClassSlotTest.kt`) |
`suspend` 와 `reified` 는 KSP 에서 선언 수준으로 판별되므로 걸러내는 비용이 낮다.
@@ -77,19 +77,19 @@ class InternalUtility { } // 클래스 통째로 제외
## 결정 이력
블랙리스트 원칙은 설계 논의에서 직접 확정됐다. 세부 규칙(가시성, 확장 함수, 매핑 불가 타입, 애노테이션
-이름)은 그 논의에서 다루지 않았고, 이후 판단으로 정한 것이다 — 구현하면서 뒤집힐 수 있다.
+이름)은 그 논의에서 다루지 않았고, 이후 판단으로 정한 것이다, 구현하면서 뒤집힐 수 있다.
특히 다음 둘은 재고 여지가 있다:
-- **확장 함수 제외 (KSP)** — 소스 경로에서는 제외 상태 그대로다. 다만 이것이 풀린 것은 아티팩트
+- **확장 함수 제외 (KSP)**: 소스 경로에서는 제외 상태 그대로다. 다만 이것이 풀린 것은 아티팩트
워커 쪽이다: 확장 함수는 수신자 프록시의 메서드로 노출된다(SPEC U-7). KSP 가 생성한 프록시에서의
동작은 아직 테스트로 단언되지 않았다.
-- **값 클래스** — 초안의 "등록하고 실패하게 둔다"는 구현되지 않았다. 워커가 값 클래스를 다루고
+- **값 클래스**: 초안의 "등록하고 실패하게 둔다"는 구현되지 않았다. 워커가 값 클래스를 다루고
(SPEC B-4), KSP 는 따로 걸러내지 않는다.
## 구현 시점의 정정 (2026-08)
-위 표 중 두 줄은 구현하면서 넓어졌고, 그 근거는 **생성된 파일이 컴파일되지 않은 것**이다 —
+위 표 중 두 줄은 구현하면서 넓어졌고, 그 근거는 **생성된 파일이 컴파일되지 않은 것**이다,
프로세서가 스스로 알아챌 수 있는 종류의 오류가 아니었다.
- **제네릭은 `reified` 뿐 아니라 전부 제외한다.** 엔트리는 `Array` 위의 람다이므로 모든
@@ -104,6 +104,6 @@ class InternalUtility { } // 클래스 통째로 제외
자동 제외했다. 비동기 업콜(`PendingCall`)이 생긴 뒤 이 수식어는 제외가 아니라 **본문 형태를
고르는 신호**가 되었다 (`BindingPolicy.isSuspending`, `GeneratedSuspendTest`).
- **추상·봉인 클래스는 클래스로는 노출하되 생성자만 뺀다** (`BindingPolicy.isConstructible`; `inner`
- 클래스도 같다 — 바깥 인스턴스를 둘 자리가 없다). 표의 "abstract / sealed 클래스의 생성자" 행의 구현.
+ 클래스도 같다, 바깥 인스턴스를 둘 자리가 없다). 표의 "abstract / sealed 클래스의 생성자" 행의 구현.
- **`internal set` 은 노출하지 않는다.** `private set` 은 생성 코드가 컴파일되지 않아서, `internal set`
은 컴파일은 되지만 노출 대상이 모듈 공개 API 이기 때문이다 (`BindingPolicy.isExposedSetter`).
diff --git a/docs/design/downcall-design.md b/docs/design/downcall-design.md
index fce470e97..549dfdf1f 100644
--- a/docs/design/downcall-design.md
+++ b/docs/design/downcall-design.md
@@ -1,4 +1,4 @@
-# 다운콜 설계 — Kotlin → Python
+# 다운콜 설계, Kotlin → Python
Kotlin 코드가 CPython Stable ABI 함수를 호출하는 방향. 반대 방향은
[`upcall.md`](upcall.md).
@@ -8,7 +8,7 @@ Kotlin 코드가 CPython Stable ABI 함수를 호출하는 방향. 반대 방향
> `JAVA_LONG`), Android 는 `RegisterNatives` 로 등록한 JNI (함수별·API 레벨별 호출 규약), Native 는
> cinterop. 아래 "shape 트램폴린" 14종은 **어휘(vocabulary)로 구현되어 있으나**(`jvmMain/.../ShapeDowncalls.kt`
> 의 `expect` + 데스크톱·Android·Native 구현) 330개 `actual` 을 `jvmMain` 한 벌로 합치는 통합에는
-> **쓰이지 않았다** — 소스에서 shape 함수를 부르는 곳은 데스크톱 `ProxyTypeFactory` 의 `tp_traverse`/
+> **쓰이지 않았다**, 소스에서 shape 함수를 부르는 곳은 데스크톱 `ProxyTypeFactory` 의 `tp_traverse`/
> `tp_free` 호출 두 곳뿐이다. 이 문서의 shape 설계 절은 그 계획과 근거의 기록이다.
## 시그니처 shape 조사 결과
@@ -28,7 +28,7 @@ Kotlin 코드가 CPython Stable ABI 함수를 호출하는 방향. 반대 방향
부동소수점을 쓰는 함수는 `PyLong_FromDouble`, `PyLong_AsDouble`, `PyFloat_FromDouble`,
`PyFloat_AsDouble`, `PyFloat_GetMax`, `PyFloat_GetMin` 여섯 개가 전부다.
-shape 를 가르는 기준은 arm64(AAPCS64)와 x86-64(SysV) 모두에서 같다 — **정수·포인터가 한 레지스터
+shape 를 가르는 기준은 arm64(AAPCS64)와 x86-64(SysV) 모두에서 같다, **정수·포인터가 한 레지스터
뱅크, 부동소수점이 다른 뱅크**를 쓰므로, (정수형 인자 수, 부동형 인자 수, 반환 종류) 가 shape 다.
혼합 시그니처가 0개라 트램폴린의 C 프로토타입이 단순해진다.
@@ -36,11 +36,11 @@ shape 를 가르는 기준은 arm64(AAPCS64)와 x86-64(SysV) 모두에서 같다
## 플랫폼별 메커니즘
-### iOS / androidNative — cinterop
+### iOS / androidNative, cinterop
Kotlin/Native 가 헤더를 읽어 직접 호출 코드를 낸다. **오버헤드가 없고 손댈 것도 없다.** 이미 최적이다.
-### Desktop — Panama
+### Desktop, Panama
`desktopMain/.../Panama.kt` 가 `java.lang.foreign`(JDK 22+, 19~21 은 preview)과
`jdk.incubator.foreign`(JDK 16~18)을 **둘 다 리플렉션으로만** 접근한다. 어느 API 도 직접 import 하지
@@ -55,7 +55,7 @@ Kotlin/Native 가 헤더를 읽어 직접 호출 코드를 낸다. **오버헤
`.invoke(` 는 0곳이다 (AGENTS.md §16: 무조건 `invokeExact`). 리플렉션은 핸들을 **만들 때** 심볼당 한 번만
돈다. `Linker.Option.critical()` 은 쓰지 않는다 (`downcallHandle(..., emptyOptions)`).
-### Android(ART) — shape 트램폴린 (구현됨, 단 주 경로는 아니다)
+### Android(ART), shape 트램폴린 (구현됨, 단 주 경로는 아니다)
ART 는 런타임에 임의 시그니처의 네이티브 호출을 만들 수 없다. 그래서 shape 마다 트램폴린을 **미리
컴파일**해두고, 대상 함수 포인터를 첫 인자로 받는다:
@@ -80,12 +80,12 @@ fun downcallJ_J(fn: Long, a0: Long): Long =
(`androidMain/README.md`). shape 트램폴린은 `ffiSymbol` 로 얻은 임의 주소를 부르는 일반 수단으로
남아 있다.
-**LLVM JIT 은 필요 없다 — 다만 근거는 아래와 같다.**
+**LLVM JIT 은 필요 없다, 다만 근거는 아래와 같다.**
ART 에는 FFM 을 위한 VM 지원이 없다. HotSpot 은 `downcallHandle` 호출 시 VM 이 스텁을 기계어로
생성해 주지만, ART 는 해 주지 않는다. 그래서 임의의 `FunctionDescriptor` 에 대해 인자를 레지스터에
배치하고 대상을 호출하는 기계어를 **누군가는 만들어야 한다.** PanamaPort 는 그것을 런타임에
-`libLLVM.so` 로 만든다 — `_AndroidLinkerImpl.generateNativeDowncallStub`(292행)이 다운콜 경로에서
+`libLLVM.so` 로 만든다, `_AndroidLinkerImpl.generateNativeDowncallStub`(292행)이 다운콜 경로에서
**조건 없이** 호출되며(938행), 업콜도 `generateNativeUpcallStub`(954행)으로 같다.
주목할 점은 그 생성 스텁도 **대상 함수 포인터를 첫 인자로 받는다**는 것이다:
@@ -96,7 +96,7 @@ stub_descriptor = stub_descriptor.insertArgumentLayouts(0, WORD); // leading fu
```
우리 `downcallII_I(fn, a0, a1)` 과 같은 구조다. 차이는 **생성 시점**뿐이다 (PanamaPort 는 의존성으로 쓰지
-않는다 — AGENTS.md §12.9. 아래는 설계 근거로 읽은 분석이다; [`android-ffm-design.md`](../archive/android-ffm-design.md)):
+않는다, AGENTS.md §12.9. 아래는 설계 근거로 읽은 분석이다; [`android-ffm-design.md`](../archive/android-ffm-design.md)):
| | PanamaPort | 이 프로젝트 |
|---|---|---|
@@ -109,10 +109,10 @@ stub_descriptor = stub_descriptor.insertArgumentLayouts(0, WORD); // leading fu
(이전 판에서 `BulkLinker.requireNativeStub` 이 `false` 를 반환하므로 참조 구현도 스텁 없이 직접
패치한다고 서술했으나 이는 오독이었다. `BulkLinker` 는 `Unsafe` 모듈에 있는 PanamaPort 자체
-부트스트랩 수단이며 — LLVM API 같은 내부 네이티브 함수를 바인딩한다 — `Core` 의 Linker 경로에서는
+부트스트랩 수단이며, LLVM API 같은 내부 네이티브 함수를 바인딩한다, `Core` 의 Linker 경로에서는
쓰이지 않는다.)
-## JVM 통합 — Desktop + Android 를 한 벌로 (계획; 미수행)
+## JVM 통합, Desktop + Android 를 한 벌로 (계획; 미수행)
계획은 이랬다. 당시 JVM 쪽은 330개 `actual` 이 **두 벌** 있고 플랫폼별 `bindings` 선언까지 더하면 손으로
유지하는 선언이 1,300개에 가까웠다. shape 를 도입하면 플랫폼별 표면이 14개로 줄고 330개 `actual` 은
@@ -147,14 +147,14 @@ Native Image 는 **FFM 다운콜을 빌드 타임에 등록**해야 한다. `ref
모든 `FunctionDescriptor` 가 그 메타데이터에 선언되어 있는지는 `ReachabilityMetadataTest.kt` 가 지킨다
(SPEC C-2). `JAVA_LONG`/shape 로 시그니처 종류가 한정되는 것이 등록을 단순하게 한다.
-이것은 더 이상 "우선순위 낮음"이 아니다 — 업콜이 네이티브 이미지에서 동작해야 한다는 것은 이 저장소의
+이것은 더 이상 "우선순위 낮음"이 아니다, 업콜이 네이티브 이미지에서 동작해야 한다는 것은 이 저장소의
결정이다 (AGENTS.md §13). 절차와 마지막 검증 기록은
[`graal-native-image-verification.md`](../platforms/graal-native-image-verification.md) (SPEC U-6: 수동 검증,
이미지 자체를 테스트하는 자동화는 아직 없음).
## 스레드 상태
-다운콜은 전부 GIL(또는 free-threaded 빌드의 스레드 상태)을 요구한다 — `withGIL { }`
+다운콜은 전부 GIL(또는 free-threaded 빌드의 스레드 상태)을 요구한다, `withGIL { }`
(`python/multiplatform/ffi/GILScope.kt`). [`threading-and-abi.md`](threading-and-abi.md) 를 본다.
---
@@ -168,7 +168,7 @@ Native Image 는 **FFM 다운콜을 빌드 타임에 등록**해야 한다. `ref
초기에는 `@CName` 으로 만든 C 함수(`JNIEnv`/`jclass` 를 받지 않음)를 일반 JNI 메서드처럼 이름으로
연결해서, ART 가 넘기는 `JNIEnv*`·`jclass` 때문에 **인자가 두 칸 밀렸다.** 지금은 `JNI_OnLoad`
-(`artMain`)가 `RegisterNatives` 로 모든 `external fun` 을 등록한다 (367개 중 363개; 예외 4개는 의도적 —
+(`artMain`)가 `RegisterNatives` 로 모든 `external fun` 을 등록한다 (367개 중 363개; 예외 4개는 의도적,
ROADMAP §2). 이름 기반 연결은 API 26 에서 런타임을 abort 시키기도 한다. 초안의 진단·표는
[`../archive/downcall-design-android-first-diagnosis.md`](../archive/downcall-design-android-first-diagnosis.md).
@@ -186,9 +186,9 @@ ROADMAP §2). 이름 기반 연결은 API 26 에서 런타임을 abort 시키기
| 함수 | critical |
|---|---|
| `PyList_Size`, `Py_IncRef`, `PyLong_FromLongLong` | 적용 가능 |
-| `PyObject_Call`, `PyRun_String`, `PyImport_ImportModule` | **불가** — Python 코드를 실행하며 Kotlin 으로 되돌아올 수 있다 |
+| `PyObject_Call`, `PyRun_String`, `PyImport_ImportModule` | **불가**, Python 코드를 실행하며 Kotlin 으로 되돌아올 수 있다 |
-가장 뜨거운 호출들이 정확히 critical 을 못 쓰는 쪽이다. 그리고 **shape 는 이를 구분하지 못한다** —
+가장 뜨거운 호출들이 정확히 critical 을 못 쓰는 쪽이다. 그리고 **shape 는 이를 구분하지 못한다**,
`downcallII_I` 하나가 `PyList_GetItem`(가능)과 `PyObject_Call`(불가)을 함께 덮는다. 시그니처가
같아도 성격이 반대다.
@@ -209,19 +209,19 @@ Desktop 과 Android 를 같은 방식으로 통일하려고 Android 에 FFM 을
Android 의 Panama 는 런타임인 척하는 라이브러리다. 같은 바닥 위에 계층이 더 있고 JIT 가 약하므로
Android 쪽이 더 빠를 수는 없다. 다만 **얼마나 다른지는 측정된 바 없다.**
-우리 desktop 구현은 현재 `Linker.Option.critical()` 을 쓰지 않는다 —
+우리 desktop 구현은 현재 `Linker.Option.critical()` 을 쓰지 않는다,
`Panama.kt` 가 `downcallHandle(..., emptyOptions)` 로 기본 경로를 탄다.
-### 조립 단위의 범위 — 정해졌다
+### 조립 단위의 범위, 정해졌다
-경계 통과가 비싸고 뜨거운 호출에는 critical 도 못 쓰므로 **통과 횟수 자체를 줄이는 것**이 접근이었다 —
+경계 통과가 비싸고 뜨거운 호출에는 critical 도 못 쓰므로 **통과 횟수 자체를 줄이는 것**이 접근이었다,
`bindings` 의 저수준 함수들을 바인더 레벨 연산으로 조립해 `artMain` 에서 내보내고 `androidMain` 은 그
굵은 단위를 한 번 부른다 (`artMain` 의 `asmGetAttr` 등; 조립 단위는 길고 업콜 가능성이 있으므로 일반
JNI, shape 계열은 짧고 콜백이 없으므로 규약을 함수별로 선택).
초안은 세 후보를 두고 결정을 미뤘다(바인더 API 를 `expect`/`actual` 로 올림 / `commonMain` 에 두고 Android
만 별도 경로 / `Python3` 수준만 조립). **택한 것은 어느 것도 아니다**: 조립은 **FFI 층의 `expect`/`actual`**
-(`EmbedAPI`)에 두고 객체 모델은 공통으로 둔다 — 객체 모델은 `bindings` 를 참조하지 않는다
+(`EmbedAPI`)에 두고 객체 모델은 공통으로 둔다, 객체 모델은 `bindings` 를 참조하지 않는다
(`architecture.md` "Consequence for composed operations", `commonMain/README.md`). 그리고 **문자열을
나르는 연산에서는 조립보다 interning 이 더 싸고 일반적**이라 그쪽으로 갔다
([`marshalling-design.md`](marshalling-design.md)); 조립이 남는 곳은 통과 횟수가 N 에 비례하는 일괄
@@ -236,8 +236,8 @@ project, and it contradicts the assumption the Android design was built on.
### Method
-`echo0`, `echoFast` and `echoNormal` are the same one-line C body — `jlong f(jlong x)
-{ return x; }` — registered three times under the three conventions
+`echo0`, `echoFast` and `echoNormal` are the same one-line C body, `jlong f(jlong x)
+{ return x; }`, registered three times under the three conventions
(`artMain/cinterop/jni_onload.def`). The callee work is identical and negligible, so the
difference between them is the transition and nothing else. A pure-Kotlin identity call is
measured alongside as the floor and subtracted.
@@ -245,7 +245,7 @@ measured alongside as the floor and subtracted.
All variants are warmed up before any are timed, and rounds interleave all four, so drift
hits them equally. Best-of-7 rounds, 2,000,000 iterations each. `JniOverheadBenchmark`.
-### Result — net of the Kotlin floor, ns/call
+### Result, net of the Kotlin floor, ns/call
| convention | API 26 (Android 8.0) | API 34 (Android 14) |
|---|---|---|
@@ -255,7 +255,7 @@ hits them equally. Best-of-7 rounds, 2,000,000 iterations each. `JniOverheadBenc
The two devices are inverted. On API 26 `@CriticalNative` is ~24x cheaper than ordinary
JNI, which is what the design predicted. On API 34 it is the *most* expensive of the three
-— 3x worse than ordinary JNI and 12x worse than `@FastNative`.
+3x worse than ordinary JNI and 12x worse than `@FastNative`.
### What was ruled out
@@ -267,12 +267,12 @@ JNI, which is what the design predicted. On API 34 it is the *most* expensive of
20,000 iterations instead of 2,000,000: API 34 critical stayed at 26.49 net. Batch size is
not the explanation.
- **Registration silently failing.** `argumentsArriveUnshifted` passes on both devices, so the
- critical convention *is* in effect on API 34 — arguments arrive unshifted. It is correct
+ critical convention *is* in effect on API 34, arguments arrive unshifted. It is correct
there, just slow.
### What is not known
-Why API 34 is slow has not been established. The inversion is suspiciously symmetric — each
+Why API 34 is slow has not been established. The inversion is suspiciously symmetric, each
device looks like it honours exactly one of the two annotations on a fast path and routes the
other through a generic trampoline that is correct but slow. That is a hypothesis, not a
finding.
@@ -286,10 +286,10 @@ measure on physical devices at both API levels.**
If this holds on hardware, the current design is optimal at minSdk and worst-case on modern
Android, which is backwards from where the users are. The fix is to pick the convention per API
level. The annotation is compile-time, so that means declaring both variants and dispatching on
-`Build.VERSION.SDK_INT` — a branch costing ~1-2ns to avoid ~22ns. (It held on hardware, below, and
+`Build.VERSION.SDK_INT`, a branch costing ~1-2ns to avoid ~22ns. (It held on hardware, below, and
was then built: `bindings.preferFastNative`.)
-### Re-measured on physical hardware — the inversion is real
+### Re-measured on physical hardware, the inversion is real
The section above asked for physical-device confirmation before acting. Done: Samsung
SM-X910 (Galaxy Tab S9 Ultra), Android 16 / API 36, arm64-v8a, 8 cores. It is not an
@@ -307,13 +307,13 @@ Net of the Kotlin floor, ns/call:
Three things are now settled.
**The binding method is not the cause.** The hypothesis was that explicitly-registered
-critical natives lose the fast path on modern ART while name-linked ones keep it — Google's
+critical natives lose the fast path on modern ART while name-linked ones keep it, Google's
advice to use RegisterNatives *before Android 12* reads as if the reverse holds after. So a
name-linked `@CriticalNative` echo was added, identical in every other way. It is *slower*
still: 66.80ns on API 34 and 76.56ns on API 36 against 24.35 and 46.68 for the registered
one. Hypothesis rejected. `@CriticalNative` is simply expensive on modern ART.
-**Name linking is not merely slower below Android 12 — it is fatal.** On API 26 that call
+**Name linking is not merely slower below Android 12, it is fatal.** On API 26 that call
aborted the ART runtime outright (`zygote64: runtime.cc:492] Runtime aborting...`) and took
the instrumentation process with it. Google's guidance turns out to be a hard requirement,
not a preference. The benchmark now guards that call behind `SDK_INT >= S`.
@@ -325,13 +325,13 @@ underlying reason, the practical shape is unambiguous.
### Consequence: the current implementation is on the wrong path for real users
The 11 migrated functions use `@CriticalNative` via RegisterNatives. That is the best
-available choice at minSdk 26 and the *worst* on the hardware people actually carry —
+available choice at minSdk 26 and the *worst* on the hardware people actually carry,
46.68ns where `@FastNative` costs 2.52ns, an 18x penalty.
The annotation is resolved at compile time, so the convention cannot be switched at runtime
for one declaration. The fix is to declare both variants and dispatch on
`Build.VERSION.SDK_INT`, paying one predictable branch (~1-2ns) to avoid ~44ns. `@FastNative`
-still receives JNIEnv and jclass, so its exports need those leading parameters — meaning two
+still receives JNIEnv and jclass, so its exports need those leading parameters, meaning two
export sets in artMain, not one.
**Implemented** (`bindings.preferFastNative = SDK_INT >= 34`, `androidMain/.../bindings.kt`; the
@@ -352,7 +352,7 @@ Measured across five API levels plus hardware. Net of the Kotlin floor, ns/call,
| 34 (Android 14) | 24.35 | **1.97** | 7.81 |
| 36 (Android 16, SM-X910 hardware) | 44.05 | **3.55** | 18.32 |
-`@CriticalNative` is effectively free — at or below the measurement floor — from API 26
+`@CriticalNative` is effectively free, at or below the measurement floor, from API 26
through 33, then collapses at 34 and stays collapsed on hardware. `@FastNative` moves the
other way: expensive through API 31, cheap from 33 onward. Two independent ART changes, and
they do not happen at the same release.
@@ -376,8 +376,8 @@ one place the data is closest.
Three separate times, an instrumented run failed because the APK did not contain what the
source tree said it should. `connectedDebugAndroidTest` does not reliably force
`linkAndroidNative*` or the `copyAndroidPythonBinaries` / `copyAndroidPythonAssets` staging,
-so a changed `.def` or a cleaned `build/` produces an APK holding a stale — or entirely
-missing — `libmultiplatform_python3.14.so` and no `libpython3.14.so` beside it. The symptom is
+so a changed `.def` or a cleaned `build/` produces an APK holding a stale, or entirely
+missing, `libmultiplatform_python3.14.so` and no `libpython3.14.so` beside it. The symptom is
`UnsatisfiedLinkError`, which reads like a code error and is not one.
The copy tasks are now wired as dependencies of the assets/jniLibs merge tasks
@@ -390,7 +390,7 @@ If an instrumented run fails with `UnsatisfiedLinkError`, run this first:
### Final rule: the convention is a per-function decision, and correctness comes first
-Picking by API level is only half of it. The two fast conventions are not merely faster —
+Picking by API level is only half of it. The two fast conventions are not merely faster,
they change what the callee is allowed to do:
- Both `@FastNative` and `@CriticalNative` **stop the garbage collector** for the duration of
@@ -401,7 +401,7 @@ they change what the callee is allowed to do:
Plenty of CPython entry points can execute arbitrary Python: a module's top-level code during
import, a `__getattr__` or descriptor during attribute lookup, a `__del__` reached by dropping
the last reference, `site.py` during start-up, `atexit` handlers during teardown. Those are
-unbounded in time, and once Kotlin callables are exposed to Python they re-enter the JVM —
+unbounded in time, and once Kotlin callables are exposed to Python they re-enter the JVM,
which is exactly what `@CriticalNative` cannot support. This is the same reason
`Linker.Option.critical()` is a per-downcall option in Panama rather than a global switch.
@@ -409,17 +409,17 @@ So each function is classified, and the classification outranks the API-level ch
| class | rule | of the 11 migrated |
|---|---|---|
-| **leaf** — cannot execute Python | fastest convention for the API level | `Py_IsInitialized`, `PyList_Size`, `PyErr_Occurred`, `Py_GetVersion`, `PyLong_FromLongLong` |
-| **re-entrant** — may run Python, may upcall, may block | **ordinary JNI, always** | `Py_Initialize`, `Py_Finalize`, `PyErr_Clear`, `PyRun_SimpleString`, `PyImport_ImportModule`, `PyObject_GetAttrString` |
+| **leaf**, cannot execute Python | fastest convention for the API level | `Py_IsInitialized`, `PyList_Size`, `PyErr_Occurred`, `Py_GetVersion`, `PyLong_FromLongLong` |
+| **re-entrant**, may run Python, may upcall, may block | **ordinary JNI, always** | `Py_Initialize`, `Py_Finalize`, `PyErr_Clear`, `PyRun_SimpleString`, `PyImport_ImportModule`, `PyObject_GetAttrString` |
`PyErr_Clear` looks like a leaf and is not: clearing the error drops the last reference to the
exception, and that can run a Python `__del__`. `PyObject_GetAttrString` looks like a field
read and is not, for the same kind of reason. When in doubt the call goes on the ordinary
-path — a wrong guess there costs nanoseconds, while a wrong guess the other way is a crash
+path, a wrong guess there costs nanoseconds, while a wrong guess the other way is a crash
once upcalls exist.
Losing the fast path on the re-entrant half is not the tragedy it appears. Those calls do real
-work — importing a module or running a statement dwarfs a 40ns transition — whereas the leaf
+work, importing a module or running a statement dwarfs a 40ns transition, whereas the leaf
calls, where the transition genuinely dominates, are exactly the ones that keep it.
## Desktop vs Android, measured
@@ -428,7 +428,7 @@ calls, where the transition genuinely dominates, are exactly the ones that keep
> row is corrected by "Fixed: 1015.95 ns -> 2.65 ns" and "Desktop vs Android, corrected" further down.
Same benchmark shape on both sides: same warmup, same iteration count, best-of-7, a
-pure-Kotlin identity call subtracted as the floor, and the same real C API call —
+pure-Kotlin identity call subtracted as the floor, and the same real C API call,
`PyList_Size` on `sys.path`.
| | floor | `PyList_Size` | net |
@@ -441,8 +441,8 @@ Desktop is roughly **140x more expensive per call than Android hardware**, and t
worse than it looks: the host is about 9x faster at the floor (0.33ns vs 3.08ns), so the FFI
call is slower on the machine that is faster at everything else.
-The assumption running through this document — that Android is the platform with the
-overhead problem and desktop is the reference — is backwards. Desktop is currently the
+The assumption running through this document, that Android is the platform with the
+overhead problem and desktop is the reference, is backwards. Desktop is currently the
slowest FFI path in the project by two orders of magnitude.
### Why
@@ -459,7 +459,7 @@ the result on every call. Worse, the argument and return filters are themselves
The backend was written reflectively so the code would compile against `java.lang.foreign`
and `jdk.incubator.foreign` on any JDK, which was the right call for portability and the
wrong one for the hot path. The portability requirement does not extend to the per-call path
-— only to how handles are *created*.
+only to how handles are *created*.
### Consequence
@@ -494,7 +494,7 @@ Measured on the same machine, same benchmark:
| `PyList_Size` on `sys.path` | 1015.95 ns/call | **2.65 ns/call** |
| net of the Kotlin floor | 1015.61 ns | **2.01 ns** |
-About 380x, and desktop goes from the slowest FFI path in the project to the fastest — 2.65ns
+About 380x, and desktop goes from the slowest FFI path in the project to the fastest, 2.65ns
against 7.47ns on Android hardware, on a host roughly 9x faster at the floor, so the two are
now in the same regime rather than separated by two orders of magnitude.
@@ -512,7 +512,7 @@ Until now the modern-Android numbers came from a Samsung tablet while the older
emulators on the M1 host, so "API 36 is slowest" mixed two variables. Running API 36 on an
emulator settles it.
-Same API level, different machine — real `PyList_Size` call:
+Same API level, different machine, real `PyList_Size` call:
| API 36 | floor | `@CriticalNative` | `@FastNative` | ordinary JNI |
|---|---|---|---|---|
@@ -523,7 +523,7 @@ Hardware is 1.5-2x the emulator across every row, and the floor moves by the sam
(3.22 vs 2.20). That is the tablet's core being slower than the host, not anything about
API 36. The earlier reading that "API 36 is the slow one" was partly this artefact.
-Same machine, different API level — emulators on the M1 host only:
+Same machine, different API level, emulators on the M1 host only:
| | `@CriticalNative` | `@FastNative` |
|---|---|---|
@@ -533,7 +533,7 @@ Same machine, different API level — emulators on the M1 host only:
The collapse is still real and still API-driven: `@CriticalNative` goes from free at 33 to
21.72ns at 36 with the hardware held constant. So the crossover conclusion and the dispatch
-threshold stand — hardware exaggerated the gap but did not create it.
+threshold stand, hardware exaggerated the gap but did not create it.
### Desktop vs Android, corrected
@@ -551,7 +551,7 @@ comparison implied. Both are now in the same regime, which is the part that matt
## Composition: measured
-Per-crossing cost has bottomed out — 2.65ns on desktop, 1.66-7.24ns on Android depending on
+Per-crossing cost has bottomed out, 2.65ns on desktop, 1.66-7.24ns on Android depending on
API level and hardware. The only lever left is crossing fewer times, which is the structure
the Android path was originally built around: `artMain` assembling a whole binder operation
and `androidMain` calling that assembly once, rather than `commonMain` driving raw C API
@@ -575,7 +575,7 @@ calls one crossing at a time.
**Composition wins, but not purely by removing crossings.** Four crossings at ~7ns is 28ns,
which does not explain 3929ns versus 713ns. The per-call `getAttr` allocates a C string
-through `ffiAllocUtf8` — a malloc, a copy out of the jstring, and a matching free — where the
+through `ffiAllocUtf8`, a malloc, a copy out of the jstring, and a matching free, where the
composed version borrows the jstring directly with `GetStringUTFChars` and releases it. Most
of the win is redundant work that disappears when the operation is expressed once on the
native side, and the crossing count is the smaller part. That is still a real argument for
@@ -585,15 +585,15 @@ composing, but the mechanism is not the one the crossing-count framing suggests.
1000 elements on hardware. That is the case the current design handles worst, because
`commonMain` drives it element by element.
-**API 26 barely benefits from composing the bulk case** — 1.2x. Its per-element crossings run
+**API 26 barely benefits from composing the bulk case**, 1.2x. Its per-element crossings run
under `@CriticalNative`, which is free at that API level, so there was little to remove. This
is the mirror image of the convention result: composition pays most exactly where the
per-crossing cost is highest, which is modern Android, which is where the users are.
### Where this leaves the design
-`PyObject` was `expect`/`actual` with an Android `actual` marked `external` — the hook for
-exactly this — until commit 0fae961a (2025-12-20) folded it into a single `commonMain` class.
+`PyObject` was `expect`/`actual` with an Android `actual` marked `external`, the hook for
+exactly this, until commit 0fae961a (2025-12-20) folded it into a single `commonMain` class.
That commit's real subject was moving `PyAutoCloseable` to per-platform implementations, which was
right and should stand; losing the composition hook was collateral. The resolution was **not** to
restore `expect`/`actual` on `PyObject`: composition lives in the FFI layer (`architecture.md`), and
@@ -605,7 +605,7 @@ that is a much smaller loss on desktop than the same gap would be on Android.
## One downcall, across all platforms
-The upcall side has §7.1 "One upcall, per platform" in [`upcall.md`](upcall.md#71-one-upcall-per-platform) — one shared
+The upcall side has §7.1 "One upcall, per platform" in [`upcall.md`](upcall.md#71-one-upcall-per-platform), one shared
test, run on every target, min–max over several runs, quoted rows marked as quotes. The downcall
side had no equivalent: the benchmarks that exist are real and each one answers a question, but
they are scattered across five files with no shared shape, so "how expensive is a downcall on
@@ -623,7 +623,7 @@ platform X" had no single table to read.
| `androidInstrumentedTest/.../CompositionBenchmark.kt` | `androidInstrumentedTest` | Per-call crossings vs one composed `artMain` call, for `getAttr` and `list → array`, plus a direct-buffer marshalling variant | Android/ART only, needs a device or emulator |
Two more files live next to these and share the word "overhead" or "Upcall" in their name but are
-**not** downcall benchmarks — noted here only so a future reader does not double-count them:
+**not** downcall benchmarks, noted here only so a future reader does not double-count them:
`commonTest/.../reflection/UpcallOverheadTest.kt` prices the upcall dispatch table
(`UpcallTable.resolve` vs a cached handle), and `androidInstrumentedTest/.../UpcallOverheadTest.kt`
prices the Android upcall attach. Both are upcall-side and already covered by `upcall.md`.
@@ -631,36 +631,36 @@ prices the Android upcall attach. Both are upcall-side and already covered by `u
`BenchmarkTest` reaches every one of the six execution paths KGP registers for this module
(`desktopTest`, `iosSimulatorArm64Test`, `iosX64Test`, `wasmJsNodeTest`, `androidInstrumentedTest`/
`connectedAndroidTest`, `androidNativeArm64Test`+`androidNativeX64Test`) because it sits in
-`commonTest`, which every one of those depends on transitively. It is confirmed to run — not just
-compile — on three of the six this pass: desktop, the iOS simulator, and wasmJs (see Validation
+`commonTest`, which every one of those depends on transitively. It is confirmed to run, not just
+compile, on three of the six this pass: desktop, the iOS simulator, and wasmJs (see Validation
below). `iosX64Test` has no practical run path on Apple Silicon and nobody targets it; the
Android/ART and androidNative rows need a connected device or emulator, which this pass did not
have available.
### A shared table, picked to match the upcall table's columns
-The upcall table's denominator column is `PyObject_CallObject` on a Python `def` — "a downcall of
-the same shape" — plus `Py_IncRef`/`Py_DecRef` and an empty `withPython` scope as the two
+The upcall table's denominator column is `PyObject_CallObject` on a Python `def`, "a downcall of
+the same shape", plus `Py_IncRef`/`Py_DecRef` and an empty `withPython` scope as the two
boundary-cost brackets. Those three, plus one string-marshalling call, are exactly what
`UpcallBoundaryCostTest`'s `measureDowncalls()` (`commonTest`) already records **in the same run as
-the upcall it prices** — so pulling them out as their own table is not a new measurement, it is the
+the upcall it prices**, so pulling them out as their own table is not a new measurement, it is the
existing "comparison basis" rows read on their own. The fourth column, string marshalling, comes
from `BenchmarkTest.testStringMarshalling`'s 8-character row, run in the same suite execution.
-Desktop, iOS simulator and wasmJs are measured fresh here — three full-suite runs
+Desktop, iOS simulator and wasmJs are measured fresh here, three full-suite runs
(`:desktopTest :iosSimulatorArm64Test :wasmJsNodeTest`), same command as Validation below, each
with `build/test-results` cleared first so a task cannot report a stale XML. All three runs matched
the baseline exactly (360/0/1, 330/0/0, 340/0/0). Ranges are min–max over three runs for the first
three columns; the string-marshalling column is min–max over two of those three runs (the first
-run's `BenchmarkTest` XML was not preserved before the second run overwrote it — its
+run's `BenchmarkTest` XML was not preserved before the second run overwrote it, its
`UpcallBoundaryCostTest` numbers were captured from console output before that happened, which is
why that row has three runs and this one has two).
-androidNative and Android/ART rows are **quoted from `upcall.md`**, not re-measured — marked
+androidNative and Android/ART rows are **quoted from `upcall.md`**, not re-measured, marked
† below, same convention that document uses for its own quoted rows. Cells with no prior
measurement of this exact quantity are left blank rather than approximated from a different one
-(e.g. `CompositionBenchmark`'s composed `getAttr` is a different quantity — four crossings folded
-into one — not a bare `PyUnicode_FromString`).
+(e.g. `CompositionBenchmark`'s composed `getAttr` is a different quantity, four crossings folded
+into one, not a bare `PyUnicode_FromString`).
> **Four of this table's seven rows are quotes from another document and one column of it is
> superseded (below), which is what happens to a table assembled by hand.** The same four columns,
@@ -734,7 +734,7 @@ Each platform-only file above measures something the others cannot, and none of
the four shared columns:
- **Desktop**: the Panama `invoke` → `invokeExact` migration (`DesktopOverheadBenchmark`,
- 1015.95 ns → 2.65 ns for `PyList_Size`) is a desktop-only story — no other platform has an
+ 1015.95 ns → 2.65 ns for `PyList_Size`) is a desktop-only story, no other platform has an
equivalent adaptation-vs-exact distinction.
- **Android/ART**: `JniOverheadBenchmark`'s ordinary/`@FastNative`/`@CriticalNative` comparison and
`CompositionBenchmark`'s per-call-vs-composed figures are both `artMain`/`androidMain` concepts
@@ -743,7 +743,7 @@ the four shared columns:
wasm-linear-memory concept; the other platforms marshal strings through Panama or JNI, not through
an explicit intern cache.
-These are not merged into the shared table, and the files are not merged or deleted — the request
+These are not merged into the shared table, and the files are not merged or deleted, the request
for this pass was a table, not a consolidation, and each file's platform-specific framing (its own
warmup counts, its own comparison baselines) is part of what makes its own number meaningful.
@@ -757,7 +757,7 @@ demonstrably present in the freshly linked `.so`. The cause was in `build.gradle
}
A bare `copy {}` inside a `configure {}` block executes while Gradle is configuring the build,
-not when the link task runs — so every build staged the *previous* build's library into
+not when the link task runs, so every build staged the *previous* build's library into
`jniLibs`. Any change to the `.def` or to native sources needed two full builds before it
reached the device, and in between the failure looked exactly like a code bug. Moved into
`doLast`, so it stages what the link actually produced.
diff --git a/docs/design/ecosystem.md b/docs/design/ecosystem.md
index fa04ccad2..9f9a82e9f 100644
--- a/docs/design/ecosystem.md
+++ b/docs/design/ecosystem.md
@@ -1,8 +1,8 @@
# The repositories around this one, and what each owes
`/Volumes/macMini/thisisthepy` holds several repositories that are meant to compose into one
-product. Until now none of them referenced the others in writing — this repository's `docs/roadmap/ROADMAP.md`
-mentions `pypackpack`, `toolchain`, `pythonx` and `chaquopy` exactly zero times — so this file
+product. Until now none of them referenced the others in writing, this repository's `docs/roadmap/ROADMAP.md`
+mentions `pypackpack`, `toolchain`, `pythonx` and `chaquopy` exactly zero times, so this file
records the intended relationships, what each repository actually contains today, and what has to
be built before the parts can meet.
@@ -26,7 +26,7 @@ than observed are marked as such.
pythonx-compose Compose bound into Python, so a UI can be written in Python.
PyREPL reference implementation for turning a Kotlin namespace into `.pyi` stubs,
- and — see below — the actual origin of `toolchain`.
+ and, see below, the actual origin of `toolchain`.
The division of labour that matters: **ppp owns the work, toolchain owns the Gradle vocabulary,
this repository owns the language boundary.** A feature that acquires a Python distribution or
@@ -46,14 +46,14 @@ Working: `init`, `python use/list/find/install/uninstall`, `package add/remove/s
`target list/add/remove`, per-package dependency operations with platform markers, and `build`
driving Meson for C/C++.
-Placeholder — verified by line count, all two to four lines: the whole `bundle/` tree, the whole
+Placeholder, verified by line count, all two to four lines: the whole `bundle/` tree, the whole
`deploy/` tree, every compile backend except Meson (Clang, MSVC, NDK, XCode, Emscripten, Cargo),
and all of `compile/middleware/{external,transcompile,minification}`.
It is already designed to be called from Gradle. `dependency/frontend/BaseInterface.kt` declares
`enum FrontendType { CLI, GRADLE }` and `Gradle.kt` returns the same middleware the CLI uses. The
-stable surface is `dependency/middleware/BaseInterface.kt` — `initProject`, `addDependencies`,
-`syncDependencies`, `addTargets`, `installPythonVersion`, and so on — plus
+stable surface is `dependency/middleware/BaseInterface.kt`, `initProject`, `addDependencies`,
+`syncDependencies`, `addTargets`, `installPythonVersion`, and so on, plus
`compile/middleware/BaseInterface.kt`'s `compile`.
**But `packpack/build.gradle.kts` applies no `maven-publish` and declares no publication.** Nothing
@@ -65,23 +65,23 @@ cannot be implemented at all.
A Kotlin Gradle plugin, id `org.thisisthepy.python.multiplatform`, about 1,240 lines. It registers
a `python` extension and three tasks. What the tasks do today: copy a local folder into
`src/main/assets/python`, copy `build/pythonLibraries` into `build/pythonBundle/libs`, zip that,
-and shell out `uv install -r` — which is not a valid uv command.
+and shell out `uv install -r`, which is not a valid uv command.
**`toolchain` shares git history with `PyREPL`.** Commits `f806656`, `5f3f932`, `1b32d03`,
`654ef7c` exist in both with identical hashes; `dba17dd` extracted PyREPL's meta-generation out of
`app/build.gradle.kts` into `toolchain/.../meta/createMetaClass.kt`. So the pyi generator to study
-is already here, in a mutated form — see §4.
+is already here, in a mutated form, see §4.
The target API is `(플러그인예시)build.gradle.kts` at the repo root: 276 lines describing
`compileSdk`, per-platform declarations, `packaging { embedLevel, hotReload, codePush }`,
`buildTypes { compileLevel }`, `projectFlavors`, `buildFeatures { metaclass, compose }`, and source
-sets carrying both `implementation("pyzmq")` and `integration("pycomposeui")` — the latter being a
+sets carrying both `implementation("pyzmq")` and `integration("pycomposeui")`, the latter being a
Kotlin-dependent Python package that needs a `KLIBDEPENS` file in its wheel.
Of that spec the DSL data classes exist for roughly 60%, and **none of `hotReload`, `codePush`,
`buildTypes`, `projectFlavors`, `buildFeatures`, `metaDirs`, `libDirs` or `integration()` is read by
any task.** `PythonPlugin.kt` consumes only `sourceSets.*.dependencies.implementations`.
-`dsl/PythonConfiguration.kt` is dead code — its extension is never registered.
+`dsl/PythonConfiguration.kt` is dead code, its extension is never registered.
`toolchain/usage-example/` does not apply the plugin, has no `python { }` block, and is commented
out of `settings.gradle.kts`. **There is currently no executable definition of the target API.**
@@ -96,7 +96,7 @@ with uv, crossenv and meson instead of kivy recipes.
Two generations coexist, and the one the notebook demonstrates is in neither of them.
`pythonx/compose/` is the chaquopy generation: a `Composable` decorator class, `ComposeApp`, and
-material3 wrappers — 37 files of which 28 are empty. It binds through `jclass(...)` and
+material3 wrappers, 37 files of which 28 are empty. It binds through `jclass(...)` and
`from java import jclass`, and it reaches **AndroidX directly**:
from androidx.compose.material3 import TextKt
@@ -121,7 +121,7 @@ The Kotlin-to-pyi converter lives inline in `app/build.gradle.kts`, 627 lines. I
source set's resolved artifacts, reads the jars with **ASM** (not kotlinx-metadata), and writes one
`__init__.pyi` per Java package under `src//generated/meta/`, with `...` bodies and a
JNI-ish type map. It drops private members, ``, `Companion`, and **any member whose name
-contains `-`** — that is, it discards the mangled ones rather than scanning for them. Collisions
+contains `-`**, that is, it discards the mangled ones rather than scanning for them. Collisions
between `commonMain` and a platform source set are resolved by renaming both and re-exporting.
It is stubs only; the runtime underneath is chaquopy `jclass`. Its purpose is IDE completion.
@@ -134,7 +134,7 @@ keep.**
### Not part of this chain
`Gemstone` belongs to a different organisation and does not depend on any of these; its README says
-it is *scheduled* to adopt Python Multiplatform. `reference/` and `cpython/` are not repositories —
+it is *scheduled* to adopt Python Multiplatform. `reference/` and `cpython/` are not repositories,
vendored material and prebuilt CPython artefacts.
---
@@ -143,10 +143,11 @@ vendored material and prebuilt CPython artefacts.
`UI.ipynb` imports `pythonx.compose.runtime`, `pythonx.compose.material3`, `pythonx.compose.ui`
and `pythonx.compose.layout`, and documents signatures like `Text(text, color, font_size)`,
-`Button(onclick, …)` — quoted as the notebook writes it; the 2024 library spells that parameter
+`Button(onclick, …)`, quoted as the notebook writes it; the 2024 library spells that parameter
`on_click` (`docs/archive/pythonx-adapter-design.md` §3 measured both). Which Python spelling
`pythonx` uses is pythonx-compose's decision: this repository's binder exposes the Kotlin name
-`onClick` and renames nothing (`docs/INTENT.md` §2.2) — plus `Column/Row/Spacer/TextField`. It also
+`onClick` and its Pythonic alias `on_click` on the Kotlin-named module, and renames no namespace
+(`docs/INTENT.md` §2.2, SPEC U-12) - plus `Column/Row/Spacer/TextField`. It also
requires `main.App` as a live object, `App.messages.getValue()/setValue()`, and
`main.App.update(NewComposable)` for hot-swapping the UI from a Jupyter cell.
@@ -172,14 +173,14 @@ handling at all. Every widget in `pycomposeui` is `@Composable`. Nothing in `pyt
leave chaquopy until this is designed. *(read from source; not runtime-verified.)* This is the
smallest and most blocking item, and it is entirely inside this repository.
-> **Closed — see `docs/archive/pythonx-adapter-design.md` §5.6.** `Text('hi')` written in Python now draws
-> through the real `androidx.compose.material3.Text` (`:ksp-fixtures:compose`'s
+> **Closed, see `docs/archive/pythonx-adapter-design.md` §5.6.** `Text('hi')` written in Python now draws
+> through the real `androidx.compose.material3.Text` (`:python-multiplatform-compose:fixtures:compose`'s
> `ComposableRenderTest`: 71 non-background pixels against 0 for an empty body). The paragraph above
> is right that a *generated Kotlin* entry for a widget cannot compile, and that is not the route
> taken: an artefact composable's call site is emitted as **bytecode**, with `$composer`, `$changed`
> and `$default` exposed as ordinary slots, and the binding layer (`python_multiplatform.binding`,
> `PythonxAdapter.kt`) computes the mask. `BindingPolicy` does now
-> have `@Composable` handling and what it does is **decline** — a composable in the consumer's *own
+> have `@Composable` handling and what it does is **decline**, a composable in the consumer's *own
> source* has no compiled signature to call yet, which is a different problem from one in a jar.
**2. `packpack` must be publishable.** One file. Without it the stated architecture is
@@ -192,7 +193,7 @@ this exists: generated modules are injected into `sys.modules` under their Kotli
name. What is missing is a `sys.meta_path` finder so a name resolves on demand rather than only
after an eager `install()`, the `pythonx` prefix, and generated `.pyi` beside it.
-> **Current (2026-10-03):** the finder exists — the binding layer `python_multiplatform.binding`
+> **Current (2026-10-03):** the finder exists, the binding layer `python_multiplatform.binding`
> installs a `sys.meta_path` finder that makes Kotlin-named modules importable on demand
> (`_Finder`/`_Loader` in `PythonxAdapter.kt`; SPEC U-8). The `pythonx` prefix is **not** this
> repository's to provide: the binder creates no `pythonx` module, and `pythonx` is a real package in
@@ -223,9 +224,9 @@ This section documents the current state, language/build system/distribution art
### `PythonMultiplatform` (this repository)
- **Observed Current State**:
- - Embedded CPython FFI binder (`python-multiplatform/src/commonMain/kotlin/.../EmbedAPI.kt`, platform implementations in `EmbedAPI.desktop.kt`, `bindings.kt`, JNI/Panama/cinterop). **Correction, 2026-08-17: this line used to say "CPython 3.13" — stale.** `gradle.properties`'s `pythonVersion` (the single source of truth, read into `Versions.currentVersion` at `python-multiplatform/src/commonMain/kotlin/python/multiplatform/Versions.kt`) is `3.14.7`, and every platform's *observed runtime* agrees: desktop prints `runtime : 3.14.7` (`4ed98143:docs/roadmap/ROADMAP.md:2063`), Android prints `3.14.7` on both `pmp_api36` and `pmp_api26` (`4ed98143:docs/roadmap/ROADMAP.md:2073`), wasmJs prints `3.14.2` — its own, separately-pinned Emscripten build, not this line's concern (`docs/platforms/wasm-design.md`). See the version-mismatch check below.
+ - Embedded CPython FFI binder (`python-multiplatform/src/commonMain/kotlin/.../EmbedAPI.kt`, platform implementations in `EmbedAPI.desktop.kt`, `bindings.kt`, JNI/Panama/cinterop). **Correction, 2026-08-17: this line used to say "CPython 3.13", stale.** `gradle.properties`'s `pythonVersion` (the single source of truth, read into `Versions.currentVersion` at `python-multiplatform/src/commonMain/kotlin/python/multiplatform/Versions.kt`) is `3.14.7`, and every platform's *observed runtime* agrees: desktop prints `runtime : 3.14.7` (`4ed98143:docs/roadmap/ROADMAP.md:2063`), Android prints `3.14.7` on both `pmp_api36` and `pmp_api26` (`4ed98143:docs/roadmap/ROADMAP.md:2073`), wasmJs prints `3.14.2`, its own, separately-pinned Emscripten build, not this line's concern (`docs/platforms/wasm-design.md`). See the version-mismatch check below.
- Kotlin object model hierarchy (`python-multiplatform/src/commonMain/kotlin/.../PyObject.kt` and wrappers).
- - Code generators: KSP processor (`python-multiplatform-ksp/`) producing `FunctionTableFragment`s, and Gradle plugin (`python-multiplatform-gradle-plugin/src/main/kotlin/.../artifact/ArtifactScanner.kt`, `PythonArtifactBindingsTask.kt`; *corrected 2026-10-03 — there is no `ArtifactBindingGenerator.kt`*) scanning resolved dependency jars (ASM + `kotlin-metadata-jvm`) and klibs for `ArtifactTable` fragments, plus `.pyi` stubs under Kotlin module paths (`stubs/PythonStubsTask.kt`).
+ - Code generators: KSP processor (`python-multiplatform-ksp/`) producing `FunctionTableFragment`s, and Gradle plugin (`python-multiplatform-gradle-plugin/src/main/kotlin/.../artifact/ArtifactScanner.kt`, `PythonArtifactBindingsTask.kt`; *corrected 2026-10-03, there is no `ArtifactBindingGenerator.kt`*) scanning resolved dependency jars (ASM + `kotlin-metadata-jvm`) and klibs for `ArtifactTable` fragments, plus `.pyi` stubs under Kotlin module paths (`stubs/PythonStubsTask.kt`).
- Multiplatform target support: Desktop JVM/Panama, Android JNI, iOS Native Cinterop, androidNativeArm64 (`:python-multiplatform:compileKotlinAndroidNativeArm64`).
- CPython binary download and SHA-256 lockfile / Sigstore verification (`python-checksums.properties`, `python-multiplatform/build.gradle.kts`).
- **Language / Build System / Distribution**:
@@ -236,19 +237,19 @@ This section documents the current state, language/build system/distribution art
- Core language boundary and binder between Kotlin Multiplatform and CPython.
- Manages low-level FFI, object reference handles, upcall/downcall function tables, GIL lifecycle, and proxy injection into CPython `sys.modules`.
- **Gap to Goal** (status notes added 2026-10-03, read from code and SPEC, not re-run):
- - `@Composable`-capable callable shape: `ExposedCallable` typed `(Array) -> Any?` cannot invoke `@Composable` functions taking synthetic `$composer` / `$changed` parameters. Needs dedicated `CallableKind` or opt-in annotation handling. — *Closed on desktop by bytecode thunks (`ComposableThunks.kt`), SPEC B-6; Android, iOS and wasm planned (SPEC N-2).*
- - Python-side `sys.meta_path` finder for lazy import resolution of Kotlin namespaces ~~(`pythonx.*` or FQCNs)~~ upon import. — *Exists for Kotlin FQNs in `python_multiplatform.binding` (SPEC U-8). `pythonx.*` is not the binder's to resolve (`AGENTS.md` §12.2).*
- - Opaque Kotlin object round-tripping for Compose `Composer` (`HandleTable` and `ObjectReference` exist, but runtime hand-off to Python needs verification). — *Done on desktop: one hand-written `@Composable` pushes the live composer as a handle onto the binding layer's stack (`push_composer`), proven by the `ksp-fixtures/compose` render tests.*
- - Lifetime and storage of Python callables passed into Kotlin across Compose recompositions (`content=lambda: ...`, `onClick=...`). — *Partly addressed: `PythonCallableScope` (`PythonCallables.kt`) holds the callables a composition hands to Kotlin and is closed by a Compose-side `RememberObserver` in `onForgotten`; several hand-written fixture wrappers (`pythonDraggable`, `pythonComposed`, the swipe/anchor wrappers) still leak their callbacks by design, pinned by `DraggableLeakTest`. Not verified here beyond reading the sources.*
+ - `@Composable`-capable callable shape: `ExposedCallable` typed `(Array) -> Any?` cannot invoke `@Composable` functions taking synthetic `$composer` / `$changed` parameters. Needs dedicated `CallableKind` or opt-in annotation handling., *Closed on desktop by bytecode thunks (`ComposableThunks.kt`), SPEC B-6; Android, iOS and wasm planned (SPEC N-2).*
+ - Python-side `sys.meta_path` finder for lazy import resolution of Kotlin namespaces ~~(`pythonx.*` or FQCNs)~~ upon import., *Exists for Kotlin FQNs in `python_multiplatform.binding` (SPEC U-8). `pythonx.*` is not the binder's to resolve (`AGENTS.md` §12.2).*
+ - Opaque Kotlin object round-tripping for Compose `Composer` (`HandleTable` and `ObjectReference` exist, but runtime hand-off to Python needs verification)., *Done on desktop: one hand-written `@Composable` pushes the live composer as a handle onto the binding layer's stack (`push_composer`), proven by the `python-multiplatform-compose/fixtures/compose` render tests.*
+ - Lifetime and storage of Python callables passed into Kotlin across Compose recompositions (`content=lambda: ...`, `onClick=...`)., *Partly addressed: `PythonCallableScope` (`PythonCallables.kt`) holds the callables a composition hands to Kotlin and is closed by a Compose-side `RememberObserver` in `onForgotten`; several hand-written fixture wrappers (`pythonDraggable`, `pythonComposed`, the swipe/anchor wrappers) still leak their callbacks by design, pinned by `DraggableLeakTest`. Not verified here beyond reading the sources.*
- Retire `stagePythonHome` in favor of `pypackpack`'s Python distribution management.
- Hand `stageWasmBrowserRuntime` logic to `toolchain`.
- **Dependency Direction**:
- `PythonMultiplatform` has no dependencies on other repos in the ecosystem.
- `pythonx-compose` and consumer applications depend on `PythonMultiplatform` runtime and its Gradle/KSP bindings plugin.
- **Unverified**:
- - Behavior of WASM browser runtime under production web bundlers. (*2026-10-03:* the former `wasm-experiment/` is folded into the library — `wasmJsMain`/`wasmJsTest`, CPython built by `tools/wasm/build-cpython.sh` — commit `6e54f617`; the bundler question is unchanged.)
+ - Behavior of WASM browser runtime under production web bundlers. (*2026-10-03:* the former `wasm-experiment/` is folded into the library, `wasmJsMain`/`wasmJsTest`, CPython built by `python-multiplatform/scripts/wasm/build-cpython.sh`, commit `6e54f617`; the bundler question is unchanged.)
-**Version check (2026-08-17): "3.14 runtime vs. 3.13 header, never checked" — could not find the
+**Version check (2026-08-17): "3.14 runtime vs. 3.13 header, never checked", could not find the
sentence, checked the claim anyway.** The literal sentence was searched for across every `.md` file
in this repository (`grep -rn` for Korean and English phrasings of "nobody/no one checked", "never
verified", "mismatch", combined with "header"/"헤더" and "runtime"/"런타임") and in the git history
@@ -256,33 +257,31 @@ of this file; it does not appear anywhere, verbatim or paraphrased. The nearest
this section's own now-corrected "CPython 3.13" line above (stale since the version became
configurable) and `4ed98143:docs/roadmap/ROADMAP.md:2033`, which already fixed a test fixture that hardcoded the string
"CPython 3.13" while `pythonVersion` had moved to 3.14.7. Neither is the sentence quoted, and neither
-left the underlying question — do headers and runtime actually agree, per platform — checked. That
+left the underlying question, do headers and runtime actually agree, per platform, checked. That
question was checked here, freshly, platform by platform:
| platform | header source | runtime source | agree? | how checked |
|---|---|---|---|---|
-| desktop | none — Panama binds by symbol name at runtime (`java.lang.foreign.Linker`/`SymbolLookup`), no C header is compiled against | `libpython3.14.dylib`/`.so`/`.dll` extracted from the `python-build-standalone` archive pinned to `pythonVersion` (`python-checksums.properties`: `macos-aarch64-3.14.7-20260807`, etc.) | n/a — no header exists to disagree with the runtime | read `build.gradle.kts`'s desktop `jvm()` block; no `cinterop`/`headers()` call anywhere in it; `4ed98143:docs/roadmap/ROADMAP.md:1034-1038` records the same conclusion independently |
-| iOS / androidNative (Kotlin/Native `cinterop`) | `$targetExtractDir/include/python$libVersion/Python.h` (Android) or the extracted `Python.xcframework/.../Headers` (iOS) — **read from the same extraction the runtime library comes from**, not from any vendored copy | same archive, same extraction, `libVersion`/`targetExtractDir` computed from the one `pythonVersion`/`libVersion` variables | **yes, by construction** — both paths are derived from the same Gradle variable, so they cannot independently drift for a given build | read `python-multiplatform/build.gradle.kts:1093-1128` (`targetIncludePath`, the `cinterops.create("python")` block); confirmed no `defFile`/hardcoded header path overrides it |
-| Android JNI (`artMain`, `jni_onload.def`) | none — hand-written `extern` prototypes in `jni_onload.def`, no `#include `, pointers passed as `jlong` | `libpython$libVersion.so` linked with `-lpython$libVersion` from the same extraction tree | n/a for headers; symbol-level agreement already checked by earlier work (`nm`/`objdump` against the shipped `.so`, cited in `docs/roadmap/ROADMAP.md` around the 3.15 migration) | read `python-multiplatform/src/artMain/cinterop/jni_onload.def` and its `build.gradle.kts` cinterop block (no `headers()`/`includeDirs()` call) |
-| wasmJs | Emscripten's own CPython 3.14.2 build, entirely separate toolchain (`docs/platforms/wasm-design.md`) | same 3.14.2 build (`Embedded CPython version: 3.14.2`, `docs/archive/wasm-design-experiment-log.md` "Building CPython 3.14.2 for Emscripten"; `runtime : 3.14.2` sample output, `4ed98143:docs/roadmap/ROADMAP.md:1488`) | yes, and deliberately a different minor-patch than the 3.14.7 native default — documented and reasoned about at length in `docs/platforms/wasm-design.md`, not an oversight |
+| desktop | none, Panama binds by symbol name at runtime (`java.lang.foreign.Linker`/`SymbolLookup`), no C header is compiled against | `libpython3.14.dylib`/`.so`/`.dll` extracted from the `python-build-standalone` archive pinned to `pythonVersion` (`python-checksums.properties`: `macos-aarch64-3.14.7-20260807`, etc.) | n/a, no header exists to disagree with the runtime | read `build.gradle.kts`'s desktop `jvm()` block; no `cinterop`/`headers()` call anywhere in it; `4ed98143:docs/roadmap/ROADMAP.md:1034-1038` records the same conclusion independently |
+| iOS / androidNative (Kotlin/Native `cinterop`) | `$targetExtractDir/include/python$libVersion/Python.h` (Android) or the extracted `Python.xcframework/.../Headers` (iOS), **read from the same extraction the runtime library comes from**, not from any vendored copy | same archive, same extraction, `libVersion`/`targetExtractDir` computed from the one `pythonVersion`/`libVersion` variables | **yes, by construction**, both paths are derived from the same Gradle variable, so they cannot independently drift for a given build | read `python-multiplatform/build.gradle.kts:1093-1128` (`targetIncludePath`, the `cinterops.create("python")` block); confirmed no `defFile`/hardcoded header path overrides it |
+| Android JNI (`artMain`, `jni_onload.def`) | none, hand-written `extern` prototypes in `jni_onload.def`, no `#include `, pointers passed as `jlong` | `libpython$libVersion.so` linked with `-lpython$libVersion` from the same extraction tree | n/a for headers; symbol-level agreement already checked by earlier work (`nm`/`objdump` against the shipped `.so`, cited in `docs/roadmap/ROADMAP.md` around the 3.15 migration) | read `python-multiplatform/src/artMain/cinterop/jni_onload.def` and its `build.gradle.kts` cinterop block (no `headers()`/`includeDirs()` call) |
+| wasmJs | Emscripten's own CPython 3.14.2 build, entirely separate toolchain (`docs/platforms/wasm-design.md`) | same 3.14.2 build (`Embedded CPython version: 3.14.2`, `docs/archive/wasm-design-experiment-log.md` "Building CPython 3.14.2 for Emscripten"; `runtime : 3.14.2` sample output, `4ed98143:docs/roadmap/ROADMAP.md:1488`) | yes, and deliberately a different minor-patch than the 3.14.7 native default, documented and reasoned about at length in `docs/platforms/wasm-design.md`, not an oversight |
**What is genuinely stale, and does not affect any of the above:** two artefacts in this repository
still carry CPython 3.13 and are not read by the default build. `python-multiplatform/src/nativeInterop/cinterop/include/patchlevel.h`
-is a vendored header tree whose `PY_VERSION` reads `"3.13.0+"` — `git log` shows it landed with
+is a vendored header tree whose `PY_VERSION` reads `"3.13.0+"`, `git log` shows it landed with
`b184cba5` (pre-dates `pythonVersion` becoming a Gradle property) and `build.gradle.kts` never
references `nativeInterop/cinterop/include` at all; cinterop reads headers from the version-keyed
-extraction tree instead (table above). `binary/` holds five committed CPython 3.13.0 archives from
-`python-build-standalone` release `20241008` (`binary/download.md`); they are consumed only by the
-special-cased branch `if (configuredPythonVersion == "3.13.0" && !pythonFreeThreaded)` in the
-`desktopJar` task (`build.gradle.kts:1049`), i.e. only if a consumer explicitly builds with
+extraction tree instead (table above). `binary/`, which held five unreferenced CPython 3.13.0 archives, was removed on 2026-10-04. The
+`-PpythonVersion=3.13.0` branch of the `desktopJar` task reads the vendored tree above, only if a consumer explicitly builds with
`-PpythonVersion=3.13.0`. That legacy path has no `Py_LIMITED_API`/header dependency either (desktop
never compiles against headers), so even under `-PpythonVersion=3.13.0` there is no header/runtime
-split — but `python-checksums.properties` has **no 3.13.0 entries for Android or iOS**, so that flag
+split, but `python-checksums.properties` has **no 3.13.0 entries for Android or iOS**, so that flag
would fail checksum verification on those two targets rather than silently mixing versions; this was
not run to confirm the failure mode, only the absence of pinned checksums.
**Correction to the paragraph above, made while verifying it: the 3.13 headers are not inert.** They
-are not a *compiler* input — that part holds, `build.gradle.kts` never puts
+are not a *compiler* input, that part holds, `build.gradle.kts` never puts
`nativeInterop/cinterop/include` on an include path. But two test suites read that directory as their
source of truth about the C API: `EmbedApiSurfaceTest.kt:454` and
`JniCallConventionClassificationTest.kt:628` both open `src/nativeInterop/cinterop/include` and
@@ -291,7 +290,7 @@ call-convention classification. So the one place in this repository where a 3.13
runtime is the evidence layer, not the build: a function whose deprecation or signature changed in
3.14 would be classified from 3.13's text and the tests would agree with themselves. This is the
mismatch the check set out to look for, in the one shape nobody had looked at, and it is recorded
-rather than fixed here — refreshing the header tree is a separate change with its own verification.
+rather than fixed here, refreshing the header tree is a separate change with its own verification.
**That separate change was made (2026-08-17), and it answered the open question: nothing this
project uses moved between 3.13 and 3.14.** The vendored `include/` tree was replaced with the
@@ -321,7 +320,7 @@ VendoredHeaderVersionTest.kt`): it compares `patchlevel.h`'s `PY_MAJOR/MINOR/MIC
`gradle.properties`'s `pythonVersion` and fails if they diverge, with a canary test so that a
regex that stops matching fails loudly instead of making the comparison vacuous. It was watched
failing on the pre-refresh tree (`expected:<3.1[4.7]> but was:<3.1[3.0]>`, sole failure in a
-469-test run) before it was trusted. It compares versions, not contents — deliberately, since a
+469-test run) before it was trusted. It compares versions, not contents, deliberately, since a
content comparison would need the extraction present and so would be unavailable exactly when the
extraction has not been run.
@@ -333,26 +332,26 @@ and no test; the runtime stdlib comes from the version-keyed extraction instead.
them is a repository-size decision, not a correctness one, and is left open.
**`Py_LIMITED_API` status, checked directly:** `grep -rn "Py_LIMITED_API"` across `.kts`/`.def` build
-files finds it only inside vendored CPython header guards (`#if defined(Py_LIMITED_API) ...`) — it is
+files finds it only inside vendored CPython header guards (`#if defined(Py_LIMITED_API) ...`), it is
never passed as a compiler define anywhere (no `-DPy_LIMITED_API`, no `compilerOpts` setting it). This
independently confirms `4ed98143:docs/roadmap/ROADMAP.md:1031-1038`'s own finding, reached from the free-threading
investigation rather than this one: the project does **not** build against the Limited API / Stable
ABI in the `Py_LIMITED_API`-macro sense. "abi3" as used elsewhere in this repository's docs means a
self-imposed rule about which C API functions this project chooses to call (ones stable across
versions), not a `Py_LIMITED_API` compilation mode. So the "Stable ABI permits a header/runtime minor
-mismatch" escape hatch the task brief raised does not apply here — but it is not needed, either,
+mismatch" escape hatch the task brief raised does not apply here, but it is not needed, either,
because the table above shows headers and runtime are never independently sourced in the first place.
**Residual risk, marked as such because it was not run:** the table above is a build-configuration
argument (headers and runtime derive from one variable), corroborated by *observed* `sys.version`-
-equivalent output on desktop and Android and by an existing wasmJs run — not by a from-scratch build
+equivalent output on desktop and Android and by an existing wasmJs run, not by a from-scratch build
of `compileKotlinIosSimulatorArm64`/`compileKotlinAndroidNativeArm64` performed in this pass (this
check reused `docs/roadmap/ROADMAP.md`'s existing, dated build/test evidence rather than re-running Gradle). If a
future change hardcodes a header path or a library name outside the `libVersion`/`targetIncludePath`
variables audited above, this guarantee would silently stop holding; nothing currently tests for that
kind of regression directly (the closest is the version-agnostic assertion in `Python3Test.kt`'s
`versionReportsTheConfiguredRelease`, which catches a runtime drift but not a header-only one, since
-no target here has a runtime check *of the header's declared version* — the headers have no
+no target here has a runtime check *of the header's declared version*, the headers have no
executable presence to assert against).
**Issues (checked 2026-08-16):** the only open issue against this repository is
@@ -367,7 +366,7 @@ Cross-checked against code rather than assumed:
- The "335/336/336/315" difference claim was incorrect. Commit `4ac2eca6` proved that `grep` counted
the numbers wrong: Kotlin block comments nest, and each file carries superseded pre-migration drafts
inside comments, so `grep` scanned them as live code. When counted with comment-aware scanning,
- the difference between `expect` and `actual` members is **zero on every target** — it was empty before
+ the difference between `expect` and `actual` members is **zero on every target**, it was empty before
the commit and remained empty after. The miscount pattern (grep unable to see block nesting) has
recurred three times in this repository (`EmbedAPI` initial draft, two parser canaries); documenting
this as a pattern is valuable to prevent it again. `EmbedAPI.wasmJs.kt` exists too (not one of the
@@ -376,7 +375,7 @@ Cross-checked against code rather than assumed:
Rather than removing deprecated CPython APIs, the repository kept them and marked them `@Deprecated`
in Kotlin: `PyEval_InitThreads` was removed (empty in CPython 3.9+), four functions kept marked
`@Deprecated` without `ReplaceWith` (because exact replacements exist but blind swaps would be wrong
- in ways the compiler cannot catch — three return borrowed vs. new references, one takes an unnormalized
+ in ways the compiler cannot catch, three return borrowed vs. new references, one takes an unnormalized
triple), and three that CPython marks as "discouraged" (not deprecated) were left alone. Commit `4ac2eca6`
adds a test that validates all three targets against a canonical set; the test passes at 466 cases
across all five targets.
@@ -415,7 +414,7 @@ Cross-checked against code rather than assumed:
**Issues (checked 2026-08-16):** two open issues,
[`toolchain#2`](https://github.com/thisisthepy/toolchain/issues/2) ("[Todo] Kotlin Gradle Plugin and Build
-Tools" — a checklist, sub-issue `pypackpack#2`) and
+Tools", a checklist, sub-issue `pypackpack#2`) and
[`toolchain#1`](https://github.com/thisisthepy/toolchain/issues/1) ("[Todo] Toolchain-lite for python-only
users", parent `pypackpack#2`, one line: `tcl install pythonx-compose`). Full checklist-vs-code table is in
§5c. Three corrections to the bullets above, found by reading the current tree (`git log --oneline -12`
@@ -424,7 +423,7 @@ runs through `fb1dba7`, `30a064c`, `f7008eb`) rather than by re-deriving them:
- **The `createMetaClass.kt` generator this file described above no longer exists.** `git log` shows it
deleted in `30a064c` ("Feat: Open the packagePython chain, delegate dependency install, and delete the
meta generator"); `find . -iname "createMetaClass*"` in the working tree returns nothing. The "Observed
- Current State" bullet above describing it is stale as of that commit — left as written per this
+ Current State" bullet above describing it is stale as of that commit, left as written per this
repository's convention of not erasing superseded claims, corrected here instead.
- **"Delete redundant tasks" (this file's own gap item) did not happen by deletion.** `InstallDependenciesTask`,
`BuildPythonArtifactTask` and `AssemblePythonPackageTask` all still exist, under the same names. What
@@ -435,10 +434,10 @@ runs through `fb1dba7`, `30a064c`, `f7008eb`) rather than by re-deriving them:
copy-to-`src/main/assets/python`-then-zip step that lived inline in `PythonPlugin.kt`'s `afterEvaluate`
was deleted (that part of the gap item is accurate), but the task classes were rewired, not removed.
- **"Wire up DSL" is now partially wrong.** `integration()` and `buildTypes` (`debug`/`release` selection
- via `-Ppython.buildType`) are wired as of `fb1dba7` and an earlier commit respectively — confirmed by
+ via `-Ppython.buildType`) are wired as of `fb1dba7` and an earlier commit respectively, confirmed by
reading `PythonPlugin.kt`'s `collectInstallDependencies` (folds `implementations + integrations`) and
`resolveActiveBuildType`. `hotReload`, `codePush`, `buildFeatures` (`metaclass`/`compose`), `metaDirs`,
- `libDirs` and per-variant `platforms` (e.g. Android min-SDK) remain unwired — but `fb1dba7`'s commit
+ `libDirs` and per-variant `platforms` (e.g. Android min-SDK) remain unwired, but `fb1dba7`'s commit
message states this was tested, not assumed: registering `PythonConfiguration.kt`'s extension was tried
and confirmed to compile and do nothing (`usage-example` never imports the DSL package that would reach
it), and the rest have no corresponding concept in `pypackpack` to bind to yet. `PythonConfiguration.kt`
@@ -475,7 +474,7 @@ runs through `fb1dba7`, `30a064c`, `f7008eb`) rather than by re-deriving them:
- **Unverified**:
- Wheel patch generation (`WheelPatchBundler.kt`) and incremental upload logic (placeholder files only).
-**Issues (checked 2026-08-16):** four open issues —
+**Issues (checked 2026-08-16):** four open issues,
[`pypackpack#2`](https://github.com/thisisthepy/pypackpack/issues/2) ("[Todo] PyPackPack Initial
Development", parent `toolchain#2`, subs `pypackpack#1`/`toolchain#1`),
[`pypackpack#1`](https://github.com/thisisthepy/pypackpack/issues/1) ("[Todo] Python Dependency
@@ -484,21 +483,21 @@ Management", parent `pypackpack#2`, sub `pypackpack#5`),
in uv with Platform Markers", help wanted, a how-to guide rather than a checklist), and
[`pypackpack#12`](https://github.com/thisisthepy/pypackpack/issues/12) ("Ambiguous file name due to
duplicated name on entirely codebase", a naming-convention proposal). Full checklist-vs-code table in §5c.
-Several "Gap to Goal" items above are now stale — this repository moved fast in the last two days
-(`git log --oneline -12` runs `488bac0` back through `08353c7`, all dated 2026-08-15/16) — and their commit
+Several "Gap to Goal" items above are now stale, this repository moved fast in the last two days
+(`git log --oneline -12` runs `488bac0` back through `08353c7`, all dated 2026-08-15/16), and their commit
messages are unusually explicit about what they do and do not close, so this agent read each rather than
inferring from diff stats:
- **"Publish to Maven" is done.** `d10ab76` ("Build: Publish packpack, so toolchain can finally depend on
it") added `maven-publish` to `packpack/build.gradle.kts`; the commit message states
`publishToMavenLocal` now produces `org.thisisthepy.python.multiplatform:packpack:0.1.0`. The one caveat
- the same commit records — CLI dependencies (Clikt, Ktor, zstd) leaking onto a library consumer's
- classpath because the CLI lived in the same module — was itself closed one commit later, `488bac0`
+ the same commit records, CLI dependencies (Clikt, Ktor, zstd) leaking onto a library consumer's
+ classpath because the CLI lived in the same module, was itself closed one commit later, `488bac0`
("Build: Split the CLI into its own module"): confirmed by `find . -iname BuildCommand.kt`, which now
resolves under `cli/src/main/kotlin/.../cli/BuildCommand.kt`, not under `packpack/`.
- **"Implement `bundle` stage" is partially done, more precisely than "all four bundlers are
placeholders."** `8d7b4b4` ("Feat: Implement the resource bundle...") implemented `ResourceBundler.kt`
- (310 lines, its own 328-line test file) — the one bundle type `python-multiplatform` and `toolchain`
+ (310 lines, its own 328-line test file), the one bundle type `python-multiplatform` and `toolchain`
need per the commit message. `BinaryBundler.kt`, `FatWheelBundler.kt`, `SingleWheelBundler.kt` and
`WheelPatchBundler.kt` are still placeholders (re-confirmed by this agent, unchanged). But
`ResourceBundler` is not yet reachable from the CLI: `grep -n "ResourceBundler\|resource"
@@ -509,14 +508,14 @@ inferring from diff stats:
Close the three known defects...") added a `registry.properties` file bridging `install`'s
project-relative writes and `find`/`list`/`uninstall`'s `~/.pypackpack/python/` reads. `docs/SPEC.md`
confirms this in its `python install` section. The same section still lists "Final placement after
- download/extraction is incomplete (marked `TODO` in code)" as an open limitation — the registry closes
+ download/extraction is incomplete (marked `TODO` in code)" as an open limitation, the registry closes
the *lookup* mismatch, not this separate placement TODO.
- **"Auto-install build tools" is done.** `isMesonInstalled()` used to be a stub returning `true`
(per `8d7b4b4`'s commit message); `5de8656` made it probe `meson --version`/`ninja --version` for real
and call `Meson.installMeson()` when the probe fails.
- **"CLI pass-through flags" is done, not just started.** `5de8656`'s message states `add`/`remove`/
`sync`/`tree` forward unrecognized flags in all three command shapes, verified through the middleware to
- the per-target `uv` call — `docs/SPEC.md`'s dependency-management section documents the same allowlist
+ the per-target `uv` call, `docs/SPEC.md`'s dependency-management section documents the same allowlist
(`parsePassthroughArgs` in `cli/CommandExtension.kt`) and a known ordering gotcha (`--target`'s greedy
vararg swallows a passthrough flag placed after it).
- **Non-Meson compile backends and bundlers remain placeholders, re-confirmed.** `Clang.kt`, `MSVC.kt`,
@@ -525,7 +524,7 @@ inferring from diff stats:
- **`pypackpack#12`'s file-naming complaint is unresolved.** The suggested rename
(`BaseInterface.kt`/`DefaultInterface.kt` duplicated across `dependency/`, `compile/backend/`,
`compile/middleware/`, `bundle/`, `deploy/` → `FrontendInterface.kt`/`MiddlewareInterface.kt`/
- `BackendInterface.kt`/`DefaultMiddleware.kt`/`DefaultBackend.kt`) has not been applied — `find . -iname
+ `BackendInterface.kt`/`DefaultMiddleware.kt`/`DefaultBackend.kt`) has not been applied, `find . -iname
"*Interface*.kt"` still returns the ambiguous names the issue complains about, unchanged.
- **`pypackpack#5`'s platform-marker guide describes something that already mostly works.** `MarkerPolicy`
(`dependency/middleware/DefaultInterface.kt`) builds `platform_system == '...' and platform_machine ==
@@ -537,12 +536,12 @@ inferring from diff stats:
- **`pypackpack#1`'s "Middleware Refactoring" item ("are `DevEnv`/`CrossEnv` redundant wrappers?") is
still genuinely open**, not stale: reading `DevEnv.kt` now, its methods still follow "resolve project
root → delegate to backend → print success/failure", i.e. still the shape the issue calls a simple
- wrapper. `CrossEnv.kt` (552 lines) has grown well past that shape, though — asymmetric, unresolved by
+ wrapper. `CrossEnv.kt` (552 lines) has grown well past that shape, though, asymmetric, unresolved by
this agent's reading, matches the issue's unchecked box.
-- **`pypackpack#1`'s "settings.gradle.kts — [ ] uv" item could not be resolved to a concrete claim.** The
+- **`pypackpack#1`'s "settings.gradle.kts, [ ] uv" item could not be resolved to a concrete claim.** The
issue text is a single unexplained bullet with no elaboration. `settings.gradle.kts` today has no
mention of `uv` (checked directly); whether that is the intended scope of the checkbox, this agent could
- not determine from the issue alone — left open rather than guessed.
+ not determine from the issue alone, left open rather than guessed.
---
@@ -574,7 +573,7 @@ inferring from diff stats:
- Full hot-reload integration with Jupyter notebook server outside `UI.ipynb` static cells.
**Issues (checked 2026-08-16):** `gh issue list --repo thisisthepy/pythonx-compose --state open` returns
-nothing. There is no issue tracker source for this repository's gap list — everything in "Gap to Goal"
+nothing. There is no issue tracker source for this repository's gap list, everything in "Gap to Goal"
above is this document's own inference from reading the code, not sourced from an issue. Flagged here so
it is not mistaken for the issue-backed items in the other three repos.
@@ -590,13 +589,13 @@ it is not mistaken for the issue-backed items in the other three repos.
- **Language / Build System / Distribution**:
- Pure Python (3.9+).
- `setup.py` (package name `toolchain`, version `3.11.0.1`).
- - PyPI package / Python CLI tool (`pip install git+https://...`).
+ - PyPI package / Python CLI tool (`uv tool install git+https://...`).
- **Goal in Ecosystem**:
- **Legacy reference implementation only.**
- Kept for historical reference to understand pre-2024 CPython cross-compilation and recipe management.
- Replaced entirely by `pypackpack` (cross-compilation and packaging in Kotlin via `uv`/`crossenv`/`meson`) and `toolchain` (Gradle plugin interface).
- **Gap to Goal**:
- - Deprecated/superseded. No active development or gap to close — retained for historical reference only.
+ - Deprecated/superseded. No active development or gap to close, retained for historical reference only.
- **Dependency Direction**:
- None. Independent legacy Python CLI tool.
- **Unverified**:
@@ -604,7 +603,7 @@ it is not mistaken for the issue-backed items in the other three repos.
---
-## 5b. The Python import surface — decided
+## 5b. The Python import surface, decided
Four questions were open about what Python code should look like. They are settled.
@@ -619,8 +618,8 @@ ordinary jar in that set, so it is covered like anything else.
The two producers are therefore split by *what they look at*, not by whether something is
reachable:
- KSP the consumer's own source — declarations it can see being compiled
- artefact walker everything the build resolves — third-party jars, AndroidX included
+ KSP the consumer's own source, declarations it can see being compiled
+ artefact walker everything the build resolves, third-party jars, AndroidX included
Both run at build time under the same applied plugin. An earlier draft of this file claimed
AndroidX could never be reached because KSP does not see it; that conflated one producer's limit
@@ -628,27 +627,27 @@ with the system's.
What genuinely differs per platform is what a *jar* means. On JVM and Android the artefacts are
jars and the walker applies directly. On iOS, androidNative and wasm the artefacts are klibs, and
-whether the same walk is possible there — and what a Kotlin declaration from a klib can be bound
-to at runtime with no JVM underneath — is the open question, not AndroidX.
+whether the same walk is possible there, and what a Kotlin declaration from a klib can be bound
+to at runtime with no JVM underneath, is the open question, not AndroidX.
-**Update — the walker now exists on one path, and both of those questions have answers.**
+**Update, the walker now exists on one path, and both of those questions have answers.**
`python-multiplatform-gradle-plugin` gained `generatePythonArtifactBindings`, and
-`ksp-fixtures/artifact` carries a resolved `junit:junit:4.13.2` through ASM, a generated
+`python-multiplatform-gradle-plugin/fixtures/artifact` carries a resolved `junit:junit:4.13.2` through ASM, a generated
`FunctionTableFragment`, `UpcallTable` and `PythonProxySource` to
`from junit.runner.Version import id` answering `"4.13.2"`. ROADMAP §16 records it in full; three
things in it change what this section says:
- **The walker's fragments are a second aggregator (`ArtifactTable`), not additions to KSP's
- `FunctionTable`.** One `UpcallTable`, one fragment interface, one Python namespace — but
+ `FunctionTable`.** One `UpcallTable`, one fragment interface, one Python namespace, but
`FunctionTable` keeps meaning "every module in this graph compiled with the processor", which is
- what `ksp-fixtures/app` asserts and what a consumer's own table should not silently outgrow.
+ what `python-multiplatform-ksp/fixtures/app` asserts and what a consumer's own table should not silently outgrow.
Install site: `UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)`.
- **ASM alone does not reach `androidx.compose.material3`.** It reaches Java statics and Kotlin
`@JvmStatic`s. A Kotlin *top-level* function compiles onto a package-private multi-file part
(`kotlin/text/StringsKt__IndentKt`) behind a facade Kotlin cannot name, and its extension receiver
is indistinguishable from an ordinary parameter in bytecode. The `@Metadata` *kind* is readable
with ASM; the payload that carries the Kotlin names is `d1`/`d2` and needs `kotlin-metadata-jvm`.
- So AndroidX is reachable, as this section says — one library short of it, not one design short.
+ So AndroidX is reachable, as this section says, one library short of it, not one design short.
- **klib is the easier half, not the harder one.**
`org.jetbrains.kotlin.library.abi.LibraryAbiReader`, already in the `kotlin-compiler-embeddable`
this build pins, read `python-multiplatform-iosX64Main.klib` and returned 441 top-level
@@ -659,17 +658,17 @@ things in it change what this section says:
is an ordinary Kotlin call with no reflection. ~~The open question that remains is not
feasibility but cost: `@ExperimentalLibraryAbiReader`, and a ~60 MB compiler artefact on the
plugin classpath versioned against the consumer's Kotlin rather than the plugin's.~~ **The cost
- question is answered too, and not by shrinking it — by moving it off the plugin's classpath.**
+ question is answered too, and not by shrinking it, by moving it off the plugin's classpath.**
ROADMAP §16e/16f: `KlibScanner` runs inside an isolated Gradle worker
(`WorkerExecutor.scanKlibIsolated`) whose classpath is supplied per build rather than baked into
the plugin jar, because calling it from the plugin's own classloader throws `NoSuchMethodError`
- against a real consumer's `kotlin-util-klib` — a correctness fix that also happens to keep the
+ against a real consumer's `kotlin-util-klib`, a correctness fix that also happens to keep the
~60 MB artefact off a build that never walks a klib. `PythonArtifactBindingsTask` is wired to it
and covered by `KlibScannerTest` against two real klibs.
**Dynamic binding is a removed option, not a missing one.** The 2024 design document
-(*PyComposeUI*, the open-source contest report) specifies a dynamic binder — Java/Kotlin
-Reflection on the JVM target, `ctypes` and `pyobjc` on Kotlin/Native — with a generated meta
+(*PyComposeUI*, the open-source contest report) specifies a dynamic binder, Java/Kotlin
+Reflection on the JVM target, `ctypes` and `pyobjc` on Kotlin/Native, with a generated meta
package existing only so an IDE can infer types against it. That is history. The binder was built
statically instead, and the reason is the same one §7 records for choosing a build-time table:
a GraalVM native image is a closed world and Kotlin/Native has no reflection, so a name that only
@@ -683,18 +682,19 @@ static.
**`pythonx.*` is ours.** Whatever we wrap or add lives under that prefix. Kotlin fully-qualified
names point at the original; `pythonx` points at our Pythonic layer. The two namespaces do not mix.
-*(2026-10-03: "ours" means the ecosystem's — `pythonx` is a real package in pythonx-compose; this
-repository's binder creates no `pythonx` module and renames nothing, `AGENTS.md` §12.1–12.2.)*
+*(2026-10-03: "ours" means the ecosystem's, `pythonx` is a real package in pythonx-compose; this
+repository's binder creates no `pythonx` module and renames no namespace, `AGENTS.md` §12.1–12.2; the
+snake_case aliases it serves live on the Kotlin-named modules, SPEC U-12.)*
**The wrapping happens in Python, not in Kotlin.** `pythonx.compose.material3` is Python code that
uses the generated `androidx.compose.material3` bindings and presents a Pythonic API over them.
-There is no hand-written Kotlin wrapper module in between — that is what the existing
+There is no hand-written Kotlin wrapper module in between, that is what the existing
`io.github.thisisthepy.pycomposeui` `RuntimeKt` and `Runtime_androidKt` are, and they are the shape
being moved away from.
This settles a question that otherwise looks hard. A `@Composable` function is, after compilation,
an ordinary function taking `$composer` and `$changed`; a binding generated from the artefact
-exposes that signature as it is, and the Python wrapper passes the composer as a value — which is
+exposes that signature as it is, and the Python wrapper passes the composer as a value, which is
what `pythonx-compose`'s `Composable` class already does. So nothing on the Kotlin side needs a
`@Composable` callable type, and the upcall trampolines do not have to preserve a `@Composable`
context.
@@ -703,8 +703,8 @@ context.
for every Composable is the cost that made the existing `pythonx-compose` 37 files of which 28 are
empty. The conversions are rules, not per-function decisions: a Pythonic name maps to the original,
the composer and change flags are threaded the same way every time, and a `content=` callable is
-adapted the same way every time. So the layer should resolve on demand — a module `__getattr__`
-that finds the corresponding binding, adapts it once and caches it — rather than enumerate.
+adapted the same way every time. So the layer should resolve on demand, a module `__getattr__`
+that finds the corresponding binding, adapts it once and caches it, rather than enumerate.
That trades away IDE completion, which is exactly why stubs are generated from the same metadata.
*(Corrected 2026-10-03:)* this repository's plugin generates stubs for the Kotlin-named modules,
@@ -714,19 +714,19 @@ under Kotlin names (SPEC B-7); the stubs that carry the Pythonic names and signa
design that was removed from here).
**`JClass`/`JavaClass`, `KClass`/`KotlinClass`, `ObjcClass` work only where the platform has the
-thing they name.** No stubs, no substitutes, no forced uniformity across targets — a target that
+thing they name.** No stubs, no substitutes, no forced uniformity across targets, a target that
has no JVM has no `JClass`, and says so.
**`.pyi` generation belongs to the Gradle plugin**, the way PyREPL did it: read the resolved
artefacts at build time and emit stubs per source set. It is not a sibling of `PythonProxySource`,
which fills `sys.modules` at runtime; these are different products for different consumers, one for
-the interpreter and one for the IDE. Long term that generator belongs to `toolchain` — see §5.
+the interpreter and one for the IDE. Long term that generator belongs to `toolchain`, see §5.
---
## 5c. Issue checklists against the code
-§5 was originally written by reading working copies, and it never opened an issue tracker — this
+§5 was originally written by reading working copies, and it never opened an issue tracker, this
repository's own `docs/roadmap/ROADMAP.md` mentions the other repos zero times, and the same was true of GitHub issues.
That is the gap this section closes. Issues read in full, 2026-08-16:
@@ -738,64 +738,64 @@ That is the gap this section closes. Issues read in full, 2026-08-16:
`pythonx-compose` has none open. Seven issues came back across the other three repos, each read with
`gh issue view --repo `, then checked against the working copy (not against the issue's own
description of itself). "✅ done" below means this agent found the corresponding code and, where the issue
-or a commit message made a narrower claim, checked that narrower claim too — not just that a file with a
+or a commit message made a narrower claim, checked that narrower claim too, not just that a file with a
plausible name exists.
-### `toolchain#2` — "[Todo] Kotlin Gradle Plugin and Build Tools"
+### `toolchain#2`, "[Todo] Kotlin Gradle Plugin and Build Tools"
| Checklist item | Issue's checkbox | Actual state (checked against code) |
|---|---|---|
-| Create the basic structure of the plugin | ☑ checked | ✅ matches — `PythonPlugin.kt` registers the `python` extension and three tasks |
-| Python version setup → Version Enum (alpha, rc, normal) | ☐ unchecked | ❌ matches — `compileSdk` is a plain `String` in `DSLCore.kt`; no enum anywhere |
-| Build target platform setup → platform-specific min-SDK setting | ☐ unchecked | 🟡 partial — `AndroidPlatformExtension.androidSdk: Int` exists in `DSLPlatforms.kt` but no task reads it (per `fb1dba7`'s own commit message: "platforms has a partial hook... mapping them silently would be wrong") |
-| Build target platform setup → check Kotlin-side enabled build target | ☐ unchecked | ❌ matches — no code found that inspects which KMP targets are enabled |
-| Hot reload / Code Push → expose a direct run button | ☐ unchecked | ❌ matches — `HotReloadExtension`/`CodePushExtension` (`DSLPackaging.kt`) are pure data holders; `fb1dba7`: "hotReload and codePush have no backend concept in packpack" |
-| SourceSet setup → `implementation` | ☐ unchecked | ✅ **stale checkbox** — wired since before `fb1dba7`; `collectInstallDependencies` reads `implementations` |
-| SourceSet setup → `integration` | ☐ unchecked | ✅ **stale checkbox** — `fb1dba7` ("Feat: Make integration() install...") folds `integrations` into the same install list; it is treated identically to `implementation`, no `KLIBDEPENS` distinction (deliberately, per the same commit — nothing in `pypackpack` has that concept) |
-| SourceSet setup → etcs (src/resource path, sourceset naming) | ☐ unchecked | ❌ matches — `SourceSetConfig.srcDirs`/`metaDirs`/`libDirs` (`DSLBuild.kt`) accept values via DSL but nothing reads them (`fb1dba7`: "metaDirs and libDirs have nothing in the resource bundler to bind to") |
-| CompileLevel setup (debug/release, project flavors) | ☐ unchecked | 🟡 partial — `debug`/`release` selection via `resolveActiveBuildType`/`-Ppython.buildType` is wired; project flavors (`buildFeatures`) are not (`fb1dba7`: "buildFeatures has no consumer at all") |
-| Automate the build process and integrate with KMP | ☐ unchecked | 🟡 largely done, not fully end-to-end verified by this agent — `buildTask`→`installTask`→`packageTask` chain runs and delegates real work to `pypackpack`'s `ResourceBundler` (`30a064c`); this agent did not run `usage-example` to confirm a full build |
-
-### `toolchain#1` — "[Todo] Toolchain-lite for python-only users"
-
-One line, no checklist: `tcl install pythonx-compose`. ❌ Not started — `find . -iname "*tcl*" -o -iname
+| Create the basic structure of the plugin | ☑ checked | ✅ matches, `PythonPlugin.kt` registers the `python` extension and three tasks |
+| Python version setup → Version Enum (alpha, rc, normal) | ☐ unchecked | ❌ matches, `compileSdk` is a plain `String` in `DSLCore.kt`; no enum anywhere |
+| Build target platform setup → platform-specific min-SDK setting | ☐ unchecked | 🟡 partial, `AndroidPlatformExtension.androidSdk: Int` exists in `DSLPlatforms.kt` but no task reads it (per `fb1dba7`'s own commit message: "platforms has a partial hook... mapping them silently would be wrong") |
+| Build target platform setup → check Kotlin-side enabled build target | ☐ unchecked | ❌ matches, no code found that inspects which KMP targets are enabled |
+| Hot reload / Code Push → expose a direct run button | ☐ unchecked | ❌ matches, `HotReloadExtension`/`CodePushExtension` (`DSLPackaging.kt`) are pure data holders; `fb1dba7`: "hotReload and codePush have no backend concept in packpack" |
+| SourceSet setup → `implementation` | ☐ unchecked | ✅ **stale checkbox**, wired since before `fb1dba7`; `collectInstallDependencies` reads `implementations` |
+| SourceSet setup → `integration` | ☐ unchecked | ✅ **stale checkbox**, `fb1dba7` ("Feat: Make integration() install...") folds `integrations` into the same install list; it is treated identically to `implementation`, no `KLIBDEPENS` distinction (deliberately, per the same commit, nothing in `pypackpack` has that concept) |
+| SourceSet setup → etcs (src/resource path, sourceset naming) | ☐ unchecked | ❌ matches, `SourceSetConfig.srcDirs`/`metaDirs`/`libDirs` (`DSLBuild.kt`) accept values via DSL but nothing reads them (`fb1dba7`: "metaDirs and libDirs have nothing in the resource bundler to bind to") |
+| CompileLevel setup (debug/release, project flavors) | ☐ unchecked | 🟡 partial, `debug`/`release` selection via `resolveActiveBuildType`/`-Ppython.buildType` is wired; project flavors (`buildFeatures`) are not (`fb1dba7`: "buildFeatures has no consumer at all") |
+| Automate the build process and integrate with KMP | ☐ unchecked | 🟡 largely done, not fully end-to-end verified by this agent, `buildTask`→`installTask`→`packageTask` chain runs and delegates real work to `pypackpack`'s `ResourceBundler` (`30a064c`); this agent did not run `usage-example` to confirm a full build |
+
+### `toolchain#1`, "[Todo] Toolchain-lite for python-only users"
+
+One line, no checklist: `tcl install pythonx-compose`. ❌ Not started, `find . -iname "*tcl*" -o -iname
"*lite*"` in the `toolchain` working copy (excluding `.git`/`build`) returns nothing.
-### `pypackpack#2` — "[Todo] PyPackPack Initial Development"
+### `pypackpack#2`, "[Todo] PyPackPack Initial Development"
| Checklist item | Issue's checkbox | Actual state (checked against code) |
|---|---|---|
-| Create the basic structure of the plugin | ☐ unchecked | (parent bullet, not independently checkable — see sub-items) |
-| Python version setup → Version Enum | ☐ unchecked | ❌ same as `toolchain#2` — no enum found in either repo |
+| Create the basic structure of the plugin | ☐ unchecked | (parent bullet, not independently checkable, see sub-items) |
+| Python version setup → Version Enum | ☐ unchecked | ❌ same as `toolchain#2`, no enum found in either repo |
| Build target platform setup → platform-specific min-SDK | ☐ unchecked | ❌ not found in `pypackpack` |
-| Build target platform setup → check Kotlin-side enabled build target | ☑ checked | 🟡 not independently verified by this agent — no corroborating code found in the time available; recorded as unverified rather than disputed |
+| Build target platform setup → check Kotlin-side enabled build target | ☑ checked | 🟡 not independently verified by this agent, no corroborating code found in the time available; recorded as unverified rather than disputed |
| Hot reload / Code Push → run button | ☐ unchecked | ❌ matches |
-| SourceSet setup → implementation (python-only) | ☐ unchecked | 🟡 the underlying dependency-add path exists (`DependencyBackend.addDependencies`), but this is `toolchain`'s DSL concept, not `pypackpack`'s — checkbox sits on the wrong side of the boundary the ecosystem doc draws in §1 |
-| SourceSet setup → integration (python+kotlin mixed) | ☐ unchecked | ❌ matches — no `KLIBDEPENS` handling anywhere in `pypackpack` (grepped, zero hits) |
-| External tool detection → Nuitka | ☐ unchecked | ❌ matches — `Nuitka.kt` is 4 lines |
-| External tool detection → Host Python | ☑ checked | ✅ matches — `python install` downloads a prebuilt CPython from `python-multiplatform`'s GitHub releases (`docs/SPEC.md`, confirmed) |
-| External tool detection → MSVC, Clang | ☐ unchecked | ❌ matches — both 4-line placeholders |
-| External tool detection → Poetry or UV | ☑ checked | ✅ matches — `dependency/backend/external/UV.kt` is a real, exercised integration |
-| External tool detection → Crossenv or equivalent | ☐ unchecked | 🟡 partial — `CrossEnv.kt` exists at 552 lines and is exercised by `add`/`sync`/`tree` per-target commands, but "creating a dedicated venv per target" is explicitly listed as not-yet-implemented in `docs/SPEC.md` |
-| Compilation modes → instant (pure python) | ☑ checked | 🟡 not verified as a selectable mode — `docs/SPEC.md` states the `--level` CLI flag is "accepted but ignored" for `build`, i.e. there's exactly one behavior today, which happens to look like `instant`, not a selection mechanism |
-| Compilation modes → bytecode/mixed/native | ☐ unchecked | ❌ matches — `docs/SPEC.md` lists all three as "Not yet implemented (target)" |
+| SourceSet setup → implementation (python-only) | ☐ unchecked | 🟡 the underlying dependency-add path exists (`DependencyBackend.addDependencies`), but this is `toolchain`'s DSL concept, not `pypackpack`'s, checkbox sits on the wrong side of the boundary the ecosystem doc draws in §1 |
+| SourceSet setup → integration (python+kotlin mixed) | ☐ unchecked | ❌ matches, no `KLIBDEPENS` handling anywhere in `pypackpack` (grepped, zero hits) |
+| External tool detection → Nuitka | ☐ unchecked | ❌ matches, `Nuitka.kt` is 4 lines |
+| External tool detection → Host Python | ☑ checked | ✅ matches, `python install` downloads a prebuilt CPython from `python-multiplatform`'s GitHub releases (`docs/SPEC.md`, confirmed) |
+| External tool detection → MSVC, Clang | ☐ unchecked | ❌ matches, both 4-line placeholders |
+| External tool detection → Poetry or UV | ☑ checked | ✅ matches, `dependency/backend/external/UV.kt` is a real, exercised integration |
+| External tool detection → Crossenv or equivalent | ☐ unchecked | 🟡 partial, `CrossEnv.kt` exists at 552 lines and is exercised by `add`/`sync`/`tree` per-target commands, but "creating a dedicated venv per target" is explicitly listed as not-yet-implemented in `docs/SPEC.md` |
+| Compilation modes → instant (pure python) | ☑ checked | 🟡 not verified as a selectable mode, `docs/SPEC.md` states the `--level` CLI flag is "accepted but ignored" for `build`, i.e. there's exactly one behavior today, which happens to look like `instant`, not a selection mechanism |
+| Compilation modes → bytecode/mixed/native | ☐ unchecked | ❌ matches, `docs/SPEC.md` lists all three as "Not yet implemented (target)" |
| Build Tools → configure Nuitka | ☐ unchecked | ❌ matches |
| Build Tools → compatibility testing with Android | ☐ unchecked | ❌ not found |
-| Build Tools → minification | ☐ unchecked | ❌ matches — `compile/middleware/minification/` has only the base interface |
+| Build Tools → minification | ☐ unchecked | ❌ matches, `compile/middleware/minification/` has only the base interface |
-### `pypackpack#1` — "[Todo] Python Dependency Management"
+### `pypackpack#1`, "[Todo] Python Dependency Management"
| Checklist item | Issue's checkbox | Actual state (checked against code) |
|---|---|---|
-| Handle `pyproject.toml` → lossless modification | ☑ checked | ✅ matches — `utils/toml/TomlEditor.kt` + `TomlValue.kt` exist and are exercised (SPEC.md's `target add/remove`, `package remove` sections describe editing specific keys in place) |
-| Fix `CrossEnv.kt` abstraction → encapsulate UV logic in backend | ☑ checked | ✅ plausible, not fully re-derived — `CrossEnv.kt`'s target-marker logic (`MarkerPolicy`) and per-target `uv` calls live in the middleware/backend layers this item describes; this agent did not diff against a pre-fix version to confirm the decoupling directly |
-| Middleware Refactoring → is `DevEnv`/`CrossEnv` still a redundant wrapper? | ☐ unchecked | ✅ matches, genuinely still open — `DevEnv.kt` (240 lines) still follows "resolve project root → delegate to backend → print result" for every method, the shape the issue questions. `CrossEnv.kt` (552 lines) has grown well past a simple wrapper, which is itself an argument the two are no longer symmetric — unresolved either way |
-| Target Platforms → research a cleaner architecture for platform-specific deps via UV | ☐ unchecked | 🟡 partial — `MarkerPolicy`/`Platforms.describeTarget` (`utils/Platforms.kt`) already implement a `platform_system`/`platform_machine` marker scheme and thread `--python-platform` through `tree`/`sync`, i.e. a pattern exists; whether it counts as the "research" this item asks for is a judgment call this agent did not make either way |
-| `settings.gradle.kts` → `uv` | ☐ unchecked | ❓ could not resolve — the issue gives no elaboration beyond the single word "uv"; `settings.gradle.kts` today has no `uv` reference at all, but this agent could not determine what behavior the checkbox is asking for, so this is left open rather than guessed |
+| Handle `pyproject.toml` → lossless modification | ☑ checked | ✅ matches, `utils/toml/TomlEditor.kt` + `TomlValue.kt` exist and are exercised (SPEC.md's `target add/remove`, `package remove` sections describe editing specific keys in place) |
+| Fix `CrossEnv.kt` abstraction → encapsulate UV logic in backend | ☑ checked | ✅ plausible, not fully re-derived, `CrossEnv.kt`'s target-marker logic (`MarkerPolicy`) and per-target `uv` calls live in the middleware/backend layers this item describes; this agent did not diff against a pre-fix version to confirm the decoupling directly |
+| Middleware Refactoring → is `DevEnv`/`CrossEnv` still a redundant wrapper? | ☐ unchecked | ✅ matches, genuinely still open, `DevEnv.kt` (240 lines) still follows "resolve project root → delegate to backend → print result" for every method, the shape the issue questions. `CrossEnv.kt` (552 lines) has grown well past a simple wrapper, which is itself an argument the two are no longer symmetric, unresolved either way |
+| Target Platforms → research a cleaner architecture for platform-specific deps via UV | ☐ unchecked | 🟡 partial, `MarkerPolicy`/`Platforms.describeTarget` (`utils/Platforms.kt`) already implement a `platform_system`/`platform_machine` marker scheme and thread `--python-platform` through `tree`/`sync`, i.e. a pattern exists; whether it counts as the "research" this item asks for is a judgment call this agent did not make either way |
+| `settings.gradle.kts` → `uv` | ☐ unchecked | ❓ could not resolve, the issue gives no elaboration beyond the single word "uv"; `settings.gradle.kts` today has no `uv` reference at all, but this agent could not determine what behavior the checkbox is asking for, so this is left open rather than guessed |
-### `pypackpack#5` — "Managing Multi-Platform Dependencies in uv with Platform Markers" (help wanted, guide not checklist)
+### `pypackpack#5`, "Managing Multi-Platform Dependencies in uv with Platform Markers" (help wanted, guide not checklist)
-Not a checklist — a how-to document proposing `platform_system`/`platform_machine` PEP 508 markers per
+Not a checklist, a how-to document proposing `platform_system`/`platform_machine` PEP 508 markers per
target. 🟡 **largely already matches the shipped design**: `MarkerPolicy.markerForTarget` in
`dependency/middleware/DefaultInterface.kt` builds exactly `platform_system == '...' and platform_machine
== '...'`, and `--python-platform` is passed to `uv tree`/`uv add` per target. The one place code and guide
@@ -804,7 +804,7 @@ come back as `platform_machine`/`sys_platform` rather than `platform_system`/`pl
breaks `remove --target`'s re-derived-marker matching for some dependencies. So the guide's approach is
implemented, with one recorded, unfixed bug in marker-round-tripping.
-### `pypackpack#12` — "Ambiguous file name due to duplicated name on entirely codebase" (question, not checklist)
+### `pypackpack#12`, "Ambiguous file name due to duplicated name on entirely codebase" (question, not checklist)
❌ **Not addressed.** The issue proposes renaming `BaseInterface.kt`/`DefaultInterface.kt` (duplicated
across `dependency/`, `compile/backend/`, `compile/middleware/`, `bundle/`, `deploy/`) to
@@ -812,7 +812,7 @@ across `dependency/`, `compile/backend/`, `compile/middleware/`, `bundle/`, `dep
`DefaultBackend.kt`. `find . -iname "*Interface*.kt"` in the current tree still shows `BaseInterface.kt`
and `DefaultInterface.kt` repeated in all five domains named in the issue, unchanged.
-### `python-multiplatform#4` — "Add Python/C API expect declaration reclassification and actual definitions"
+### `python-multiplatform#4`, "Add Python/C API expect declaration reclassification and actual definitions"
No checklist, two prose tasks. ✅ **Closed** by commit `4ac2eca6` ("Fix: Remove the drafts stranded in
comments, and reclassify what CPython deprecated"). The "20-member gap" this document had reported
@@ -834,13 +834,13 @@ each repository's block). Two kinds of mismatch, kept separate rather than merge
`pypackpack#1`'s `DevEnv`/`CrossEnv` redundancy question and its unexplained `settings.gradle.kts`/`uv`
item; `toolchain#2`'s Python-version-enum and Kotlin-target-detection sub-items; `pypackpack#2`'s
compile-mode checklist (`instant`/`bytecode`/`mixed`/`native`) and Android-build-compatibility item.
- These were not in this document before this pass — they are what "renders §5 incomplete" in the sense
+ These were not in this document before this pass, they are what "renders §5 incomplete" in the sense
the task described, now folded into each repository's "Issues" note above rather than duplicated again
here.
- **In §5's original gap lists, absent from any issue**: `PythonMultiplatform`'s `@Composable`-callable-shape
- gap (already closed per §4 item 1, and never was a `python-multiplatform` issue — it is `pythonx-compose`'s
+ gap (already closed per §4 item 1, and never was a `python-multiplatform` issue, it is `pythonx-compose`'s
problem surfacing in this repo's code), its `sys.meta_path` lazy-import gap, and all of `pythonx-compose`'s
- "Gap to Goal" list. These are this document's own judgment calls, not sourced from a tracker — flagged in
+ "Gap to Goal" list. These are this document's own judgment calls, not sourced from a tracker, flagged in
the `pythonx-compose` "Issues" note above since that repository has zero open issues to source anything
from.
@@ -851,3 +851,24 @@ each repository's block). Two kinds of mismatch, kept separate rather than merge
It describes five repositories and sits in one of them, because that is the only one with an
active working copy and a documentation habit. If the organisation grows a place for cross-repo
documents, this belongs there, and what stays here is the last section's first entry.
+
+## 7. Priorities and the pythonx family (decided 2026-10-04)
+
+The maintainer focuses on darkpyonix for a while; thisisthepy proceeds **linearly** in this order:
+torchnative, then python-multiplatform (TypedPython included), then pypackpack and toolchain-lite,
+then pythonx-compose, then the other pythonx packages and Gemstone. Each repository first records its
+decisions as issues, finishes document updates and folder cleanup, and lists what is left until its
+next release; lower-priority repositories then hold new heavy work until their turn.
+
+| Package | Decision | Where it is recorded |
+|---|---|---|
+| `pythonx-concurrent` | Structured concurrency bridging asyncio and Kotlin coroutines. Spec written in detail, implementation deferred. Python coroutines may run on JVM virtual threads and on any Kotlin dispatcher by being stepped from Kotlin ("Kotlin mode"); blocking-style Python on virtual threads is not offered. | its `docs/SPEC.md` (§7); binder requests #164, #161, #162 |
+| `pythonx-platform` | Device features from Python (plyer-like): camera, sensors, notifications, file picker, permissions. The Kotlin implementations belong in `compose-multiplatform-core-extended` as multiplatform `androidx.*` libraries; Python reaches them through the binder's names (snake_case aliases included); `pythonx-platform` is at most a thin Pythonic layer. Repository and spec to be created in its turn. | OS notifications: compose-multiplatform-core-extended#13 |
+| `pythonx-game` | Direction open. Recommended, not measured: Filament as the 3D renderer (scene level, the height of three.js, PBR and glTF, Vulkan/Metal/GL, Android Kotlin API, Apache-2.0) with a Python declarative layer on top; Compose's Skia canvas for 2D; wgpu only where custom GPU compute is needed (its per-call validation costs CPU, and it is a GPU API, not a scene engine). | this section |
+| `pythonx-native` | Not a repository. Building Rust crates as Python extension modules for the app is pypackpack's Cargo backend; Kotlin reaches the crate through python-multiplatform. | pypackpack#65 |
+
+TypedPython decisions of the same day: GC collection timing is outside the CPython-equivalence
+guarantee (option (b), SPEC N-12); the gap-closing items of the Cython/numba/LPython comparison are
+approved (#147 to #151, #143); open decisions are #152 (yielding the GIL in array loops), #153
+(fastmath opt-in) and whether the first usable release may compile on desktop only. Free-threaded
+readiness across repositories is #158 (GIL stays the default until decided), with #159 to #163.
diff --git a/docs/design/kotlin-extensions-in-python.md b/docs/design/kotlin-extensions-in-python.md
index fe2da0d67..33cb62f61 100644
--- a/docs/design/kotlin-extensions-in-python.md
+++ b/docs/design/kotlin-extensions-in-python.md
@@ -2,14 +2,14 @@
Compose is the first library whose API this binder has to reach that is *mostly* extension
functions over value-class parameters. `Modifier.padding(16.dp)` is the shape, and neither half of
-it — the extension receiver nor the `Dp` — survives into JVM bytecode as itself.
+it, the extension receiver nor the `Dp`, survives into JVM bytecode as itself.
This file records what the Compose artefacts actually contain, what the artefact walker binds from
them today, and what the Python surface should look like. Everything under "Measured" was counted
from real jars on 2026-08-14 and the commands are reproducible from §1. Everything under a
"Judged" heading is a decision, and the alternatives that were rejected are named.
-## Status — what this document has been overtaken by, and where
+## Status, what this document has been overtaken by, and where
This file was written at commit `f378ce87` (2026-08-14 15:28). Two commits landed within the hour
that invalidated parts of it. Nothing measured here has been deleted; each superseded claim is left
@@ -26,7 +26,7 @@ in place with what replaced it, because the old measurement is why the replaceme
| §1, the Compose version | 1.6.11 | the measurements here were taken on 1.6.11 and are kept as such; the build now pins Compose Multiplatform **1.11.1** (`gradle/libs.versions.toml`, `compose-plugin`), with material3 1.9.0 |
| §3.2, 43 `Modifier` extensions declined for a function-typed parameter | "a real boundary limit" | function-typed slots bind now (SPEC B-5); `ArtifactScanner`'s KDoc records **157 `Modifier` extensions bound and 8 declined** after `functionSlotOrNull` (not re-measured for this revision) |
| §3.1/§6, where the overload dispatcher lives | "in `pythonx`" | in **this repository's** binding layer, `python_multiplatform.binding` (`_Overloads` in `PythonxAdapter.kt`), which serves the base name (`padding`) and selects among the `name__` entries (SPEC U-8) |
-| §4.1, primitive receivers as `pythonx.compose.ui.unit.to_dp(x)` | snake_case, under `pythonx` | the binder serves Kotlin names only (`docs/INTENT.md` §2.2): such an extension is a module-level function on its Kotlin-named module, receiver first. A Pythonic spelling is pythonx-compose's |
+| §4.1, primitive receivers as `pythonx.compose.ui.unit.to_dp(x)` | under `pythonx` | the binder renames no namespace (`docs/INTENT.md` §2.2): such an extension is a module-level function on its Kotlin-named module, receiver first, reachable as `toDp` and as its Pythonic alias `to_dp` (SPEC U-12) |
| §4.4, the allowlist | decided, not built | built at run time in the binding layer: `_VALUE_CLASS_ALLOWLIST` starts **empty** and pythonx-compose fills it through `allow_raw_primitive` (`PythonxAdapter.kt`) |
| §4.5, `.pyi` with `@overload` and `Dp \| float` | proposed for this plugin | the plugin emits Kotlin-named stubs with boundary types only (SPEC B-7, `docs/design/pyi-generation-design.md`); the Pythonic stub is pythonx-compose's. §4.5 is replaced below; the original is in [`docs/archive/kotlin-extensions-in-python-pyi.md`](../archive/kotlin-extensions-in-python-pyi.md) |
| §4.6, composables | "remain blocked" | callable from Python inside a composition (SPEC B-6); see §4.6's update |
@@ -63,13 +63,13 @@ jars. Sanity check that the harness works: the same binary returns 9 entries fro
**§3.2's re-measurement does not use this harness.** A standalone copy has to be re-copied every time
the walker changes, and it was already stale by the time §3's zero was recorded. The replacement is
`ArtifactScannerTest.composeModifierExtensionsSurviveBothGates`, which runs *the* `ArtifactScanner`
-against the same cached jars on every build — no copy, and no way for the number to go unrecounted.
+against the same cached jars on every build, no copy, and no way for the number to go unrecounted.
It walks 6 of the 19 jars rather than all of them; §3.2 says which, and why the two counts are not
comparable.
---
-## 2. Measured — the size of the surface
+## 2. Measured, the size of the surface
### 2.1 Overall
@@ -90,9 +90,9 @@ comparable.
| … public | 251 | 252 |
| … internal | 40 | 37 |
| … private | 39 | 32 |
-| public **top-level** (the chainable API) | **177** | — |
-| … mangled | **52 (29.4%)** | — |
-| public **member** extensions (need a dispatch receiver) | 74, over 32 owner classes | — |
+| public **top-level** (the chainable API) | **177** | - |
+| … mangled | **52 (29.4%)** | - |
+| public **member** extensions (need a dispatch receiver) | 74, over 32 owner classes | - |
The two ecosystems agree to within one function, so the numbers are a property of Compose and not
of one packaging of it.
@@ -125,7 +125,7 @@ Metadata arity excludes the synthetic parameters; the JVM descriptor does not.
low.** `docs/archive/pythonx-adapter-design.md` §5.2 found this by re-counting with `javap -p`; the count
has since been reproduced a third time, independently, against the same 1.6.11 jars. What was
missed is **`$default`**. The trailing JVM parameters of `Button` are `Composer, int, int` and of
-`Text-fLXpl1I` are `Composer, int, int, int` — `$composer`, one or two `$changed` masks, and a
+`Text-fLXpl1I` are `Composer, int, int, int`, `$composer`, one or two `$changed` masks, and a
`$default` mask.
`$default` is easy to miss because Compose does not carry it the way the rest of Kotlin does.
@@ -136,8 +136,8 @@ itself instead. Reproduce with:
unzip -o -q "$GRADLE_CACHE/.../material3-desktop-1.6.11.jar" 'androidx/compose/material3/ButtonKt.class'
javap -p androidx/compose/material3/ButtonKt.class
-The conclusion drawn from this table — *never derive arity from a descriptor; metadata arity is the
-source* (§4.6) — is unaffected, and the correction strengthens it: the error was made by counting
+The conclusion drawn from this table, *never derive arity from a descriptor; metadata arity is the
+source* (§4.6), is unaffected, and the correction strengthens it: the error was made by counting
descriptors by hand, which is the exact failure the rule forbids a machine to make. The `+2/+3/+4/+5`
distribution in §2.6 was **not** re-derived and may be shifted the same way; treat it as unconfirmed.
@@ -145,15 +145,15 @@ distribution in §2.6 was **not** re-derived and may be shifted the same way; tr
`Button`, `Card`, `Column`, `Row` are not mangled because none of their parameters is.
**`Text--4IGK_g` appears twice in the same class with different descriptors.** That is not an
-anomaly — across corpus A, **108 mangled JVM names are ambiguous within their own class**. The
+anomaly, across corpus A, **108 mangled JVM names are ambiguous within their own class**. The
mangling suffix is a hash of the *value-class parameter signature only*, so it is shared by
unrelated functions: `-3ABfNKs` is worn by `padding`, `size`, `width`, `height`, `requiredSize`,
`requiredWidth`, `requiredHeight`, `times` and `surfaceColorAtElevation`, all of which take a
single `Dp`; `-VpY3zN4` is worn by thirteen functions taking two `Dp`s.
This retires an approach rather than complicating it. Any binder that *looks a function up by
-name* — Java reflection, a Python `__dict__` sweep, the 2024 `pythonx-compose` probe that scanned
-`ButtonKt.__dict__` for keys beginning `"Button-"` — cannot address a Compose function
+name*, Java reflection, a Python `__dict__` sweep, the 2024 `pythonx-compose` probe that scanned
+`ButtonKt.__dict__` for keys beginning `"Button-"`, cannot address a Compose function
unambiguously even in principle. This binder does not look functions up by name: it emits Kotlin
source that says `Modifier.padding(16.dp)` and lets `kotlinc` do the mangling. The mangled name
never appears in anything we generate.
@@ -187,7 +187,7 @@ under raw-number coercion, is wrong twice over. `16.sp` produces a `TextUnit`, a
The high word is the unit tag (`1` = Sp, `2` = Em, `0` = Unspecified) and the low word is the raw
float bits. A raw Python `16` handed to a `TextUnit` parameter therefore has tag `0` and decodes
-as `TextUnit.Unspecified` — not "16 of the wrong unit", but *silently no value at all*.
+as `TextUnit.Unspecified`, not "16 of the wrong unit", but *silently no value at all*.
Only **three** value classes in all of Compose wrap a `Float`: `Dp`, `text.style.BaselineShift`,
and `LineHeightStyle.Alignment`. Ambiguity between float-shaped units is a non-problem; packing is
@@ -208,12 +208,12 @@ occurrence: `Dp` 56, `Role` 11, `Color` 8, `TextUnit` 4, `TransformOrigin` 4, `D
`BlurredEdgeTreatment` 2, `MarqueeAnimationMode` 1, `CompositingStrategy` 1, `PointerEventType` 1.
**`Dp` alone is 62% of all value-class parameter occurrences on `Modifier`.**
-### 2.5 Name collisions — far rarer than assumed, and the first count was wrong
+### 2.5 Name collisions, far rarer than assumed, and the first count was wrong
Counted naively over *every* public extension function, 41 `(receiver, name)` pairs appear in more
than one package, which would have made a "require qualification on collision" rule
-unaffordable. That count is an artefact. Most of it is **member** extension functions — `Dp.toPx`
-is declared inside the `Density` interface, `Modifier.align` inside `RowScope` — which metadata
+unaffordable. That count is an artefact. Most of it is **member** extension functions, `Dp.toPx`
+is declared inside the `Density` interface, `Modifier.align` inside `RowScope`, which metadata
records again in every implementing class. They are not competing top-level declarations and
Kotlin's import scope never sees them as such.
@@ -243,7 +243,7 @@ costs five names in the whole library.
descriptor; matching on the substring `Composable` inflates this to 524 by catching
`ComposableTarget` and friends). 183 are mangled.
-Every one of them gains synthetic JVM parameters — `$composer`, one or two `$changed` bitmasks,
+Every one of them gains synthetic JVM parameters, `$composer`, one or two `$changed` bitmasks,
zero to two `$default` bitmasks:
+2 params : 80 +3 params : 349 +4 params : 62 +5 params : 9
@@ -261,12 +261,12 @@ disjoint problems.
---
-## 3. Measured — what the artefact walker bound from Compose, and what it binds now
+## 3. Measured, what the artefact walker bound from Compose, and what it binds now
**This section is kept as it was measured, because the zero is why the work that follows it
happened.** It describes commit `f378ce87` (2026-08-14 15:28). Two gates are named in the funnel
below and a third in §3.1; all three are gone, and §3.2 is the re-measurement. Read §3 and §3.1 as
-the diagnosis and §3.2 as the outcome — not as a description of the walker today.
+the diagnosis and §3.2 as the outcome, not as a description of the walker today.
**Zero. From all 19 jars of corpus A, and all 19 of corpus B.**
@@ -291,12 +291,12 @@ Two independent gates each zero Compose on their own.
**The metadata-kind gate.** A Kotlin top-level function compiles into a file facade (`k=2`) or a
multi-file part (`k=5`), and the walker declines both because their JVM names are not names Kotlin
-can write. That drops **all 1,411 public top-level Compose functions**, mangled or not —
+can write. That drops **all 1,411 public top-level Compose functions**, mangled or not,
`ButtonKt.Button` and `PaddingKt.padding` alike. The entire top-level Compose API is behind this
one gate.
-> **Closed by `15fc5a62`, 25 minutes after this was written.** The gate was one line —
-> `kotlinMetadataKind(node)?.let { if (it != KOTLIN_KIND_CLASS) return emptyList() }` — and it is
+> **Closed by `15fc5a62`, 25 minutes after this was written.** The gate was one line,
+> `kotlinMetadataKind(node)?.let { if (it != KOTLIN_KIND_CLASS) return emptyList() }`, and it is
> gone: `ArtifactScanner.scanClassNode`'s `when` now dispatches on the decoded
> `KotlinClassMetadata`, with arms for `FileFacade` (`k=2`) and `MultiFileClassFacade` (`k=4`,
> which fetches each part by name and reads the part's own `kmPackage`). The `KOTLIN_KIND_CLASS`
@@ -312,11 +312,11 @@ something that is not a primitive, `String`, `byte[]` or `void`.
> `python.multiplatform.reflection.TypeTag.OBJECT` **already existed**, and so did the entire
> runtime path behind it: `UpcallTrampoline.marshalResult` already put a returned Kotlin object into
> `HandleTable` and handed Python the integer, and `toKotlinObject` already resolved it back. All of
-> that predates this document — it is checkable in the tree at `f378ce87`. What the *walker* lacked
+> that predates this document, it is checkable in the tree at `f378ce87`. What the *walker* lacked
> was not a marshalling category but a **Kotlin type name to cast the handle to**, which a JVM
> descriptor genuinely cannot supply (`Ljava/util/List;` has no Kotlin spelling Kotlin will accept
-> written out). `@Metadata`'s classifier *is* that name — `androidx/compose/ui/Modifier`, `/` for
-> `.` and nothing else to guess — so the object case lives in `resolveKotlinType`
+> written out). `@Metadata`'s classifier *is* that name, `androidx/compose/ui/Modifier`, `/` for
+> `.` and nothing else to guess, so the object case lives in `resolveKotlinType`
> (`KotlinMetadata.kt`) and the descriptor-only `boundaryTypeOf` still has no `OBJECT` arm, on
> purpose. The gate was real; "no boundary type exists" was not the reason it was shut.
@@ -324,7 +324,7 @@ something that is not a primitive, `String`, `byte[]` or `void`.
it, but the 190 it lets through die at the next gate anyway. This matters for how the gap is
described: the mangling filter is being replaced right now by metadata-driven understanding, and
when it is gone the Compose number will still be zero until the facade gate and the type gate move
-too. The 52-of-177 mangling figure in §2.2 is therefore not a measure of what is unreachable — it
+too. The 52-of-177 mangling figure in §2.2 is therefore not a measure of what is unreachable, it
is a measure of **how much surface the metadata work opens that a descriptor-only walker could
never have named**.
@@ -351,22 +351,22 @@ that costs **33 of 130 names**, and the casualty list is the API's centre of gra
scale scrollable selectable shadow size toggleable transformable triStateToggleable width
Overload dispatch is a larger problem than name collision (§2.5: five cases) and is unsolved here.
-Python's own answer — one function that inspects its arguments — is available because the Python
+Python's own answer, one function that inspects its arguments, is available because the Python
side is a dispatcher, not a one-to-one mirror; see §4.4.
-**Solved by `f455ef41` — by distinguishing, not by choosing.** The principle that made the drop-rule
+**Solved by `f455ef41`, by distinguishing, not by choosing.** The principle that made the drop-rule
right survives intact and is worth restating, because it is what rules out the obvious fix: nothing
may arbitrate. `org.junit.Assert.assertEquals` has eight bindable overloads and a sort order picks
the deprecated `(double, double)` that always fails, so `assertEquals(3, 3)` from Python would
silently call the one that cannot work. That is still refused.
What the drop-rule did not have was a way to *tell the overloads apart*: with only JVM descriptors
-there is no Kotlin parameter type to name one by, and the JVM name cannot do it either — §2.3 counts
+there is no Kotlin parameter type to name one by, and the JVM name cannot do it either, §2.3 counts
108 mangled JVM names that are ambiguous inside their own class. `@Metadata` supplies the declared
Kotlin types, so `ArtifactScanner.disambiguateOverloads` now gives each member of a set a name of its
own:
-- a group of **one** keeps the bare Kotlin name — always, so a bare name still means exactly one
+- a group of **one** keeps the bare Kotlin name, always, so a bare name still means exactly one
declaration;
- a group of more than one gets `name__`, under the **first of three schemes that separates
the group**: the simple names of the declared value parameters; then the receiver joined to them
@@ -375,7 +375,7 @@ own:
- a group **no scheme separates** is still dropped. The old rule survives as the floor.
`padding` comes out as `padding__Dp`, `padding__Dp_Dp`, `padding__Dp_Dp_Dp_Dp` and
-`padding__PaddingValues` — pinned by
+`padding__PaddingValues`, pinned by
`ArtifactScannerTest.composeModifierExtensionsSurviveBothGates`. The receiver is left out of the
suffix unless it is what separates the group, since spelling every `Modifier` extension
`padding__Modifier_Dp` restates the one parameter the reader already knows from where the name is
@@ -388,7 +388,7 @@ because an overload set is a property of a *package* and Kotlin lets one live in
`ClassNode`s, one Kotlin name, invisible to a per-class grouping, which would have emitted both under
the same table key.
-The dispatcher §4.4 asks for is still wanted and still cannot live in the table — `UpcallTable` is
+The dispatcher §4.4 asks for is still wanted and still cannot live in the table, `UpcallTable` is
keyed by name and `ExposedCallable` carries one fixed arity, so one name reaches one signature by
construction. ~~It belongs in `pythonx` (`docs/archive/pythonx-adapter-design.md` §4.1) and selects
among these names.~~ **Current:** it lives in this repository's binding layer,
@@ -398,7 +398,7 @@ selects among these names; the table's job is still to make that choice *possibl
### 3.2 Re-measured at `958c0082`: 314
The successor to the zero above, out of the same Gradle cache and the same
-`org.jetbrains.compose.*` 1.6.11 release — though over a subset of the jars, which is why the counts
+`org.jetbrains.compose.*` 1.6.11 release, though over a subset of the jars, which is why the counts
below are not the ones in §2 with different values:
compose: 314 declarations bound, 104 of them Modifier extensions
@@ -414,15 +414,15 @@ here: 18 tests, 0 failures.
| | §2 (corpus A) | §3.2 |
|---|---|---|
-| jars walked | 19, every `org.jetbrains.compose.*` the cache held | **6** — `foundation-layout`, `ui-unit`, `foundation`, `ui`, `material`, `material3`, all 1.6.11 desktop |
-| jars on the resolution classpath | — | 15 (the six plus `ui-geometry`, `ui-graphics`, `ui-text`, `ui-util`, `runtime`, `runtime-saveable`, `animation-core`, `collection`, `annotation`, and `kotlin-stdlib`) |
+| jars walked | 19, every `org.jetbrains.compose.*` the cache held | **6**, `foundation-layout`, `ui-unit`, `foundation`, `ui`, `material`, `material3`, all 1.6.11 desktop |
+| jars on the resolution classpath | - | 15 (the six plus `ui-geometry`, `ui-graphics`, `ui-text`, `ui-util`, `runtime`, `runtime-saveable`, `animation-core`, `collection`, `annotation`, and `kotlin-stdlib`) |
| package filter | none | `androidx.compose` |
| unit counted | **declarations**, and for `Modifier` the 177 public top-level extensions over 130 distinct Kotlin names | **bindings**, after overload disambiguation |
So the 104 and the 177 are not the same quantity twice. `padding` alone contributes **four** entries
to the 104 and **four** declarations to the 177 but only **one** name to the 130; and the six jars
-are a subset of the nineteen. The declined count is in a third unit again — 45 distinct Kotlin
-*names*, deduplicated — so 104 + 45 sums to nothing meaningful. **The number of distinct Kotlin
+are a subset of the nineteen. The declined count is in a third unit again, 45 distinct Kotlin
+*names*, deduplicated, so 104 + 45 sums to nothing meaningful. **The number of distinct Kotlin
names among the 104 bindings was not measured**, and no comparison against 130 is claimed here.
The classpath is separate from the walked set for a reason §1's tooling did not have to face:
@@ -431,8 +431,8 @@ so a value class or an extension receiver has to be resolvable from jars that ar
being bound.
**What the remaining 45 are, counted rather than asserted.** 43 of them declare a function-typed
-parameter. A Python callable cannot become a Kotlin `FunctionN` at this boundary —
-`UpcallTrampoline.toKotlinObject` would hand the cast a `PyObject` — so binding them would produce
+parameter. A Python callable cannot become a Kotlin `FunctionN` at this boundary,
+`UpcallTrampoline.toKotlinObject` would hand the cast a `PyObject`, so binding them would produce
entries that always fail, and declining them is correct rather than pending. §2.2's "66 of the 177
take at least one lambda" is the same fact seen from the other side. This is the honest remaining
limit on the `Modifier` chain and it is not a metadata problem.
@@ -444,7 +444,7 @@ limit on the `Modifier` chain and it is not a metadata problem.
The chain itself runs. `WalkedArtifactComposeModifierTest` assembles
`Modifier.padding(16.dp).size(24.dp)` **from Python**, out of Compose's own jars, under Compose's own
-names — no `Composer` anywhere, because §2.6 already established that a `Modifier` extension is not a
+names, no `Composer` anywhere, because §2.6 already established that a `Modifier` extension is not a
composable. What Python holds is an integer handle per link, and **nothing releases them**: a bare
handle returned from a `CallableKind.FUNCTION` reaches Python as an `int`, which has nothing to hang
a finaliser off. Three handles leak per run of that test, deliberately, because owning them is what
@@ -453,13 +453,13 @@ receiver proxies, `_proxy_type` in `PythonxAdapter.kt`, SPEC U-7.)*
---
-## 4. Judged — the Python surface
+## 4. Judged, the Python surface
### 4.1 An extension function becomes a method on its receiver's proxy
Adopted, as proposed. Chaining then costs nothing: each of the 177 `Modifier` extensions returns
`Modifier`, so `Modifier.padding(16).background(...)` is ordinary Python method chaining with no
-combinator machinery. This is what the 2024 attempt never reached —
+combinator machinery. This is what the 2024 attempt never reached,
`pythonx/compose/ui/modifier.py` is a copy of the Button wrapper in which `padding()` composes
nothing and `fill_max_size()` returns `self`.
@@ -475,14 +475,14 @@ Not every receiver can take a method, and the split was counted:
Python cannot attach a method to `int`, so `Int.toDp` and its 19 siblings stay module-level
functions on their Kotlin-named module, taking the receiver as the first positional argument. That is
a real loss of fidelity and it is confined to 20 functions. (This file first wrote the result as
-`pythonx.compose.ui.unit.to_dp(x)`; the binder renames nothing, and a snake_case spelling under
-`pythonx` is pythonx-compose's to add.)
+`pythonx.compose.ui.unit.to_dp(x)`; the binder renames no namespace -- it serves `to_dp` on
+`androidx.compose.ui.unit` itself (SPEC U-12) -- and anything under `pythonx` is pythonx-compose's.)
### 4.2 `Modifier` as one name for both the type and the empty modifier
`Modifier` in Kotlin is an interface, and `Modifier` as an *expression* is `Modifier.Companion`.
The relevant observation is that **`Modifier$Companion` implements `androidx.compose.ui.Modifier`**
-— it is an instance of the very type it is the companion of, and it is the empty modifier that
+it is an instance of the very type it is the companion of, and it is the empty modifier that
starts every chain.
So Python does not need to reconcile two things. It needs one class whose *class object* also
@@ -508,7 +508,7 @@ spellings work, and the generated `.pyi` can state all three (§4.5).
> `type.__getattribute__`'s documented order: a metaclass attribute wins only if it is a **data**
> descriptor, and a plain `def` on a metaclass is a non-data descriptor, so the class's own MRO is
> searched first. `Modifier.padding(16)` binds `16` to `self` and raises `TypeError`. It raises only
-> because the two arities differ by one — for a pair that happened to match it would bind *silently*,
+> because the two arities differ by one, for a pair that happened to match it would bind *silently*,
> which is the worse failure.
>
> **In mypy**, the same stub gives ten errors in twenty lines; every class-object spelling fails and
@@ -516,15 +516,15 @@ spellings work, and the generated `.pyi` can state all three (§4.5).
>
> Two fixes work at run time and neither fixes the checker: a `@property` on the metaclass *is* a
> data descriptor and does win, as does a hybrid descriptor in the class body with no metaclass at
-> all — but **mypy does not model metaclass data-descriptor precedence** and still resolves the
+> all, but **mypy does not model metaclass data-descriptor precedence** and still resolves the
> class's own method. That combination is the worst available: a correct runtime with a wrong IDE.
>
> What resolves it is to stop making the stub mirror the runtime mechanism. Pick a runtime that
> works (either of the two above) and declare each operation in the stub as a **class attribute whose
-> type is a Protocol with an overloaded `__call__`** — `padding: ClassVar[_Modifier_padding]` — which
+> type is a Protocol with an overloaded `__call__`**, `padding: ClassVar[_Modifier_padding]`, which
> checks clean for all three spellings. `docs/archive/pyi-generation-pythonic-stubs.md` §4.4 adopts that and measured
> it against mypy; PyCharm is listed there as unverified. The *observation* this section is built on
-> is untouched — `Modifier$Companion`
+> is untouched, `Modifier$Companion`
> implements `androidx.compose.ui.Modifier`, so one name really can serve as both the type and the
> empty modifier, and the three spellings really are the requirement. Only the mechanism proposed
> for reaching them is retired. *(2026-10-03: the binding layer reaches both run-time spellings with a
@@ -532,8 +532,8 @@ spellings work, and the generated `.pyi` can state all three (§4.5).
> modifier is a Pythonic layer and belongs to pythonx-compose.)*
**Rejected: binding the module-level name `Modifier` to the companion instance.** It is simpler at
-runtime and reads identically in the common case, but it destroys `Modifier` as an annotation —
-a type checker cannot use an instance in `m: Modifier` — and the stubs exist precisely so that a
+runtime and reads identically in the common case, but it destroys `Modifier` as an annotation,
+a type checker cannot use an instance in `m: Modifier`, and the stubs exist precisely so that a
type checker can.
### 4.3 Collisions demand qualification; they do not get silently resolved
@@ -541,8 +541,8 @@ type checker can.
Confirmed, and now with a price attached: **five names** in the whole of Compose (§2.5). The
default binding is the unqualified name; when two packages contribute the same
`(receiver, name)`, neither wins and both are reachable only through a qualified accessor. The
-same reasoning as dropping ambiguous overloads — a silent choice is a wrong call that never
-reports itself — but the cost here is now known to be negligible rather than assumed to be.
+same reasoning as dropping ambiguous overloads, a silent choice is a wrong call that never
+reports itself, but the cost here is now known to be negligible rather than assumed to be.
What is lost relative to Kotlin is real and should be said plainly: Kotlin's import scope means an
extension is invisible unless imported, so an application that imports only `material3` never sees
@@ -556,8 +556,8 @@ Measurement says a blanket rule in either direction is wrong.
**The evidence against blanket raw coercion.** For `TextUnit`, raw `16` decodes as `Unspecified`
(§2.4). For `Color`, raw `0xFFFF0000` is not red. For `Offset`, `Size`, `DpSize`, `IntSize`,
-`IntOffset`, `DpOffset`, `TransformOrigin`, `CornerRadius`, `Constraints` — every `packedValue`
-class, 20 of the 25 `Long`-backed ones — a user-meaningful number is not the stored number. None of
+`IntOffset`, `DpOffset`, `TransformOrigin`, `CornerRadius`, `Constraints`, every `packedValue`
+class, 20 of the 25 `Long`-backed ones, a user-meaningful number is not the stored number. None of
these failures raises; they render wrong.
**The evidence against blanket `dp(16)`.** `Dp` is 62% of value-class parameter occurrences on
@@ -574,8 +574,8 @@ stores from one that packs. Whether a wrapper packs is a semantic fact with no b
Adopted:
-1. **Every value-class parameter accepts its proxy** — `Modifier.padding(dp(16))` always works.
-2. **Raw primitives are accepted only for an explicit allowlist**, seeded with `Dp` and extended by
+1. **Every value-class parameter accepts its proxy**: `Modifier.padding(dp(16))` always works.
+2. **Raw primitives are accepted only for an explicit allowlist**: seeded with `Dp` and extended by
hand with evidence. `Dp` covers most of the ergonomic benefit on its own. *(Current: the binder's
allowlist starts empty and the seeding is pythonx-compose's, through `allow_raw_primitive`.)*
3. **Everything not on the allowlist rejects a raw number with an error that names the constructor
@@ -585,7 +585,7 @@ Adopted:
renders with no font size. *(Current: the binding layer refuses with "a raw number is not accepted
for TextUnit: it is a value class and not on the raw-primitive allowlist …"; naming the
constructor to call is left to the Pythonic layer, which owns `sp`.)*
-4. **`Color` is on the reject list despite passing the constructor test**, and the allowlist
+4. **`Color` is on the reject list despite passing the constructor test**: and the allowlist
mechanism is manual precisely so that this exception has somewhere to live.
Residual risk, stated rather than mitigated: the allowlist is a human judgement per type, and a new
@@ -601,8 +601,9 @@ Kotlin module path, Kotlin parameter names, `= ...` for a Kotlin default, the ex
positional-only first parameter, the declared Kotlin types as stub classes (`Modifier`, `Dp | float`,
`Callable[...]`), each extension also as a callable attribute of its receiver's class, and `@overload`
sets over a base name in table-key order, with the Kotlin signature in the docstring (issue #31). What
-stays out is snake_case, `pythonx` and the Pythonic allowlist narrowing, which belong to pythonx-compose. The section
-this replaces — the metaclass stub and the three measured requirements for a Pythonic stub — is in
+stays out is `pythonx` and the Pythonic allowlist narrowing, which belong to pythonx-compose; the
+snake_case aliases the runtime serves are in (SPEC U-12, B-7). The section
+this replaces, the metaclass stub and the three measured requirements for a Pythonic stub, is in
[`docs/archive/kotlin-extensions-in-python-pyi.md`](../archive/kotlin-extensions-in-python-pyi.md).
### 4.6 `@Composable` needs no new machinery here
@@ -619,12 +620,12 @@ arity is the correct source, and the generated Kotlin passes the composer explic
The "2–5" comes from §2.6's distribution, which §2.3's correction leaves unconfirmed; the nine
functions re-counted in §2.3 are all `+3` or `+4`. The rule this paragraph states is what the
-correction *proves* rather than what it weakens — the original nine descriptor counts were done by
+correction *proves* rather than what it weakens, the original nine descriptor counts were done by
hand and were all one low, for the same reason a machine would be: a `$default` mask is
indistinguishable from an ordinary trailing `int` unless you already know the Kotlin arity.
Two things this paragraph did not yet know. **`$default` is the third synthetic**, and Compose
-carries it as a *declared* trailing parameter rather than emitting a `Button$default` bridge — so
+carries it as a *declared* trailing parameter rather than emitting a `Button$default` bridge, so
"call the synthetic to omit an argument" is not available for a composable at all
(`docs/archive/pythonx-adapter-design.md` §4.5). And **the generated Kotlin cannot pass the composer
explicitly**: `docs/archive/pythonx-adapter-design.md` §5.3 finds that Kotlin source can neither call a
@@ -632,7 +633,7 @@ explicitly**: `docs/archive/pythonx-adapter-design.md` §5.3 finds that Kotlin s
not parameters of the Kotlin declaration. That is a property of this generator, not of Compose, and
it is why composables remain blocked while the `Modifier` chain of §3.2 runs.
-**Update — composables are callable (SPEC B-6), and not through generated Kotlin.** What settled it
+**Update, composables are callable (SPEC B-6), and not through generated Kotlin.** What settled it
(first recorded in `docs/archive/pythonx-adapter-design.md` §5.6):
- `@Composable` is `@Retention(BINARY)`: it appears in `RuntimeInvisibleAnnotations`, so the walker
@@ -643,13 +644,13 @@ it is why composables remain blocked while the `Modifier` chain of §3.2 runs.
- In the `$default` mask, bit *i* is parameter *i*; `$changed` is passed as `0`, the conservative
value. Shifting the mask by one bit made the render fail, so the encoding is load-bearing.
- A Kotlin file facade has no Kotlin name (`unresolved reference 'TextKt'`), so the call site is
- emitted as **bytecode** — one `INVOKESTATIC` per composable in `ComposableThunks.kt`, name and
+ emitted as **bytecode**, one `INVOKESTATIC` per composable in `ComposableThunks.kt`, name and
descriptor copied from the walked `MethodNode` at build time; no runtime lookup.
- The binding layer (`python_multiplatform.binding`) computes the `$default` mask from which
arguments the Python call wrote and threads the composer from a stack that one hand-written
- `@Composable` entry pushes (`PythonComposition` in `ksp-fixtures/compose`).
+ `@Composable` entry pushes (`PythonComposition` in `python-multiplatform-compose/fixtures/compose`).
-Proven by `ksp-fixtures/compose`'s render tests (`ComposableRenderTest`, `M3ProofRenderTest`,
+Proven by `python-multiplatform-compose/fixtures/compose`'s render tests (`ComposableRenderTest`, `M3ProofRenderTest`,
`CallbackDrivenRenderTest`) on desktop only; Android, iOS and wasm are planned (SPEC N-2). Extension
composables and recomposition skipping remain open.
@@ -663,12 +664,12 @@ suppress mangling, 4 wrappers taking `color: Long` so that `Color(...)` could be
Kotlin side, and 3 taking raw `Float` sizes.
That shim also invented API. `Card(modifier=…, corner_radius=20, color=…)` in `UI.ipynb` is not
-Compose — `material3.Card` has no `corner_radius` parameter in either of its overloads (§2.3). The
+Compose, `material3.Card` has no `corner_radius` parameter in either of its overloads (§2.3). The
shim absorbed shape and colour into flat keyword arguments per component, by hand, which is exactly
the per-function wrapping `docs/design/ecosystem.md` §5b rules out.
The modifier chain was never built. `pythonx/compose/ui/modifier.py` lines 20–22 probe
-`ButtonKt.__dict__` for a key starting `"Button-"` — a name-based lookup, which §2.3 shows cannot
+`ButtonKt.__dict__` for a key starting `"Button-"`, a name-based lookup, which §2.3 shows cannot
be made unambiguous for Compose no matter how carefully it is written.
---
@@ -685,8 +686,8 @@ happened.
dispatch receiver that Kotlin supplies implicitly from the enclosing lambda. Python has no
implicit receiver, so `Modifier.weight(1f)` cannot work as written; the scope has to be passed
into the content callable and the call spelled against it. No shape has been chosen. The walker
- still declines them explicitly — `ArtifactScanner`'s `KotlinClassMetadata.Class` arm filters
- extensions out, so nothing arrives half-bound — and `docs/design/pyi-generation-design.md` §4.5 adds the
+ still declines them explicitly, `ArtifactScanner`'s `KotlinClassMetadata.Class` arm filters
+ extensions out, so nothing arrives half-bound, and `docs/design/pyi-generation-design.md` §4.5 adds the
consequence for stubs: they **must not** be stubbed as plain methods on `Modifier`, because a stub
that says `Modifier.weight(1.0)` checks is a stub promising a call the runtime cannot make.
- **Overload dispatch.** *Moved, not closed.* The "drop ambiguous names" rule that cost 33 of 130
@@ -695,25 +696,25 @@ happened.
count and type, selecting among `padding__Dp` / `padding__Dp_Dp` / `padding__PaddingValues`.
*Closed since:* it lives in this repository's binding layer (`_Overloads` in `PythonxAdapter.kt`,
serving the base name; SPEC U-8), not in `pythonx`, and it interacts with §4.4 exactly as
- described — if the raw `16` is what selects the `Dp` overload, then raw-primitive acceptance is
+ described, if the raw `16` is what selects the `Dp` overload, then raw-primitive acceptance is
also overload resolution (a bare handle is not allowed to vote in a dispatch).
- **The type gate.** *Closed*, by `f455ef41`. `Shape`, `Brush` and `PaddingValues` bind now, and the
paragraph above misnamed what was missing: the boundary *type* (`TypeTag.OBJECT`) and its whole
runtime (`HandleTable`, `marshalResult`, `toKotlinObject`) already existed; the walker lacked a
Kotlin type name to cast to, which `@Metadata`'s classifier supplies. See §3's note on the type
gate. What is left of the 45 declined `Modifier` extensions is **43 with a function-typed
- parameter** (§3.2) — that is the lambda half of "66 take a lambda". *(Since closed in turn:
+ parameter** (§3.2), that is the lambda half of "66 take a lambda". *(Since closed in turn:
function-typed slots bind, SPEC B-5; see §3.2's note.)*
- **Handle ownership.** *New, opened by closing the type gate.* Every object crossing to Python is a
strong root in `HandleTable`, and an entry bound by this walker returns it as a bare `int` with
nothing to hang a finaliser off. A chained `Modifier.padding(...).size(...)` leaks one handle per
intermediate link until §4.1's proxy exists to own them. Known, bounded, and true of every
- `OBJECT`-returning KSP entry already — but it is now on the Compose path. *(The proxy exists now in
+ `OBJECT`-returning KSP entry already, but it is now on the Compose path. *(The proxy exists now in
the binding layer, and SPEC M-4 asserts that owned results are released when Python drops them;
the raw module still hands out bare handles.)*
-- **Whether the allowlist of §4.4 should be data or code**, and where a downstream consumer adds to
+- **Whether the allowlist of §4.4 should be data or code**: and where a downstream consumer adds to
it for a library this repository has never seen. *Closed:* it is run-time data in the binding
- layer — `_VALUE_CLASS_ALLOWLIST`, empty in this repository, extended by the Pythonic package
+ layer, `_VALUE_CLASS_ALLOWLIST`, empty in this repository, extended by the Pythonic package
through `allow_raw_primitive` (`PythonxAdapter.kt`). The manifest candidate
(`docs/archive/pyi-generation-pythonic-stubs.md` §5.3) was implemented and removed with the
Pythonic stubs; it belongs to pythonx-compose.
@@ -721,4 +722,4 @@ happened.
`docs/design/ecosystem.md` §5b records that `LibraryAbiReader` exposes extension receivers and unerased
types from klibs, which suggests the same design carries to iOS and androidNative, but no Compose
klib has been read yet. *(Since: `KlibScanner` produces declarations from klibs through
- `LibraryAbiReader`, but none is bound at run time yet — SPEC B-2/N-3.)*
+ `LibraryAbiReader`, but none is bound at run time yet, SPEC B-2/N-3.)*
diff --git a/docs/design/marshalling-design.md b/docs/design/marshalling-design.md
index 4f48d587d..e58b93bc7 100644
--- a/docs/design/marshalling-design.md
+++ b/docs/design/marshalling-design.md
@@ -31,7 +31,7 @@ inside the JVM and never crosses.
| direct buffer + ASCII fast path | 373.18 | 248.94 |
| **interned C string** | **160.25** | **167.33** |
-Interning wins on both levels and wins *uniformly* — 160 against 167 — where composition
+Interning wins on both levels and wins *uniformly*, 160 against 167, where composition
differs by 2x between the same two devices. It is 3.3x better than composing on API 36 and
1.5x better on API 26.
@@ -43,7 +43,7 @@ on every call is pure waste. CPython interns its own strings for the same reason
The first row of the table above is labelled "was current" because it no longer describes any
platform. `internedUtf8`, `encodeScratchUtf8` and `freeUtf8` are `expect`s in
`jvmMain/.../ShapeDowncalls.kt` with an `actual` on desktop and Android, and the wasmJs target has
-its own equivalent in `Wasm.kt` (same four-slot scratch). Android's call path uses them —
+its own equivalent in `Wasm.kt` (same four-slot scratch). Android's call path uses them,
`EmbedAPI.android.kt`'s `PyObject_GetAttrString` goes through `internedUtf8`, so the live cost is
the bottom row, not the top one. Quoting 2238 ns as Android's `getAttr` cost is wrong, and it has
been quoted that way.
@@ -58,8 +58,8 @@ Do not mix the two primitives up. They exist for different lifetimes:
| | for | lifetime |
|---|---|---|
-| `internedUtf8` | repeated identifiers — attribute, module, method names | kept, bounded at 4096 entries |
-| `encodeScratchUtf8` | arbitrary content — `exec` source, user strings | thread-local scratch, freed on the next call |
+| `internedUtf8` | repeated identifiers, attribute, module, method names | kept, bounded at 4096 entries |
+| `encodeScratchUtf8` | arbitrary content, `exec` source, user strings | thread-local scratch, freed on the next call |
Interning an `exec` source string would blow the cache; intern only where the argument is a name.
@@ -67,7 +67,7 @@ Interning an `exec` source string would blow the cache; intern only where the ar
Centre the abstraction on **interning**, not on a general allocator.
- // jvmMain — shared by Android and desktop (jvmMain/.../ShapeDowncalls.kt)
+ // jvmMain, shared by Android and desktop (jvmMain/.../ShapeDowncalls.kt)
@PublishedApi internal expect fun internedUtf8(s: String): Long
internal expect fun encodeScratchUtf8(s: String): Long
internal expect fun freeUtf8(address: Long)
@@ -80,7 +80,7 @@ free.
**The scratch pool holds four slots per thread**, handed out round-robin, on both desktop and
Android. One slot would have been enough for the "encode, then immediately pass to C" pattern,
-but not for a call that needs two arbitrary strings at once —
+but not for a call that needs two arbitrary strings at once,
`PyImport_ExecCodeModuleWithPathnames` needs two, `PyUnicode_DecodeLocale` needs two. Four
leaves headroom without pretending the addresses last. The fifth consecutive encode on a thread
invalidates the first, and `StringMarshallingTest.testMultiSlotScratchReuseLimit` pins exactly
@@ -94,13 +94,13 @@ that boundary, so it is a tested guarantee rather than an implementation detail.
`filename` and scratches `source`; they are arguments to the same call. Desktop and Android make
the same split for the same argument, and `desktopMain/.../bindings.kt` is the reference.
-| interned — the argument is a name | scratched — the argument is content |
+| interned, the argument is a name | scratched, the argument is content |
|---|---|
| attribute names (`PyObject_*AttrString*`) | source text (`Py_CompileString.str`, `PyRun_*`) |
| dict / mapping keys (`PyDict_*ItemString`, `PyMapping_*String`) | exception and warning messages |
| module names (`PyImport_*`, `PySys_*`, `PyErr_NewException.name`) | file paths a module was loaded *from* (`pathname`, `cpathname`) |
| codec names (`encoding`) | error-handler names (`errors`), comparands, docstrings, `PySequence_Fast.m` |
-| `PyUnicode_InternFromString.str` — the caller is asserting it repeats | `PyBytes_FromString.v` — payload |
+| `PyUnicode_InternFromString.str`, the caller is asserting it repeats | `PyBytes_FromString.v`, payload |
Two cases are worth stating because they read the wrong way at a glance:
@@ -115,7 +115,7 @@ the four-slot boundary.
### Cache policy
-Interning without a bound is a leak, because callers can pass arbitrary strings — an attribute
+Interning without a bound is a leak, because callers can pass arbitrary strings, an attribute
name built at runtime, a code fragment. The cache therefore:
- holds a bounded number of entries; beyond that, misses take the scratch path instead of
@@ -124,7 +124,7 @@ name built at runtime, a code fragment. The cache therefore:
- never frees an interned entry, which is what makes the returned address safe to hold
The bound turns "unbounded growth" into "predictable ceiling, then fall back to scratch". A
-literal-heavy workload — which is the normal case — stays entirely in the cache.
+literal-heavy workload, which is the normal case, stays entirely in the cache.
### ASCII fast path
@@ -142,7 +142,7 @@ full UTF-8 only when it sees a character above 0x7F.
Android's one JNI call per buffer is amortised to nothing; what matters is that no crossing
happens per *string*. That is the same shape Panama has on desktop, and the same shape
-PanamaPort would have provided — its `allocateFrom(String)` runs through
+PanamaPort would have provided, its `allocateFrom(String)` runs through
`AndroidUnsafe.allocateMemory` to `sun.misc.Unsafe`, a JVM intrinsic with no crossing.
### What this replaces
@@ -152,7 +152,7 @@ Composition for string-carrying operations, at every API level. `getAttr` compos
native function, and no JVM-side wiring. It also applies to every string-carrying operation at
once rather than one at a time.
-Composition remains justified only where crossing count genuinely scales with N — bulk
+Composition remains justified only where crossing count genuinely scales with N, bulk
iteration, measured at 11x for a 1000-element list.
## Non-goals
diff --git a/docs/design/object-lifetime.md b/docs/design/object-lifetime.md
index a33b44e14..b843d81ed 100644
--- a/docs/design/object-lifetime.md
+++ b/docs/design/object-lifetime.md
@@ -3,7 +3,7 @@
Two runtimes, two memory models, and no shared view between them. This is what has been
settled, what is measured, and what is still open.
-## Kotlin holding Python — solved and verified
+## Kotlin holding Python, solved and verified
CPython counts references; a Kotlin wrapper takes one on construction and gives it back when it
closes.
@@ -14,7 +14,7 @@ closes.
the Kotlin wrapper closes refcount 0, freed
`RefCountTest` checks this in both directions, because both failure modes are silent: releasing
-one too few leaks, and releasing one too many frees an object still in use — that crash lands
+one too few leaks, and releasing one too many frees an object still in use, that crash lands
somewhere unrelated, which is how the interpreter corruption during the lifetime work first
appeared, as a segfault inside `Py_Finalize` far from its cause.
@@ -31,7 +31,7 @@ library therefore takes a periodic checkpoint itself (`Python3.drainPendingRelea
`autoDrainInterval`, on by default only for free-threaded builds). See ROADMAP §9,
`docs/investigations/gc-scheduling-investigation.md` and [`threading-and-abi.md`](threading-and-abi.md).
-## Python holding Kotlin — the handle table
+## Python holding Kotlin, the handle table
There is no counterpart to `Py_INCREF` for a JVM or Kotlin/Native object, so something on the
Kotlin side has to act as a GC root:
@@ -48,7 +48,7 @@ Python holds only the integer handle; the map holds the object. This is what JNI
The sketch above has no answer to that, and neither did the implementation. `HandleTable` is
generational precisely so that a double release is safe, and its class doc named the counterpart
-exactly — *"the proxy's `tp_dealloc` calling `release` is the entire lifetime contract"* — but
+exactly, *"the proxy's `tp_dealloc` calling `release` is the entire lifetime contract"*, but
`PythonProxySource` rendered a proxy class whose `__init__` took a handle and which had no path
back at all. Every Kotlin object Python ever constructed stayed rooted for the life of the process.
@@ -64,13 +64,13 @@ Two separate defects wore that one number, and it is worth keeping them apart:
| a proxy instance (`Counter(10)`) | the Python object | the **library**: no `__del__` was rendered. Fixed; `ProxyHandleLifetimeTest` pins it, including for a proxy inside a reference cycle |
| a bare handle from `_pm_invoke` on a CONSTRUCTOR entry | the **caller** | nothing was wrong with the library. An integer has nothing to hang a finaliser off, so the caller must call `_pm_release`; the measurement harness was not doing it |
-The leak was **identical on desktop and on the iOS simulator** — 64 handles registered, 64 still
-live after `gc.collect()`, on both — which is what one would expect of a defect in generated Python
+The leak was **identical on desktop and on the iOS simulator**, 64 handles registered, 64 still
+live after `gc.collect()`, on both, which is what one would expect of a defect in generated Python
that `commonMain` renders. The earlier impression that "iOS was stable" was about the *measurement*
and not about the table: the iOS boundary costs about 3.5 µs against desktop's 0.5 µs, so the same
drift was proportionally invisible there.
-`__del__` was chosen over `weakref.finalize` on price — the alternatives and their measured costs
+`__del__` was chosen over `weakref.finalize` on price, the alternatives and their measured costs
are tabulated in `PythonProxySource`'s KDoc. The pre-PEP-442 objection to `__del__` (an object with
one, inside a cycle, was never finalised at all) has not applied since CPython 3.4, and the cycle
case is asserted rather than assumed.
@@ -80,7 +80,7 @@ case below is untouched. What changed is only that "Python has finished with it"
at all, which is the precondition for anything else.
A note for anyone reading older discussion: Panama has no equivalent of `NewGlobalRef`, and it
-does not need one. Panama is an API for *native memory*, not for JVM object lifetime — a
+does not need one. Panama is an API for *native memory*, not for JVM object lifetime, a
`MemorySegment` holding an address does not keep a Kotlin object alive. The GC root is an
ordinary Kotlin data structure, not anything Panama provides. Looking for the answer inside
Panama was looking at the wrong layer.
@@ -89,7 +89,7 @@ Panama was looking at the wrong layer.
### Why reference counting cannot do it
-Not a boundary problem — an inherent limit of reference counting, in any language:
+Not a boundary problem, an inherent limit of reference counting, in any language:
a = []; b = []
a.append(b); b.append(a)
@@ -104,7 +104,7 @@ CPython's algorithm is simple, and it turns on one requirement:
1. each tracked object's `tp_traverse` enumerates the `PyObject`s it holds
2. references discovered that way are subtracted from the refcount
-3. if the remainder is zero, the object is kept alive only from inside the cycle — collectable
+3. if the remainder is zero, the object is kept alive only from inside the cycle, collectable
4. `tp_clear` drops those references and breaks the cycle
**Everything depends on step 1 being able to see through whatever holds the reference.**
@@ -138,7 +138,7 @@ fields are `PyObject`-typed. A traverse entry is one more generated function:
obj.otherField?.let { visit(it.pointer.toRawValue()) }
}
-No reflection — which is the whole reason the table exists. A reflection-based binding would
+No reflection, which is the whole reason the table exists. A reflection-based binding would
find this expensive; that is why mature ones (JPype and similar) document "do not create
cycles" instead of solving it. We are not in that position.
@@ -166,7 +166,7 @@ strongly, so it is a root regardless.
**Still open.** The handle table holds strongly (`HandleTable.kt` notes it "needs to hold weakly" for
this case; ROADMAP §14b records it as untouched). The likely answer is to make the map hold weakly,
-with the strong reference supplied by the Python proxy for as long as it lives — so "Python stopped
+with the strong reference supplied by the Python proxy for as long as it lives, so "Python stopped
using it" becomes "the JVM may collect it". That inverts who owns the strength and has not been
worked through.
@@ -174,7 +174,7 @@ worked through.
An earlier discussion concluded that "cycles are handled by each language's GC" on the strength
of the four-step refcount sequence at the top of this document. That sequence is correct, and it
-is implemented and tested — but it describes the **non-cyclic** case. Step 4, "the Kotlin side
+is implemented and tested, but it describes the **non-cyclic** case. Step 4, "the Kotlin side
releases", is exactly what a cycle prevents from ever happening. The claim does not follow, and
the same discussion walked it back a few messages later.
@@ -183,7 +183,7 @@ the same discussion walked it back a few messages later.
`PyContext` chooses how far a Python value is converted toward Kotlin, per
`ConversionStrategy`; `withContext` swaps and restores the strategy even on throw, and all five
variants dispatch. `PyValue`'s lazy path is filled in too: the stub that used to end in
-`cachedNativeValue!!` — an NPE on any genuine cache miss — is gone. No `!!` on that field remains
+`cachedNativeValue!!`, an NPE on any genuine cache miss, is gone. No `!!` on that field remains
anywhere in the library sources; the only occurrence of the expression left in the tree is a
comment in `ConversionTest` recording that it used to be there.
@@ -196,23 +196,23 @@ actually returns, not from what the type looks like.
### The rule, per source type
`PyProxy.toKotlinOrNull` converts through `pyObjectToNative`, whose dispatch compares the source's
-**exact** type object against `PyTypeChecks`' cached builtin types — `PyLong_Check` semantics, not
+**exact** type object against `PyTypeChecks`' cached builtin types, `PyLong_Check` semantics, not
`isinstance`. So the table is by exact type; a subclass falls through to the last row.
| Python type | Conversion | Kotlin result | Points into Python memory? | Cached? |
|---|---|---|---|---|
| `None` | `PyNone.isNone`, a pointer comparison | `null` | no | re-derived; `null` cannot be told apart from "not computed", and the comparison costs no FFI call |
-| `bool` | `PyLong_AsLongLong(...) != 0` | `Boolean` | no — a scalar copy | yes |
-| `int` | `PyLong_AsLongLong` | `Long` | no — a scalar copy (64-bit range only; wider ints do not round-trip) | yes |
-| `float` | `PyFloat_AsDouble` | `Double` | no — a scalar copy | yes |
-| `str` | `PyUnicode_AsUTF8` | `String` | **the C call returns a `const char*` owned by CPython**, but that pointer never leaves the binding: `EmbedAPI`'s `expect` returns `String?`, and each platform decodes at the boundary (`toKString()` on Kotlin/Native, `Panama.readUtf8String` on desktop, `Wasm.readUtf8String` on wasmJs). The `String` is a copy. | yes — the `String`, never the pointer |
-| `list`, `tuple` | `toNativeList()`, recursive | `List` | no — every element is itself one of the rows above | yes, as a **snapshot** |
+| `bool` | `PyLong_AsLongLong(...) != 0` | `Boolean` | no, a scalar copy | yes |
+| `int` | `PyLong_AsLongLong` | `Long` | no, a scalar copy (64-bit range only; wider ints do not round-trip) | yes |
+| `float` | `PyFloat_AsDouble` | `Double` | no, a scalar copy | yes |
+| `str` | `PyUnicode_AsUTF8` | `String` | **the C call returns a `const char*` owned by CPython**, but that pointer never leaves the binding: `EmbedAPI`'s `expect` returns `String?`, and each platform decodes at the boundary (`toKString()` on Kotlin/Native, `Panama.readUtf8String` on desktop, `Wasm.readUtf8String` on wasmJs). The `String` is a copy. | yes, the `String`, never the pointer |
+| `list`, `tuple` | `toNativeList()`, recursive | `List` | no, every element is itself one of the rows above | yes, as a **snapshot** |
| `dict` | `toNativeMap()`, recursive over a snapshot of the entries | `Map` | no | yes, as a **snapshot** |
| `set`, `frozenset` | `toNativeSet()`, recursive over `PySequence_Tuple` | `Set` | no | yes, as a **snapshot** |
-| anything else *inside a container* | `pyObjectToNative`'s `else`: `str(obj)` | `String` | no — a copy, but lossy and one-way | yes |
-| `bytes`, `bytearray` | `bytes.hex()` across the boundary, decoded to bytes on this side | `ByteArray` | yes — a copy, and `bytearray` a **snapshot** | yes |
-| `memoryview` | **none — refused** | `null` / `PyTypeError` | the buffer protocol hands out a pointer *into* the object, and a copy would still not carry format, shape and strides | never |
-| user-defined classes, subclasses of builtins, `complex`, ... | **none — refused** | `null` / `PyTypeError` | — | never |
+| anything else *inside a container* | `pyObjectToNative`'s `else`: `str(obj)` | `String` | no, a copy, but lossy and one-way | yes |
+| `bytes`, `bytearray` | `bytes.hex()` across the boundary, decoded to bytes on this side | `ByteArray` | yes, a copy, and `bytearray` a **snapshot** | yes |
+| `memoryview` | **none, refused** | `null` / `PyTypeError` | the buffer protocol hands out a pointer *into* the object, and a copy would still not carry format, shape and strides | never |
+| user-defined classes, subclasses of builtins, `complex`, ... | **none, refused** | `null` / `PyTypeError` | - | never |
The container rows are the answer to "what happens when the elements are themselves `PyObject`s":
they are not. `pyObjectToNative` recurses, so a converted container bottoms out in `Long`,
@@ -228,7 +228,7 @@ staleness and the recovery are both pinned by tests.
| `ConversionStrategy` | Hands back | Cacheable |
|---|---|---|
-| `RAW` | a bare `NativePointer` — no wrapper, no incref, nothing that releases it | **never.** It is an address, not a reference. `PyValue`'s constructor rejects one as `initialNativeValue`, because storing it would fail only once the address had been reused, far from the cause |
+| `RAW` | a bare `NativePointer`, no wrapper, no incref, nothing that releases it | **never.** It is an address, not a reference. `PyValue`'s constructor rejects one as `initialNativeValue`, because storing it would fail only once the address had been reused, far from the cause |
| `UNMANAGED` | the `PyObject` unchanged, under Python's own refcounting | no native value exists to cache |
| `TYPED` / `DEFAULT` | a `PyValue` over a wrapper it owns a reference to | per the type table above |
| `NATIVE` | the fully-converted value, eagerly | nothing holds it; there is no cache in this path |
@@ -239,7 +239,7 @@ staleness and the recovery are both pinned by tests.
scalars, `String`, `ByteArray` and containers built recursively out of those; `false` for a
`NativePointer` (an unowned address) and for a `PyObject` (a reference, but not a native value),
and false for anything unrecognised. Every store to `cachedNativeValue` on the conversion path
-passes through it, and a value it rejects is still returned to the caller — it is simply
+passes through it, and a value it rejects is still returned to the caller, it is simply
re-converted next time instead of kept. Today every branch the walk can reach passes; the guard
exists so a branch added later cannot acquire a cache without someone deciding that it should.
@@ -248,12 +248,12 @@ exists so a branch added later cannot acquire a cache without someone deciding t
The Python side has the opposite rule: `cachedPyObjectValue` must hold an **owned** reference,
never a borrowed one, because the proxy can outlive whatever handed the object over.
-`PyContext.proxyConvert` did not honour it. `typedWrap` returns a fresh wrapper — built
-`borrowed = true`, i.e. with its own incref — for the nine builtins it knows, and returns *the
+`PyContext.proxyConvert` did not honour it. `typedWrap` returns a fresh wrapper, built
+`borrowed = true`, i.e. with its own incref, for the nine builtins it knows, and returns *the
caller's wrapper unchanged* for everything else. On that second path the `PyValue` held no
reference of its own, so a caller closing its wrapper (which callers do; `hasNativeCounterpart`
does it to its own scratch wrapper) left the `PyValue` pointing at a released object. Measured as
-a missing `+1` on `sys.getrefcount` — 3 where 4 was required — by
+a missing `+1` on `sys.getrefcount`, 3 where 4 was required, by
`PyValueLazyConversionTest.pyContextGivesEveryPyValueItsOwnReference`, and fixed by taking an
independent reference on the fallback path. Both branches now hand the `PyValue` a wrapper nobody
else holds.
@@ -269,7 +269,7 @@ else holds.
What changed is that the buffer is not the only route. `bytes.hex()` is exact, pure ASCII, and
costs three FFI crossings whatever the length, where walking the sequence costs one per byte.
The price is four passes over the payload where a direct copy would make one, which makes this
- the largest per-byte cost in the conversion layer — the case for adding a real
+ the largest per-byte cost in the conversion layer, the case for adding a real
`PyBytes_AsStringAndSize` binding later. That binding is not free either: a non-primitive value
would have to cross four platform boundaries against `androidMain`'s primitives-only rule.
- **Subclasses of builtins are refused** because the dispatch is by exact type. That is
diff --git a/docs/design/pyi-generation-design.md b/docs/design/pyi-generation-design.md
index 59eb1732c..2e5b0c69f 100644
--- a/docs/design/pyi-generation-design.md
+++ b/docs/design/pyi-generation-design.md
@@ -1,9 +1,9 @@
-# Generating `.pyi` — the reference implementation, the input, and what a stub can actually say
+# Generating `.pyi`, the reference implementation, the input, and what a stub can actually say
> **Revised 2026-10-03.** This file now describes only the stub product this repository ships: `.pyi`
> under the **Kotlin** module paths, with Kotlin names (SPEC B-7). The design of a second, Pythonic
-> product — `pythonx.*` stubs, snake_case, `Dp | float`, `@overload` sets, the `Modifier`
-> callback-Protocol shape and the `pythonx-map.toml` manifest — was removed from the plugin in
+> product, `pythonx.*` stubs, snake_case, `Dp | float`, `@overload` sets, the `Modifier`
+> callback-Protocol shape and the `pythonx-map.toml` manifest, was removed from the plugin in
> `238119b7` and moved to
> [`docs/archive/pyi-generation-pythonic-stubs.md`](../archive/pyi-generation-pythonic-stubs.md);
> that product belongs to pythonx-compose. Section numbers are kept so that the code's KDoc
@@ -14,7 +14,11 @@
> its receiver's class as well, and states function types, nullability, value classes and overload
> sets (§3.1, §3.3–§3.6, §4.1). Everything is still under Kotlin names; the "`Dp | float`, `@overload`
> sets, `Modifier` callback-Protocol shape" that the paragraph above says left the plugin are back
-> **as Kotlin-name typing**, not as a Pythonic product -- no snake_case, no `pythonx`.
+> **as Kotlin-name typing**, not as a Pythonic product -- no `pythonx`.
+>
+> **Revised again for issue #131.** snake_case is back -- not as a `pythonx` product, but as the
+> Pythonic aliases the runtime serves on the Kotlin-named modules themselves (SPEC U-12): parameters
+> under their Pythonic keyword, and `alias = kotlinName` beside each module-level name (§3.6).
The binder serves Kotlin-named modules (`androidx.compose.foundation.layout`, `junit.runner.Version`,
a consumer's own package) that exist only as `sys.modules` entries made at run time
@@ -29,7 +33,7 @@ does not revisit it.
What this file answers: **what the generator reads**, **what it writes**, and **where the result
goes**. The generator is `PythonStubsTask` + `renderKotlinFqnStubs` (`PyiRendering.kt`, in
`python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/`); its tests are
-`PyiRenderingTest`, `KotlinNamesOnlyStubTest` and `ksp-fixtures/artifact`'s `WalkedArtifactStubTest`.
+`PyiRenderingTest`, `KotlinNamesOnlyStubTest` and `python-multiplatform-gradle-plugin/fixtures/artifact`'s `WalkedArtifactStubTest`.
Everything under "Observed" was read from working copies or run as a command on 2026-08-14, with the
path or the command given, unless dated otherwise. Everything under "Judged" is a decision, and the
@@ -45,7 +49,7 @@ alternatives it was chosen over are named. "Current" statements were checked aga
| what | where | what it settled |
|---|---|---|
| PyREPL's generator | `/Volumes/macMini/PyREPL/app/build.gradle.kts` (627 lines, HEAD `899c6bf`, 2024-09-01, remote `github.com/thisisthepy/PyREPL`) | §1 in full |
-| toolchain's mutated copy | `/Volumes/macMini/thisisthepy/toolchain/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dependency/lang/kotlin/meta/createMetaClass.kt` (248), `meta/MetaPackageBuildTask.kt` (64), `kotlin/decompileKotlinMeta.kt` (145) | §1.3 — and it corrects `docs/design/ecosystem.md` |
+| toolchain's mutated copy | `/Volumes/macMini/thisisthepy/toolchain/toolchain/src/main/kotlin/org/thisisthepy/python/multiplatform/toolchain/dependency/lang/kotlin/meta/createMetaClass.kt` (248), `meta/MetaPackageBuildTask.kt` (64), `kotlin/decompileKotlinMeta.kt` (145) | §1.3, and it corrects `docs/design/ecosystem.md` |
| the artefact walker | `python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/{ArtifactScanner,KotlinMetadata,ArtifactRendering,JvmDescriptors,PythonArtifactBindingsTask}.kt` | §2 |
| the KSP producer | `python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/{FragmentScanner,Model,TypeShape,KsAdapters}.kt` | §2 |
| the runtime proxy renderer | `python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt` | §2.2, §6 |
@@ -57,13 +61,13 @@ alternatives it was chosen over are named. "Current" statements were checked aga
mypy **2.3.0** in a throwaway venv at `/Volumes/macMini/tmp/pyicheck` (nothing was installed into
the repository, and no repository file other than this one was touched). Eight checks, reported in
-§4.3, §4.4 and §6.1 — §4.3, §4.4 and most of §6.1 are now in the archive file named at the top.
+§4.3, §4.4 and §6.1, §4.3, §4.4 and most of §6.1 are now in the archive file named at the top.
CPython 3.13 for the two runtime attribute-lookup checks in §4.3.
### Not read, and therefore not claimed
-- **`github.com/thisisthepy/PyREPL`'s files.** The repository page was fetched and exists — public,
- described as *"UI-Enabled Python REPL for Android/iOS/Desktop"*, 7 stars, 9 forks — but the fetch
+- **`github.com/thisisthepy/PyREPL`'s files.** The repository page was fetched and exists, public,
+ described as *"UI-Enabled Python REPL for Android/iOS/Desktop"*, 7 stars, 9 forks, but the fetch
returned no file contents. **Everything said about PyREPL here comes from the local clone at
`/Volumes/macMini/PyREPL`**, whose `origin` is that URL. Whether the clone is current with the
remote was not checked.
@@ -77,12 +81,12 @@ CPython 3.13 for the two runtime attribute-lookup checks in §4.3.
throughout, and not asserted.**
- **klib.** Everything here is the JVM path. `docs/design/ecosystem.md` §5b records that
`LibraryAbiReader` returns Kotlin qualified names, extension-receiver flags and unerased types
- from a klib, which is the same information §2 says a stub needs — but no klib was read for this
+ from a klib, which is the same information §2 says a stub needs, but no klib was read for this
document.
---
-## 1. Observed — PyREPL's generator
+## 1. Observed, PyREPL's generator
### 1.1 The pipeline
@@ -98,11 +102,11 @@ set makes a **resolvable copy** of its `implementation` and `api` configurations
then takes `resolvedConfiguration.lenientConfiguration.artifacts`. Unresolved dependencies that name
a sibling project are recursed into with `isSubModule = true`, which switches the copy to `api` only
-— the same visibility rule Gradle itself applies. Everything else is collected as genuinely
+the same visibility rule Gradle itself applies. Everything else is collected as genuinely
unresolved and printed.
The output directory is derived per source set: from `sourceSet.kotlin.srcDirs`, take the one under
-`/kotlin`, and emit into its sibling `generated/meta` — i.e. `src//generated/meta/`,
+`/kotlin`, and emit into its sibling `generated/meta`, i.e. `src//generated/meta/`,
**inside the source tree, not the build directory**. That directory is then registered as a Python
source root through the only mechanism the project had for one:
@@ -111,7 +115,7 @@ source root through the only mechanism the project had for one:
"src/commonMain/python", "src/commonMain/generated/meta") } } }
A `build/kotlin-python/meta_json.lock` records the resolved artefact set (group, name, version,
-file) as JSON so a changed dependency graph can be detected. **It is computed and then ignored** —
+file) as JSON so a changed dependency graph can be detected. **It is computed and then ignored**,
`outputs.upToDateWhen { false }` and the `if (!isChanged) return@doLast` guard is commented out, so
the task runs every time.
@@ -120,7 +124,7 @@ for each class whose binary name has no `$` and which is not `private`/`protecte
### 1.2 What it writes
-One `__init__.pyi` **per Java package**, at `createInitPythonFile` (line 368) — the file is named
+One `__init__.pyi` **per Java package**, at `createInitPythonFile` (line 368), the file is named
after the package directory, and every class in that package is *appended* to it. Bodies are `...`;
there is no runtime in the file at all.
@@ -131,7 +135,7 @@ there is no runtime in the file at all.
def main(self, *args, **kwargs): ...
Filters, from `generateMetaPythonClass` (line 375): drop `private`/`protected`; drop fields starting
-`$` or `Companion`; drop methods starting `<`; **drop any member whose name contains `-`** — the
+`$` or `Companion`; drop methods starting `<`; **drop any member whose name contains `-`**, the
value-class mangling suffix, discarded rather than decoded; skip numeric (anonymous) nested classes;
emit `...` for a class that ends up empty.
@@ -144,18 +148,18 @@ Collisions between `commonMain` and a platform source set are handled by `checkF
(line 525): find `__init__.pyi` paths present in both trees, rename **both** to
`_.pyi` and `__.pyi`, and write a new `__init__.pyi` that re-exports both with
`from ._X import *`. `findCommonDir` and `reCreateFile` split on the literal `"\\__init__.pyi"`, so
-**this path is Windows-only** and silently does nothing on macOS or Linux — `docs/design/ecosystem.md`
+**this path is Windows-only** and silently does nothing on macOS or Linux, `docs/design/ecosystem.md`
already flagged the separators; what is added here is that the failure is silent rather than an
error.
-### 1.3 The two lineages — and a correction to `docs/design/ecosystem.md`
+### 1.3 The two lineages, and a correction to `docs/design/ecosystem.md`
`docs/design/ecosystem.md` §2 says `dba17dd` "extracted PyREPL's meta-generation" into `createMetaClass.kt`
and that `toolchain`'s copy is "the same code mutated into a *runtime* generator". The extraction is
right; the implied direction is not.
-`git show f0dd368:app/build.gradle.kts` — the commit in **toolchain's own history** that first added
-the generator, one day before the extraction — already emits `.py`, not `.pyi`:
+`git show f0dd368:app/build.gradle.kts`, the commit in **toolchain's own history** that first added
+the generator, one day before the extraction, already emits `.py`, not `.pyi`:
fun createMetaPythonFile(node: ClassNode, outputDir: File): File {
...
@@ -173,7 +177,7 @@ is larger than "delete the bodies".
One thing in `toolchain` is worth keeping regardless of lineage: `decompileKotlinMeta.kt` already
reaches for **`kotlinx-metadata-jvm`** and dispatches on `KotlinClassMetadata.Class` /
`FileFacade` / `MultiFileClassPart` / `MultiFileClassFacade`. It is a standalone `main()`, wired into
-no task, and its output is `def name(self): pass` with no types — but the instinct is the same one
+no task, and its output is `def name(self): pass` with no types, but the instinct is the same one
this repository's `KotlinMetadata.kt` acted on, two years later.
### 1.4 What survives the port
@@ -193,7 +197,7 @@ this repository's `KotlinMetadata.kt` acted on, two years later.
---
-## 2. The input — judged
+## 2. The input, judged
### 2.1 Observed (2026-08-14): what the two producers kept then
@@ -212,28 +216,28 @@ field-for-field near-identical:
| `arity` | yes | yes |
| `paramTags` | `List` | `List` |
| `returnTag` | `Tag` | `String` |
-| `kind` | `"FUNCTION"`, `"METHOD"`, … | — (always FUNCTION) |
+| `kind` | `"FUNCTION"`, `"METHOD"`, … |, (always FUNCTION) |
| `lambdaBody` | generated Kotlin source text | generated Kotlin source text |
-| `isSuspend` | yes | — (always false) |
-| `imports` | — | extension-call aliases |
+| `isSuspend` | yes |, (always false) |
+| `imports` | - | extension-call aliases |
-`Tag` (`TypeShape.kt` line 9) is `INT | FLOAT | BOOLEAN | STRING | BYTES | UNIT | OBJECT` — seven
+`Tag` (`TypeShape.kt` line 9) is `INT | FLOAT | BOOLEAN | STRING | BYTES | UNIT | OBJECT`, seven
values, and `OBJECT` means "a handle, contents unknown". The runtime's `ExposedCallable`, which
`PythonProxySource.render` consumes, carries the same and nothing more.
**What is therefore absent from every existing representation:**
-- parameter **names** — `FragmentScanner` does `function.parameters.map { it.type.toShape() }` and
+- parameter **names**: `FragmentScanner` does `function.parameters.map { it.type.toShape() }` and
drops `it.name`; `ArtifactScanner` does the same to `KmValueParameter`
-- **defaults** — `KmValueParameter.declaresDefaultValue` is read by nobody
-- **nullability** — `resolveKotlinType` returns `null` for a nullable type outright
+- **defaults**: `KmValueParameter.declaresDefaultValue` is read by nobody
+- **nullability**: `resolveKotlinType` returns `null` for a nullable type outright
(`KotlinMetadata.kt` line 206), so the fact never leaves the scanner
-- the **declared Kotlin type** — `Dp` becomes `Tag.FLOAT`, `List` becomes `Tag.OBJECT`
-- whether a parameter is an **extension receiver** — known inside `buildCallableFromFunction`, spent
+- the **declared Kotlin type**: `Dp` becomes `Tag.FLOAT`, `List` becomes `Tag.OBJECT`
+- whether a parameter is an **extension receiver**, known inside `buildCallableFromFunction`, spent
on choosing `receiver.alias(...)`, then discarded
-- **overload grouping** — both producers *delete* the information: `ArtifactScanner.scanClassNode`
+- **overload grouping**: both producers *delete* the information: `ArtifactScanner.scanClassNode`
line 230 keeps only names with exactly one candidate; `FragmentScanner` keeps the first
-- value-class identity — `ValueClassInfo` exists (`KotlinMetadata.kt` line 168) and is consumed
+- value-class identity, `ValueClassInfo` exists (`KotlinMetadata.kt` line 168) and is consumed
inside a closure to build a wrap/unwrap expression, never surfaced
### 2.2 Judged: the input is a third representation, upstream of both renderers
@@ -241,7 +245,7 @@ values, and `OBJECT` means "a handle, contents unknown". The runtime's `ExposedC
**Neither producer's output can drive a `.pyi` generator, and neither should be widened to try.**
The reasoning is the one `PythonProxySource`'s KDoc already gives for a different split: an
-`ExposedCallable` is *"exactly the inputs"* for the runtime proxy, and that is the point — it was cut
+`ExposedCallable` is *"exactly the inputs"* for the runtime proxy, and that is the point, it was cut
down to exactly what a trampoline needs. A `.pyi` needs a strict superset of what a *call site* needs,
because a stub describes a signature and a call site only has to produce one. Growing
`CallableEntryModel` to carry parameter names and defaults would put fields into the model that
@@ -268,7 +272,7 @@ what crosses, not what Kotlin declares (§3.1); and of the annotations it keeps
function, a member, a constructor, a property; the extension receiver's Kotlin type or `null`; an
ordered parameter list of `(name, KotlinTypeModel, declaresDefault)`; the return `KotlinTypeModel`;
`isSuspend`; and the annotations that matter (`@Composable`, `@Deprecated`). `KotlinTypeModel`
-carries a qualified name, nullability, type arguments, and — when the classifier is a value class —
+carries a qualified name, nullability, type arguments, and, when the classifier is a value class,
its underlying `KotlinTypeModel` plus the two visibility booleans `ValueClassInfo` already computes.
Three properties this buys, each of which is a defect in the current shape:
@@ -281,7 +285,7 @@ Three properties this buys, each of which is a defect in the current shape:
them (§3.6). Those are different decisions and today they are the same line of code. *(Since
then the binder stopped dropping them: each member gets its own `name__` key, §3.6.)*
3. **What is declined stays visible.** A model entry can be marked "not bound, reason X" and still
- be stubbed — or deliberately not stubbed, which is the honest choice when the runtime cannot call
+ be stubbed, or deliberately not stubbed, which is the honest choice when the runtime cannot call
it. Today a declined declaration returns `null` and vanishes.
**Rejected alternatives:**
@@ -290,15 +294,15 @@ Three properties this buys, each of which is a defect in the current shape:
|---|---|
| generate `.pyi` from the runtime `UpcallTable` (mirroring `PythonProxySource`) | the table holds `ExposedCallable`, i.e. tags and arity. Every parameter would be `a0, a1, a2` with no type. It is also the wrong time: `PythonProxySource` renders at run time *because* the installed set is only known then, and a stub has to exist before anything runs |
| widen `CallableEntryModel` / `ArtifactCallable` in place | puts fields on the hot path that its renderer must ignore, and does not fix the KSP/ASM asymmetry (`kind` and `isSuspend` on one, `imports` on the other) |
-| a second scan, independent of the binder's | two readers of the same jars that can disagree — precisely the failure `PythonProxySource` §"Where this is generated" refuses ("a second generator that can disagree with the first") |
+| a second scan, independent of the binder's | two readers of the same jars that can disagree, precisely the failure `PythonProxySource` §"Where this is generated" refuses ("a second generator that can disagree with the first") |
| emit `.pyi` from KSP as well as from the plugin | KSP does not see third-party artefacts at all, which is the entire reason the artefact walker exists (`docs/design/ecosystem.md` §5b). The plugin sees both |
### 2.3 What each producer has to supply
| producer | has it today | needs |
|---|---|---|
-| KSP | `KSFunctionDeclaration` gives names, defaults (`KSValueParameter.hasDefault`), nullability, type arguments, annotations | nothing new read — only *kept*. `KsAdapters.renderWithArguments` already walks type arguments |
-| artefact walker | `KmFunction` gives `valueParameters[].name`, `.declaresDefaultValue`, `KmType.isNullable`, `.arguments`, `receiverParameterType`; `ValueClassInfo` gives the rest | nothing new read — only *kept*. `functionsOf` currently throws away `KmValueParameter` and keeps `.type` |
+| KSP | `KSFunctionDeclaration` gives names, defaults (`KSValueParameter.hasDefault`), nullability, type arguments, annotations | nothing new read, only *kept*. `KsAdapters.renderWithArguments` already walks type arguments |
+| artefact walker | `KmFunction` gives `valueParameters[].name`, `.declaresDefaultValue`, `KmType.isNullable`, `.arguments`, `receiverParameterType`; `ValueClassInfo` gives the rest | nothing new read, only *kept*. `functionsOf` currently throws away `KmValueParameter` and keeps `.type` |
| Java classes in a walked jar (no `@Metadata`) | JVM descriptors only | **no Kotlin types, no parameter names, no nullability.** §3.2 |
| klib | **current:** `KlibScanner` builds `DeclarationModel` through `LibraryAbiReader` | what it cannot fill it declines on, and a declined declaration is not stubbed (`PythonStubsTask`); no klib declaration is bound at run time yet (SPEC B-2), so in practice no klib stub is emitted |
@@ -307,7 +311,7 @@ is being fetched and then dropped, one function before it would be used.
## 3. Kotlin → Python type mapping
-### 3.1 The table — the declared Kotlin type, a stub class per bound type
+### 3.1 The table, the declared Kotlin type, a stub class per bound type
**Current (issue #31).** A stub says what the Kotlin declaration says, wherever the runtime serves
that type. `renderKotlinFqnStubs` (`PyiRendering.kt`) maps each `KotlinTypeModel` with its boundary
@@ -363,14 +367,14 @@ nothing is emitted for that module (the runtime resolves the name to the functio
A class in a walked jar with no `@Metadata` goes down `ArtifactScanner.javaStaticCandidates`, which
has only the JVM descriptor. That path can produce `int`, `str`, `bytes`, `bool`, `float`, `None` and
-nothing else — no nullability, and **no parameter names** (`MethodNode.parameters` is present only
+nothing else, no nullability, and **no parameter names** (`MethodNode.parameters` is present only
when the jar was compiled with `-parameters`, which is not the default and cannot be assumed).
-Judged: for a Java-sourced declaration, emit positional-only parameters —
+Judged: for a Java-sourced declaration, emit positional-only parameters,
def assertEquals(__a0: int, __a1: int, /) -> None: ...
-— rather than inventing names. A wrong keyword name is worse than no keyword name, because it
+rather than inventing names. A wrong keyword name is worse than no keyword name, because it
type-checks at the call site and fails at run time. Reference return types from Java get `| None`,
because Java can return null and nothing in the bytecode says otherwise.
@@ -422,7 +426,7 @@ expressible and are not faked. The Kotlin type stays in the docstring.
- **The synthetic parameters are not stubbed.** Parameters come from `KmFunction` metadata, so
`$composer`, `$changed` and `$default` never appear (`docs/design/kotlin-extensions-in-python.md`
§4.6: metadata arity is the correct source).
-- **A required parameter after a defaulted one is keyword-only**, exactly as `inspect.signature` on a
+- **A required parameter after a defaulted one is keyword-only**: exactly as `inspect.signature` on a
binder-made callable reports it (`KotlinSurface.kt`): `def Column(modifier: Modifier = ...,
verticalArrangement: Any = ..., horizontalAlignment: Any = ..., *, content: Callable[[ColumnScope],
None])`, and the defaults stay marked. The one exception is a declaration with an unwritable
@@ -456,8 +460,10 @@ Python's one `int`/`float` and a class that fell back to `Any` cannot tell two K
so each overload after the first carries `# type: ignore[overload-cannot-match]`
(`TypedStubTest.everyOverloadAfterTheFirstSilencesTheCheckersNeverMatchedDiagnostic`).
-Parameters are keyword-capable under their **Kotlin** names (`all`, `fraction`, `alignmentLine`);
-nothing is converted to snake_case (`KotlinNamesOnlyStubTest.kotlinNamesAreNeverSnakeCased`). A name
+Parameters are keyword-capable under the keyword `inspect.signature` shows: the Pythonic one where an
+alias is served (`all`, `fraction`, `alignment_line`), else the Kotlin name; each module-level name also
+gets `alias = kotlinName` (issue #131, SPEC U-12;
+`KotlinNamesOnlyStubTest.aKotlinNameKeepsItsDefAndGainsItsPythonicAlias`). A name
that is not a Python identifier or is a Python keyword, or an unknown name, forces positional-only up
to and including that parameter, spelled `__a`
(`KotlinNamesOnlyStubTest.aParameterNamedLikeAPythonKeywordForcesAPositionalOnlyPrefix`). A Kotlin
@@ -472,7 +478,7 @@ default is `= ...`, because metadata carries `declaresDefaultValue` but not the
| ordinary class or interface | 514 | method on the receiver's stub class |
| Compose value class | 41 | method on the value class's stub |
| Kotlin collection | 29 | module-level function |
-| Kotlin primitive or `String` | 20 | module-level function — Python cannot attach a method to `int` |
+| Kotlin primitive or `String` | 20 | module-level function, Python cannot attach a method to `int` |
Nothing measured here disturbs that. Chaining is free because each of the 177 returns `Modifier`, and
the mypy run in archive §4.4 confirms `Modifier.size(4).padding(2)` type-checks as `Modifier` in a Pythonic stub.
@@ -510,22 +516,22 @@ attribute shape that replaced it are a Pythonic stub design: archive §4.2–§4
74 public member extensions on `Modifier` (`RowScope.weight`, `ColumnScope.align`,
`BoxScope.matchParentSize`) need a dispatch receiver Kotlin supplies implicitly from the enclosing
lambda. `docs/design/kotlin-extensions-in-python.md` §6 records that no shape has been chosen. Nothing here
-changes that, and **they must not be stubbed as if they were plain methods on `Modifier`** — a stub
+changes that, and **they must not be stubbed as if they were plain methods on `Modifier`**, a stub
that says `Modifier.weight(1.0)` checks is a stub that promises a call the runtime cannot make.
---
## 5. Package mapping
-There is none. A stub lives at the Kotlin module path the runtime publishes onto —
+There is none. A stub lives at the Kotlin module path the runtime publishes onto,
`androidx/compose/foundation/layout/__init__.pyi`, `junit/runner/Version/__init__.pyi` for a static on
-a class — and nothing is emitted under `pythonx/`
+a class, and nothing is emitted under `pythonx/`
(`KotlinNamesOnlyStubTest.theStubLivesUnderTheKotlinModulePathAndNothingLivesUnderPythonx`,
-`WalkedArtifactStubTest.theStubsUseKotlinNamesAndNothingIsExportedUnderPythonx`). The `androidx.` →
+`WalkedArtifactStubTest.theStubsKeepKotlinNamesAddPythonicAliasesAndExportNothingUnderPythonx`). The `androidx.` →
`pythonx.` default rule, the observation that `pythonx.compose.layout` wraps
`androidx.compose.foundation.layout`, and the `pythonx-map.toml` manifest are in archive §5.
-### 5.3 The Kotlin-FQN stubs — the one product
+### 5.3 The Kotlin-FQN stubs, the one product
Of the two products the original §5.3 named, only the first remains:
@@ -550,7 +556,7 @@ Of the mypy runs in `/Volumes/macMini/tmp/pyicheck`, the two that bear on the Ko
3. **A stub-only tree with no `.py` and no `__init__.py` resolves.** A directory containing only
`androidx/compose/material3/__init__.pyi` on `MYPYPATH` gave
`Revealed type is "def (text: str, color: int =, fontSize: int =)"` and rejected `Text(3)`. So
- the Kotlin-FQN stubs of §5.3 — which describe modules that exist only in `sys.modules` — need no
+ the Kotlin-FQN stubs of §5.3, which describe modules that exist only in `sys.modules`, need no
accompanying files.
4. Namespace packages need no `__init__.py` anywhere above the stub; mypy's `--namespace-packages`
is on by default.
@@ -575,7 +581,7 @@ So:
when chaquopy is applied; a `.pth` file in the venv for a desktop run; the interpreter path list
for PyCharm. This is per-environment and none of it was verified.
-**Current:** implemented as written for the first two bullets — `PythonStubsTask` writes
+**Current:** implemented as written for the first two bullets, `PythonStubsTask` writes
`build/generated/pythonStubs//`, deleting it first, and `generatePythonStubs` is a
dependency of `prepareKotlinIdeaImport` (`PythonBindingsPlugin.configureStubGeneration`). No
environment registration is implemented. A `pythonx` wheel's own stubs are pythonx-compose's (archive
@@ -586,14 +592,14 @@ environment registration is implemented. A `pythonx` wheel's own stubs are pytho
PyREPL's answer (§1.2) is the right shape: when the same qualified name is contributed by
`commonMain` and by a platform source set, rename both and write an `__init__.pyi` that re-exports
each. Its implementation splits on `"\\__init__.pyi"` and so does nothing outside Windows. The port
-must use `File.separator` — or better, operate on `Path` objects and never on path strings, which is
+must use `File.separator`, or better, operate on `Path` objects and never on path strings, which is
what made the bug invisible.
`docs/design/ecosystem.md` §5b's `JClass`/`KClass`/`ObjcClass` rule bears on this: a target that has no JVM
has no `JClass` and says so. The stubs must reproduce that per source set rather than emitting a
union that promises every platform's surface everywhere.
-**Current:** not implemented — `PythonStubsTask` is registered per source set and nothing detects or
+**Current:** not implemented, `PythonStubsTask` is registered per source set and nothing detects or
merges an overlap.
---
@@ -602,7 +608,7 @@ merges an overlap.
- **A class whose name is a function in its module is `Any`** (§3.1): `PaddingValues`, `TextStyle`,
`Color`, `AnnotatedString`, `Dp` in Compose 1.11.1.
-- **Supertypes are the nearest one only**, and only for types a bound declaration returns (§3.1); an
+- **Supertypes are the nearest one only**: and only for types a bound declaration returns (§3.1); an
interface a class implements besides its first listed supertype is not in the stub.
- **Type arguments of a non-function class are dropped** (§3.1).
- **Suspend functions** (§3.5) are bound and awaitable but not stubbed.
@@ -612,7 +618,7 @@ merges an overlap.
accepts them, `TypeVar` is the mapping and nothing here covers it.
- **klib.** The scanner produces declarations (§2.3), but none is bound at run time yet (SPEC B-2),
so none is stubbed.
-- **Member extensions** (§4.5) — 74 on `Modifier` — remain unstubbable until the scope-passing shape
+- **Member extensions** (§4.5), 74 on `Modifier`, remain unstubbable until the scope-passing shape
is chosen.
- **IDE pick-up** (§6.2): registering `build/generated/pythonStubs//` with an interpreter
or IDE is per-environment and none of it is implemented or verified; PyCharm was never tested
@@ -621,10 +627,10 @@ merges an overlap.
## 8. Checked with mypy, and published
-- `tools/stubs/check-stubs.sh ` runs `mypy --strict` over `tools/stubs/consumer.py` (a
+- `.github/scripts/stubs/check-stubs.sh ` runs `mypy --strict` over `.github/scripts/stubs/consumer.py` (a
`Modifier` chain from the class and from an instance, `Checkbox`, `Text`, `Column` with its keyword
`content`, and four lines that must be rejected) against stubs generated for the real Compose jars
- (`:ksp-fixtures:compose:generatePythonStubs`, which `ksp-fixtures/compose` now enables). Errors inside
+ (`:python-multiplatform-compose:fixtures:compose:generatePythonStubs`, which `python-multiplatform-compose/fixtures/compose` now enables). Errors inside
the stubs are reported as well, not only in the consumer.
- `.github/workflows/stubs.yml` runs the stub task and that check on every push to `develop`, uploads
the stubs as the workflow artifact `kotlin-stubs` (with a README naming the Compose version and the
diff --git a/docs/design/threading-and-abi.md b/docs/design/threading-and-abi.md
index da806838e..22865f0cc 100644
--- a/docs/design/threading-and-abi.md
+++ b/docs/design/threading-and-abi.md
@@ -14,7 +14,7 @@ The library supports **two flavours**, chosen at build time:
The interpreter version is pinned by `pythonVersion` in `gradle.properties` (3.14.7 at the time of
writing). Free-threading is **not** gated on 3.15t: the whole desktop suite runs on 3.14.7t
-(236 tests, 0 failures, 1 skipped — ROADMAP §9; SPEC T-2). Only desktop has free-threaded
+(236 tests, 0 failures, 1 skipped, ROADMAP §9; SPEC T-2). Only desktop has free-threaded
prebuilts; Android and iOS have none, so the opt-in is desktop-only. How the earlier "from 3.15t"
conclusion was reached, and why it did not hold, is in
[`../archive/threading-and-abi-3.15t-gate.md`](../archive/threading-and-abi-3.15t-gate.md).
@@ -24,7 +24,7 @@ conclusion was reached, and why it did not hold, is in
PEP 684 gives real parallelism on GIL builds by giving each sub-interpreter its own GIL. It was
evaluated and rejected for this project.
-It is technically reachable. `Py_NewInterpreterFromConfig` is exported from libpython —
+It is technically reachable. `Py_NewInterpreterFromConfig` is exported from libpython,
verified with `nm` on `libpython3.14.dylib`:
```
@@ -48,7 +48,7 @@ typedef struct {
The Stable ABI is a compile-time contract, not a runtime one, and this project resolves symbols
dynamically, so calling a non-limited symbol is possible.
-The blocker is not the ABI — it is **extension compatibility**. Per-interpreter GIL requires every C
+The blocker is not the ABI, it is **extension compatibility**. Per-interpreter GIL requires every C
extension to implement multi-phase init and declare `Py_mod_multiple_interpreters`; extensions that
do not simply fail to import. That would cost exactly the Python libraries this project exists to
share, in return for parallelism.
@@ -68,12 +68,12 @@ available on free-threaded builds for extension *modules*. What it asks of us if
| Requirement | Our status |
|---|---|
| `PyObject` / `PyVarObject` become incomplete types; no field access | **Compatible.** `PyObject` appears only as an opaque pointer and is never dereferenced. |
-| Extensions may not embed `PyObject` in their own structs | Compatible — nothing does. |
+| Extensions may not embed `PyObject` in their own structs | Compatible, nothing does. |
| `PyModExport` hook (PEP 793) instead of static `PyModuleDef` | Not relevant: we embed CPython rather than building an extension module. It would matter if Kotlin classes were ever exposed as a CPython extension module. |
## PEP 809 may collapse the two flavours into one (forward-looking)
-[PEP 809](https://peps.python.org/pep-0809/) — **Draft**, also targeting 3.15 — would replace `abi3`
+[PEP 809](https://peps.python.org/pep-0809/), **Draft**, also targeting 3.15, would replace `abi3`
with time-bound versioned ABIs (`abi2026`), each frozen for at least ten years with at least five
years of overlap. Crucially, a single extension compiled against `abi2026` supports **both**
free-threaded and GIL-enabled builds.
@@ -96,7 +96,7 @@ Desktop (macOS arm64/x86_64, Linux x86_64, Windows x86_64) has GIL and free-thre
python-build-standalone releases; the build uses the `-freethreaded` archive when
`pythonFreeThreaded=true`. **Android (python.org) and iOS (Python-Apple-support) publish no
free-threaded build**, so free-threading stays a desktop-only opt-in, and enabling it by default
-would split the threading model — and the object-lifetime and thread-state design layered on top —
+would split the threading model, and the object-lifetime and thread-state design layered on top,
across platforms. Switching the default would additionally need adequate free-threaded wheel
coverage for the C extensions users care about. Table as of the 20260807 release, kept in the
archive note above.
diff --git a/docs/design/typedpython.md b/docs/design/typedpython.md
index ff3bc4639..d2089d25e 100644
--- a/docs/design/typedpython.md
+++ b/docs/design/typedpython.md
@@ -1,4 +1,4 @@
-# TypedPython — 설계 제안
+# TypedPython, 설계 제안
> **참고 (2026-10-03):** §4.4 가 전제하는 스텁 매니페스트(`pythonx-map.toml`, 그리고 그것을 읽어
> `pythonx.*` 스텁을 내던 `StubManifest`/`renderPythonicStubs`)는 이 저장소의 스텁 생성기에서
@@ -11,7 +11,7 @@
> 구현하면서 달라질 수 있다.
> **결정 (2026-10-02, 사용자):** TypedPython 은 별도 레포가 아니라 **PythonMultiplatform 의 기본
-> 동작**이다. 범위는 **"검사는 기본, 컴파일은 opt-in"** — 사용자 프로젝트의 파이썬 코드는 빌드할 때
+> 동작**이다. 범위는 **"검사는 기본, 컴파일은 opt-in"**, 사용자 프로젝트의 파이썬 코드는 빌드할 때
> 항상 정적 타입 검사를 거치고, 네이티브 컴파일은 표시한 코드에만 적용된다 (§2.3).
---
@@ -31,7 +31,8 @@ TypedPython 의 목표는 **새 언어를 만들지 않고, 파이썬 문법 그
1. 타입이 확정된 코드는 **네이티브로 컴파일**해서 앱에 싣는다. PythonMultiplatform 의 배포 목표인
"파이썬 부분도 컴파일된 채로 들어간다" 와 같은 자리다.
-2. GPU 커널을 **별도 언어(CUDA, Metal Shading Language, Mojo) 없이** 파이썬 서브셋으로 쓴다.
+2. GPU 커널과 CPU SIMD 를 **별도 언어(CUDA, Metal Shading Language, Mojo) 없이** 파이썬 서브셋으로
+ 쓴다. 2026-10-04 에 탐색에서 약속으로 올라갔다 (INTENT §1.5, §5).
## 2. 원칙
@@ -59,10 +60,10 @@ TypedPython 의 목표는 **새 언어를 만들지 않고, 파이썬 문법 그
| 등급 | 대상 / 표기 (안) | 보장 | 출력 |
|---|---|---|---|
-| 0 | 서드파티 패키지 (의존성) | 없음 — 검사하지 않는다 | 바이트코드 (pypackpack 이 받은 그대로) |
-| 1 `checked` | **사용자 프로젝트의 파이썬 코드 전부 — 기본값** | 빌드 타임 타입 오류, `Any` 유출 0 (§4.2) | 바이트코드 |
-| 1 `compiled` | `@compiled` (내장 이름, import 없음) 또는 모듈 선언 `# typedpython: compiled` | `checked` + §3.1 금지 목록 | C 확장 모듈 |
-| 2 `kernel` | `@kernel` | 객체·힙 할당 없음, 고정 dtype | SPIR-V / MSL / WGSL / PTX |
+| 0 | 서드파티 패키지 (의존성) | 없음, 검사하지 않는다 | 바이트코드 (pypackpack 이 받은 그대로) |
+| 1 `checked` | **사용자 프로젝트의 파이썬 코드 전부, 기본값** | 빌드 타임 타입 오류, `Any` 유출 0 (§4.2) | 바이트코드 |
+| 1 `compiled` | `@compiled` (내장 이름, import 없음) 또는 모듈 선언 `# typedpython: compiled` | `checked` + §3.1 금지 목록 | C 확장 모듈 (NEON · AVX 벡터화, §5.3) |
+| 2 `kernel` | `@kernel` | 객체·힙 할당 없음, 고정 dtype | SPIR-V / MSL (직접) / WGSL / PTX, 빌드 타임 (§5) |
- **사용자 코드는 표시하지 않아도 검사된다.** 타입 오류가 있으면 Kotlin 컴파일 에러처럼 빌드가
실패한다. TS 의 `strict` 기본값과 같은 위치다.
@@ -73,13 +74,13 @@ TypedPython 의 목표는 **새 언어를 만들지 않고, 파이썬 문법 그
§3.1 금지 목록을 지키는 것뿐이다.
- 등급 사이의 경계에서는 **검사된 변환**이 일어난다.
-## 3. 등급 1 — checked / compiled
+## 3. 등급 1, checked / compiled
### 3.1 금지 목록
-`compiled` 코드에서는 다음을 컴파일 에러로 다룬다. `checked` 코드에서는 경고로 보고한다 — 기본 검사가
+`compiled` 코드에서는 다음을 컴파일 에러로 다룬다. `checked` 코드에서는 경고로 보고한다, 기본 검사가
기존 파이썬 관용구를 막아 채택을 해치지 않게 하면서, `@compiled` 로 옮기기 전에 무엇을 고쳐야 하는지
-미리 보이게 하려는 것이다. (이 경고/에러 구분은 제안이다 — §8.)
+미리 보이게 하려는 것이다. (이 경고/에러 구분은 제안이다, §8.)
- 변수의 타입 재할당 (`x = 1` 뒤 `x = "a"`)
- `eval` / `exec`, 이름을 문자열로 만드는 `getattr`/`setattr`
@@ -97,7 +98,7 @@ TypedPython 의 목표는 **새 언어를 만들지 않고, 파이썬 문법 그
## 4. 등급 1 의 구현
-### 4.1 프론트엔드 — Pyrefly
+### 4.1 프론트엔드, Pyrefly
Meta 의 Pyrefly 를 타입 추론기로 쓴다.
@@ -108,7 +109,7 @@ Meta 의 Pyrefly 를 타입 추론기로 쓴다.
**포크하지 않는다.** 처음에는 CLI 와 기계 판독 가능한 출력 수준에서 붙이고, 라이브러리 임베드는
API 안정성을 확인한 뒤로 미룬다.
-### 4.2 건전성 게이트 — TypedPython 고유의 일
+### 4.2 건전성 게이트, TypedPython 고유의 일
타입 체커와 컴파일러는 목표가 반대다.
@@ -125,7 +126,7 @@ API 안정성을 확인한 뒤로 미룬다.
**이 게이트만으로도 독립적인 가치가 있다.** 컴파일러가 없어도 "사용자 코드에는 `Any` 가 없다" 는
보장이 생기고, 그것이 AI 친화적 피드백 루프의 핵심이다. 이후 모든 단계가 이 게이트를 전제로 한다.
-### 4.3 백엔드 — 자체 typed IR → C (결정 2026-10-03, #41)
+### 4.3 백엔드, 자체 typed IR → C (결정 2026-10-03, #41)
**Cython 은 쓰지 않는다** (사용자: "Cython 의존 별로야. 2단계로 바로 진행하도록 해"). Cython 경로
(PR #39)는 닫았고, 거기서 검증된 의미 보존 규칙만 IR 계약으로 옮겼다.
@@ -136,7 +137,7 @@ API 안정성을 확인한 뒤로 미룬다.
- **IR 이 계약이다** (`typedpython/ir.py`). 각 노드는 CPython 동작으로 정의된다: 검사되는 i64 산술과
오버플로 시 deopt(인터프리터 재실행), 파이썬식 floor 나눗셈·나머지, 정확한 int/float 비교, 리스트식
인덱싱, `math` 모듈의 예외. C 런타임 API 는 `typedpython/runtime/API.md` 에 고정했다.
-- **왜 C 인가, LLVM 이 아니라.** 모든 타깃에 C 컴파일러가 이미 있다(clang, NDK, Xcode) — 새 의존이 없다.
+- **왜 C 인가, LLVM 이 아니라.** 모든 타깃에 C 컴파일러가 이미 있다(clang, NDK, Xcode), 새 의존이 없다.
CPython C API 가 C 이고, 플랫폼 디버거가 그대로 쓰인다. 타입이 확정된 스칼라·배열 코드에서 중요한
최적화(인라이닝, 벡터화)는 C 컴파일러가 이미 한다. IR 이 안정 계층이라 LLVM 백엔드는 나중에도 붙일 수 있다.
- **왜 Rust 가 아닌가.** 파이썬의 별칭과 공유 변경은 Rust 소유권에 맞지 않아 생성 코드가
@@ -148,7 +149,7 @@ API 안정성을 확인한 뒤로 미룬다.
### 4.3.1 안전성은 IR 에서 증명한다 (SPEC N-9)
-사용자 결정: "IR 단계에서 안전성을 증명하도록 해". C 를 만들기 전에 `verify.py` 가 IR 에서 증명한다 —
+사용자 결정: "IR 단계에서 안전성을 증명하도록 해". C 를 만들기 전에 `verify.py` 가 IR 에서 증명한다,
정의 전 사용 없음, 노드 타입 일치, 비순수 함수에 deopt 노드 없음, 배열 접근은 경계 증명 또는 런타임 검사
유지, 배열 파라미터는 인덱싱·저장·len 만, OBJ 참조는 하나의 소유 규칙. **증명하지 못한 함수는 컴파일하지
않고** 인터프리터에 남기며 이유를 진단으로 낸다. 생성 C 는 `tp_runtime.h` 헬퍼로만 메모리와 `PyObject`
@@ -163,31 +164,32 @@ Gradle 태스크는 입력·출력을 정확히 선언해 up-to-date 판정과
### 4.3.3 고정 레이아웃 클래스 (SPEC N-11, M2b)
-**레이아웃이 CPython 에서도 고정일 때만** 고정 접근으로 컴파일한다 — 사용자가 `__slots__` 를 직접 쓴 클래스.
+**레이아웃이 CPython 에서도 고정일 때만** 고정 접근으로 컴파일한다, 사용자가 `__slots__` 를 직접 쓴 클래스.
컴파일러가 `__slots__` 를 몰래 더하면 `__dict__`·`vars()`·속성 추가가 CPython 과 달라지므로("같은 결과" 원칙
위반) 하지 않는다. binary-trees 벤치의 `Node` 는 그래서 `__slots__ = ("left", "right")` 를 소스에 적어야
컴파일되고, 그 변경은 모든 언어 열에 공평하게 적용되도록 벤치 소스 자체에 넣는다(CPython 기준선 재측정 필요).
- **필드 접근:** `type(x) is C` 로 가드된 값의 필드는 member descriptor 의 오프셋으로 바로 읽고 쓴다. 오프셋은
모듈 초기화 때 클래스 사전의 descriptor 가 정말 member descriptor 인지 확인하고 잡는다. 값은 객체 그대로다.
-- **순수성:** 슬롯 읽기는 사용자 코드를 실행하지 않으므로 효과가 아니다 — `check(node)` 같은 재귀 순회가
+- **순수성:** 슬롯 읽기는 사용자 코드를 실행하지 않으므로 효과가 아니다, `check(node)` 같은 재귀 순회가
순수 함수로 남아 i64 경로를 쓸 수 있다.
- **생성:** `__init__` 이 매개변수를 각 슬롯에 한 번씩 대입하기만 하면 `C(a, b)` 를 할당 + 슬롯 채우기로
내린다. 그 밖의 `__init__` 은 객체 호출로 남는다.
-- **메서드:** `self` 를 `type(self) is C` 로 가드한 함수로 컴파일한다.
-- **IR 확장(예정):** `FieldGet(obj, cls, field)`, `FieldSet(obj, cls, field, value)`, `New(cls, args)`,
- 그리고 정확한 타입 가드 `IsExact(obj, cls)` 조건. 검증기는 가드 없는 필드 접근, 슬롯 밖 필드, 생성자 규칙
- 위반을 거부한다.
+- **메서드(예정):** `self` 를 `type(self) is C` 로 가드한 함수로 컴파일한다. 아직 구현 전이다.
+- **IR (구현됨, #124):** `FieldGet`, `FieldSet`, `New`, `IsExact`, `CheckExact`, `Is`, `ClassDecl`, `Param.cls`.
+ 검증기는 정확한 타입 증명이 없는 필드 접근, 슬롯 밖 필드, 생성자 규칙 위반을 거부한다.
+- **초기화 뒤 변경 (구현됨):** 슬롯 접근마다 정확한 타입과 클래스의 `tp_version_tag` 를, 생성마다 전역 바인딩을
+ 확인하고, 다르면 일반 경로(비순수) 또는 deopt(순수)로 간다. 세부는 SPEC N-11.
검토하고 기각한 경로:
-- **Cython** — 사용자 결정으로 폐기(위).
-- **mypyc** — 타입 체커가 mypy 에 묶여 있어 Pyrefly 와 판정이 갈라진다.
-- **Nuitka** — 동작 동일성을 우선해 타입 특수화를 하지 않는다.
-- **LPython** — 업스트림 활동이 LFortran 쪽으로 옮겨간 것으로 보인다.
-- **Rust 생성** — 위.
+- **Cython**: 사용자 결정으로 폐기(위).
+- **mypyc**: 타입 체커가 mypy 에 묶여 있어 Pyrefly 와 판정이 갈라진다.
+- **Nuitka**: 동작 동일성을 우선해 타입 특수화를 하지 않는다.
+- **LPython**: 업스트림 활동이 LFortran 쪽으로 옮겨간 것으로 보인다.
+- **Rust 생성**: 위.
-### 4.3.3 재귀 (SPEC N-8, #57)
+### 4.3.3a 재귀 (SPEC N-8, #57)
호출 사이클(직접·상호 재귀)은 컴파일한다. 컴파일된 프레임에는 파이썬 프레임이 없으므로 모든 컴파일
함수의 진입에서 `tp_enter_call()`, 모든 출구(ok·error·deopt)에서 `tp_leave_call()` 을 부른다. 진입은
@@ -197,12 +199,194 @@ Gradle 태스크는 입력·출력을 정확히 선언해 up-to-date 판정과
지킨다. **측정(3.13.0, 3.14.7, 기본 한도 1000):** 인터프리터와 컴파일 재귀 모두 같은 깊이(997)에서
실패하고, 인터프리터 프레임 k 개 위에서 컴파일 재귀를 시작해도 `최대 n + (추가 프레임 수)` 가 일정해
오프셋이 없다. 오프셋이 생기는 곳은 둘이다. (1) 한도를 C 스택이 먼저 닫을 만큼 올리면(예: 100만) C 가드의
-메시지가 나온다 — 3.13 은 `maximum recursion depth exceeded in compiled code`, 3.14 는
-`Stack overflow (used 8144 kB) in compiled code` — 인터프리터는 같은 한도에서 파이썬 한도 메시지를 낸다.
+메시지가 나온다, 3.13 은 `maximum recursion depth exceeded in compiled code`, 3.14 는
+`Stack overflow (used 8144 kB) in compiled code`, 인터프리터는 같은 한도에서 파이썬 한도 메시지를 낸다.
(2) 카운터는 모듈별(헤더가 `static`)이라, 한 컴파일 모듈의 두 프레임 사이에 **다른** 컴파일 모듈의
프레임이 끼면 그 프레임은 세지 않는다. 테스트는 두 경우 모두 "둘 다 예외를 내고 죽지 않는다" 만 단언하고
깊이를 기록한다(`test_recursion.py`).
+### 4.3.4 객체 비용: 가상 객체, refcount 생략, 영역 할당 (SPEC N-12, 설계)
+
+사용자 지시 (2026-10-03): "Rust 쪽으로 더 가는 방법을 적용하되 cpython과 동일성을 보장할 방법을 찾아."
+
+**왜 필요한가.** 숫자·배열 코드는 이미 Rust 근처다(nbody 31x, Rust 의 약 2배). 객체가 많은 코드는 그렇지
+않다. binary-trees 는 1.7x 인데, 컴파일된 코드도 노드마다 CPython 할당(`tp_alloc`: 0 채우기, GC 헤더,
+GC 추적), refcount 증감, 해제(`subtype_dealloc`)를 그대로 치르기 때문이다(#125, 아직 프로파일로 확정 전).
+더 가려면 객체를 아예 만들지 않거나(가상 객체), 증감을 빼거나(refcount 생략), 한꺼번에 할당·해제해야
+한다(영역 할당). 셋 다 `id()`, `__del__` 시점, `gc` 관측과 부딪칠 수 있다.
+
+**동일성의 정의.** 컴파일된 코드의 모든 실행은 **어떤 유효한 CPython 실행과 구별할 수 없어야 한다.**
+구별은 관측으로만 할 수 있으므로, 설계는 "관측 지점" 목록과 "관측 지점에 닿기 전에 실제 객체로 되돌린다"는
+규칙으로 이루어진다. CPython 자체가 정하지 않는 것(스레드 전환 시점)은 CPython 이 고를 수 있는 실행 중
+하나를 고른 것으로 본다. 컴파일된 코드는 GIL 을 놓지 않으므로, 컴파일 구간 안에서 다른 스레드가 끼어드는
+실행은 생기지 않는다. 이는 CPython 이 그 구간에서 전환하지 않은 실행과 같다.
+
+> **free-threaded 3.15t 에서 다시 볼 점 (#193, #159).** 위 문장은 GIL 빌드의 논증이다. 3.15t 에는 GIL 이 없어 다른
+> 스레드가 컴파일 구간과 동시에 돈다. 그래서 다른 스레드에서 보일 수 있는 객체에 대한 아래의 최적화(특히 나의
+> refcount 생략)는 스레드 지역으로 증명된 객체로 제한한다. 가상 객체는 정의상 실체화 전까지 다른 스레드가 볼 수
+> 없으므로 영향이 없다.
+
+#### 가. 가상 객체 (부분 탈출 분석 + 지연 실체화)
+
+PyPy·GraalVM 과 같은 방식이다. N-11 클래스의 객체를 PyObject 대신 C 구조체(필드 값만)로 두고, 관측
+지점에 닿기 직전에 실제 PyObject 로 만든다(실체화). 실체화된 뒤로는 인터프리터와 같은 객체이므로 결과가 같다.
+
+- **대상 클래스.** N-11 조건에 더해 `__del__` 이 없고 `__slots__` 에 `__weakref__` 가 없는 클래스만. 이 둘이
+ 있으면 객체의 죽는 시점이 사용자 코드로 관측된다. `base = object` 이고 슬롯이 고정이므로 weakref 는
+ `__weakref__` 슬롯으로만 생긴다.
+- **관측 지점(실체화 트리거).** 아래에 닿는 가상 객체는 그 직전에 실체화한다.
+
+ | 관측 | 이유 |
+ |---|---|
+ | 함수 밖으로 반환, 전역·컨테이너·실객체 필드에 저장 | 컴파일 구간 밖에서 보인다 |
+ | 미지 코드 호출의 인자, `id()`, 기본 `hash()`·`repr()` | 객체 자체나 주소가 드러난다 |
+ | **살아 있는 동안 일어나는 모든 미지 코드 호출** | 인자로 넘기지 않아도 `gc.get_objects()`·`gc.get_referrers()`·`tracemalloc` 이 존재와 메모리를 본다. 그래서 그 시점에 살아 있는 가상 객체는 **전부** 실체화한다 |
+ | deopt | 순수 함수의 deopt 는 인자부터 인터프리터로 다시 실행하므로 내부 가상 객체는 버리면 된다. 비순수 함수는 효과 뒤 deopt 가 없다(N-9) |
+
+ `is`, `type(x)`, `isinstance` 는 관측 지점이 아니다. 두 가상 객체의 `is` 는 할당 지점 동일성으로 정적으로
+ 정해지고, 가상 객체와 실객체는 같을 수 없다. 타입은 정적으로 안다.
+- **실체화 순서.** 할당 순서대로 만든다. `gc.get_objects()` 의 순서도 관측되기 때문이다. 실체화는 도달
+ 가능한 가상 그래프 전체를 만들고 필드를 채운 뒤 진행한다.
+- **효과.** 가상 객체는 미지 코드 호출이 없는 구간에서만 산다. 트리를 만들고 바로 소비하는 코드(binary-trees
+ 의 `check(make(d))`)는 그런 구간이다. `print` 를 섞는 루프는 매 반복 실체화하므로 지금과 같다.
+
+#### 나. refcount 생략
+
+- 같은 객체의 증가·감소 쌍을 빼는 것은 **그 사이 내내 다른 소유 참조가 그 객체를 살려 둔다**는 것과
+ **그 사이에 미지 코드 호출이 없다**는 것을 IR 에서 증명할 때만 한다. 감소가 0 에 닿을 수 있는 지점은
+ 해제 지점이므로 빼지 않는다.
+- 첫 대상은 빌린 읽기다. 예를 들어 `check(node.left)` 는 지금 `FieldGet` 이 새 참조를 만들어 넘기고 돌아와
+ 놓는다. 바깥 객체가 소유되어 있고, 그 슬롯이 그동안 쓰이지 않고, 사용자 코드가 돌지 않으면 빌린 참조로
+ 넘길 수 있다.
+- `sys.getrefcount` 는 호출로만 관측되고, 호출은 위 조건으로 막혀 있으므로 관측되는 refcount 는 늘 정확하다.
+ **free-threaded 3.15t 에서는 이 논증이 성립하지 않는다(#193, 결정 D5).** GIL 이 없으므로 다른 스레드가 그
+ 사이에 슬롯을 쓰고 자식을 해제할 수 있다. 그래서 빌린 읽기와 refcount 생략은 **스레드 지역으로 증명된 객체에만**
+ 적용하고, 나머지는 실제 참조를 잡거나 CPython 의 객체별 critical section 안에서 읽는다 (§4.3.5).
+
+#### 다. 영역(arena) 할당
+
+- 개수 상한이 있는 가상 객체는 C 스택에 둔다. 재귀 구조처럼 개수에 상한이 없는 가상 객체(`make(d)` 의 트리)는
+ **활성(activation) 하나에 묶인 영역**에 할당하고, 그 활성이 끝날 때 한꺼번에 놓는다.
+- **실객체는 영역에 두지 않는다.** CPython 이 실객체를 하나씩 해제하기 때문이다(`tp_free`). 영역에는 가상
+ 객체만 있고, 실체화는 영역 밖에 실객체를 새로 만든다.
+- **수명.** 영역은 수명을 감싸는 가장 안쪽 범위(루프 한 반복 등)에 둔다. CPython 이 일찍 놓는 객체를 영역이
+ 함수 끝까지 붙잡으면 최대 메모리가 커진다. 그 차이는 `tracemalloc` 같은 관측에서 드러날 수 있지만,
+ 관측은 미지 코드 호출로만 일어나고 그 직전에 실체화하므로, 남은 차이는 프로세스 메모리 총량뿐이다.
+- **할당 실패.** 영역 할당이 실패하면 CPython 이 그 할당에서 냈을 `MemoryError` 를 같은 지점에서 낸다.
+- **함수 경계.** `make` 가 만든 트리를 호출자가 바로 소비하면, 호출자의 영역에 쓰는 `make` 의 변형을 따로
+ 만든다. 할당 지점 특수화이고, 공개 진입점(wrapper)은 늘 실체화한 객체를 돌려준다.
+
+#### 라. 증명과 시험
+
+- **증명(N-9 확장).** 검증기가 증명하는 것은 넷이다. 가상 객체가 영역·스택 밖으로 새지 않는다. 모든 관측
+ 지점 앞에 실체화가 있다. 생략한 refcount 쌍마다 살려 두는 참조가 있다. 영역 수명이 그 안의 모든 참조
+ 수명을 감싼다. 증명하지 못하면 N-11 경로(실객체)로 컴파일한다.
+- **차등 시험.** 관측 연산을 일부러 넣은 경우를 인터프리터와 비교한다. 대상은 다음과 같다.
+ - 가상 객체에 대한 `id()`, 기본 `hash`·`repr`.
+ - 살아 있는 동안 부른 함수 안에서의 `gc.get_objects()` 수·순서, `gc.get_referrers`.
+ - 호출 경계의 `sys.getrefcount`, `tracemalloc` 스냅샷.
+ - 가상 객체와 실객체 사이의 `is`.
+ - 생성 도중 `MemoryError`. 시험용 할당기 훅으로 일으킨다.
+ - `__del__`·`__weakref__` 가 있는 클래스. 대상에서 빠지는지만 본다.
+- **변형 시험.** 미지 호출 앞 실체화를 빼면 `gc.get_objects` 시험이 실패해야 한다. refcount 쌍의 살려 두는
+ 참조 조건을 빼면 해제 시점 시험이 실패해야 한다.
+
+#### 마. 동일성을 지키려면 결정이 필요한 점 (사용자 결정, 리더 경유)
+
+1. **GC 수집 시점.** CPython 3.12 이후 세대 수집은 GC 추적 객체 할당 수로 예약되고, 다음 eval breaker 검사에서
+ 실행된다. 가상 객체는 할당되지 않으므로 수집이 늦어지거나 빠진다. 그러면 **다른 곳의** 순환 쓰레기의
+ `__del__`, weakref 콜백, `gc.callbacks` 가 실행되는 시점이 옮겨간다.
+ - (a) 지키는 방법: 가상 객체를 만들 때마다 GC 할당 카운터를 실제 할당과 같이 올리고, 수집이 예약되면 그
+ 자리에서 실행한다. 둘 다 CPython 내부 구조체(비공개 API)를 쓴다. 임베디드 CPython 판이 고정이라 가능은
+ 하지만 판이 바뀔 때마다 다시 확인해야 한다.
+ - (b) 범위 밖으로 두는 방법: 언어 레퍼런스는 수집 시점을 구현이 정하도록 허용하고(데이터 모델 §3.1),
+ CPython 도 3.13→3.14 에서 증분 GC 로 바꾸며 시점이 달라졌다.
+ - **결정 (2026-10-04, 사용자): (b).** GC 수집 시점은 동일성 범위에서 뺀다. 그 밖의 모든 관측은 정확히 지킨다.
+2. **이미 있는 차이: eval breaker 를 검사하지 않는다(N-8 부터).** 컴파일된 코드는 루프 안에서 eval breaker 를
+ 보지 않는다. 그래서 긴 컴파일 루프 동안 넷이 미뤄진다: 시그널(`KeyboardInterrupt`), 예약된 GC, pending
+ call, **다른 스레드로의 GIL 넘김.** 앞의 셋은 시점 차이지만, 마지막은 다른 스레드가 굶는 동작 차이다.
+ 루프 뒤 간선(back-edge)과 호출에서 eval breaker 를 검사하면 고쳐진다.
+ **1단계 구현 (#141):** 루프와 함수 진입에서 폴링하고, 일정 횟수마다 모듈의 `lambda: None` 을 불러 CPython 자기
+ eval breaker 처리를 그대로 돌린다. 배열 copy-in·전역 스냅샷을 쥔 함수가 실행 중이면 미룬다. 그 함수 안에서의
+ GIL 넘기기는 2단계(#154)이고, 방식은 결정 대기(#152).
+ **비용 (2026-10-17, 조용한 창, develop 대비 교대 11쌍):** fannkuch +8.2% (중앙값 1.082, 범위 1.043~1.191),
+ nbody 0.995 (배열 스냅샷 함수는 폴링하지 않음). 루프 폴링을 뺀 실험판은 fannkuch 1.000 이므로 남은 비용은
+ 전부 짧은 안쪽 루프의 매 반복 카운트다운이다. CPython 도 모든 back-edge 에서 eval breaker 를 검사하므로 같은
+ 종류의 비용이고, 시그널·스레드 굶김과 3.15t 의 GC stop-the-world 차단을 고치는 대가로 받아들였다(리더 결정).
+ range 루프의 묶음 폴링은 후속 이슈다. 원자료: `benchmarks/typedpython/results/2026-10-17-eval-breaker-*.json`.
+3. **이미 있는 차이: 추적·모니터링.** 컴파일된 함수는 `sys.settrace`·`sys.setprofile`·`sys.monitoring`
+ 이벤트를 내지 않는다. 진입에서 추적이 켜져 있으면 인터프리터로 실행하는 방법(deopt 경로 재사용)을 제안한다.
+ 이것도 별도 이슈다.
+
+이 셋은 N-12 이전부터 있는 경계도 포함한다. 1번이 결정되어 N-12 구현을 시작한다. 2번은 #141(1단계 완료), 3번은 #142.
+
+### 4.3.5 소유권 추론, 메모리, 인터프리터 최소 상주 (설계, 결정 2026-10-05)
+
+사용자 질문 (2026-10-05): "GC의 동작을 smart pointer로 전환하는 컴파일러를 개발하는게 가능할까?", 그리고
+CPython 의 두 약점(Rust 대비 연산 속도와 메모리 사용) 중 메모리가 지금 설계로 얼마나 풀리는가. 근거와 측정은
+[`typedpython-ownership-memory.md`](../investigations/typedpython-ownership-memory.md).
+
+**전제.** CPython 객체는 이미 객체 안에 refcount 를 둔 smart pointer 이고, GC 는 순환만 맡는다. 그래서 "smart
+pointer 로 전환" 의 실제 뜻은 컴파일 타임에 소유권을 추론해 refcount 연산과 GC 부담을 없애는 것이다. GC 를
+통째로 없애지는 못한다. 인터프리터와 서드파티 코드가 같은 힙을 쓰고, 사용자가 순환 구조를 만들 수 있다.
+
+#### 가. 소유권 추론 (관측 차이 없음, 결정 불필요)
+
+§4.3.4 의 가상 객체(고유 소유), refcount 생략(빌림), 영역 할당(영역 소유)을 함수 경계 너머로 넓힌다.
+
+- 컴파일된 함수의 매개변수와 반환값에 **소유 이전 / 빌림** 을 추론해 붙인다. 선례는 Lean 4 의 borrow 추론,
+ Koka Perceus, Lobster(런타임 refcount 연산 약 95% 제거 보고), Swift OSSA 다. 이들은 불변성이나 단일 소유자
+ 가정에서 이득을 얻는데, 파이썬에는 그 가정이 없으므로 이득은 증명되는 범위에 한정된다.
+- **free-threaded 에서 효과가 크다.** 3.15t 에서 refcount 증감 한 쌍은 현재 스레드 소유 객체에 약 3.2 ns, 다른
+ 스레드가 만든 객체에 약 14.2 ns 다(실측). 다만 빌린 참조는 **스레드 지역으로 증명된 객체에만** 쓴다(#193).
+- Perceus 식 제자리 재사용(refcount 1 이면 해제 대신 재사용)은 `gc.get_objects` 순서와 `tracemalloc` 에서
+ 관측되고, 유일 참조 판정 API 가 stable ABI 밖이라 지금은 하지 않는다.
+
+#### 나. 메모리: 무엇이 줄고 무엇이 남는가
+
+실측(Rust 대비, 3.15t): float 40 B 대 8 B, int 44 B 대 8 B, 실수 필드 2 개 객체 약 157 B 대 16 B, 정수 리스트
+원소당 56 B 대 8 B, 빈 인터프리터 실점유 약 7.6 MB 대 Rust 빈 프로그램 RSS 1.4 MB. 지금 설계(가상 객체, 영역,
+i64/f64 지역)는 **컴파일된 구간 안의 임시 값**만 줄인다. 컨테이너·전역에 사는 오래 가는 데이터와 인터프리터
+바닥은 그대로다.
+
+결정에 따라 다음을 한다.
+
+- **스칼라 슬롯 직접 저장 (D1, D2, D3).** `@compiled` 클래스에서 `int`·`float` 로 선언된 슬롯은 객체 포인터
+ 대신 i64/f64 값을 객체 안에 저장한다(객체당 약 3.3 배 감소, 실측 근거는 조사 문서). 인터프리터가 읽을 때마다
+ 새 int/float 를 만든다.
+ - **D1 (예):** 슬롯에서 읽은 정확한 `int`·`float` 값의 동일성(`is`, `id`)은 동일성 보장에서 뺀다. 언어 규격이
+ 불변 값의 동일성을 구현에 맡기므로 "어떤 유효한 구현과 구별할 수 없다" 는 원칙 안이다. 서브클래스 값은 대상이
+ 아니다.
+ - **D2 (예, 조건부):** 이 클래스의 타입은 컴파일러가 `PyType_FromSpec` 으로 만든다. `__module__`,
+ `__qualname__`, `__slots__`, MRO, pickle, `inspect` 결과가 원래 `class` 문과 같음을 차등 시험으로 보인다.
+ - **D3 (예, 조건부):** 이 클래스의 슬롯에 넣는 값은 선언 타입을 강제한다(그 자리에서 `TypeError`). 사용자
+ 코드는 이미 게이트를 통과하므로 막히는 것은 타입을 어긴 외부 동적 코드다. opt-in 이 곧 동의다.
+- **GC 없는 클래스 (D4, 아니오).** GC 헤더 제거는 GIL 빌드에서 객체당 16 B 를 줄이지만 3.15t 에서는 0 B 다(실측).
+ 유일한 대상이 3.15t 이므로 하지 않고, 그래서 상속 금지(final)도 두지 않는다.
+- **타입 확정 컨테이너.** 압축 배열은 `array.array` 나 별도 opt-in 타입으로 안내한다. `list` 의 표현은 바꾸지
+ 않는다(동일성 위험이 가장 크다).
+- **기대치.** 타입을 확정한 데이터는 Rust 의 약 1~2 배, `dict`·`str`·일반 클래스 위주의 코드는 지금 수준(3~10 배)
+ 이 크게 줄지 않는다. 이 문단은 추정이고, 벤치마크에 실점유 메모리 측정을 더해 기준선으로 박는다.
+
+#### 다. 인터프리터 최소 상주
+
+인터프리터는 갱신 가능한 코드(FastTrack), 서드파티 동적 코드, 개발 중 핫 리로드를 맡고, 그 밖에서는 바닥만
+쓴다. 바닥(실점유 GIL 5.7 MB, 3.15t 7.6 MB)은 CPython 런타임이 정하고, 앱이 정하는 것은 import 다(흔한 모듈 다섯
+개에 10~12 MB).
+
+- **PEP 810 `lazy import` (결정 불필요).** 3.15 에 들어갔다. 쓰지 않는 import 다섯 개를 lazy 로 두면 3.15t RSS 가
+ 30.3 MB 에서 17.5 MB 로 줄었다(실측). 컴파일러가 몰래 넣지 않는다(import 시점과 부작용이 바뀐다). pythonx
+ 라이브러리와 생성 코드에서 적극적으로 쓴다.
+- **D6 (예, 앱 배포 빌드만):** `site` 를 끈다(약 1 MB). pypackpack 이 `sys.path` 를 정하므로 할 일이 없다.
+ `.pth` 처리와 `quit`/`exit` 이 사라지는 점을 문서화하고, 개발 모드는 켜 둔다.
+- **D7 (예):** free-threaded 빌드는 해제한 메모리를 OS 에 돌려주지 않았다(객체 100 만 개 해제 뒤 점유 그대로,
+ 실측). 우리가 직접 빌드하는 FT CPython 에 플랫폼 메모리 압박(Android `onTrimMemory`, iOS 메모리 경고) 때
+ mimalloc 정리를 부르는 최소 패치를 넣고 upstream 에도 제안한다.
+- **D8 (지금은 아니오):** 클래스·함수 정의를 C 초기화 코드로 바꿔 인터프리터 의존을 줄이는 설계는 규모와 위험이
+ 크고, 같은 문제를 lazy import 가 더 싸게 줄인다.
+- 효과가 작아 하지 않는 것: `-OO`(0.35~0.8 MB, `assert` 도 사라짐), deopt 원본 지연 로딩(함수 1000 개당 약
+ 0.4 MB), 동결 모듈 끄기(오히려 4.4 MB 증가).
+
### 4.4 Kotlin 경계의 직접 호출
이 플랫폼에서만 가능한 이점이다.
@@ -218,43 +402,183 @@ Gradle 태스크는 입력·출력을 정확히 선언해 up-to-date 판정과
**선행 조건:** 스텁 생성기가 픽스처 대역이 아닌 실제 `pythonx-map.toml` 을 읽고, 생성된 스텁이
IDE·타입 체커가 찾는 자리(pythonx-compose 배포본)에 설치돼야 한다. 2026-10 현재 미완료.
-## 5. 등급 2 — 별도 언어 없는 GPU
+## 5. 등급 2, 별도 언어 없는 커널
+
+> **결정 (2026-10-04, 메인테이너):** GPU 커널과 CPU SIMD 는 탐색이 아니라 **약속**이다 (INTENT §1.5).
+> 아래 백엔드 결정의 근거는 [`typedpython-kernel-backend.md`](../investigations/typedpython-kernel-backend.md)
+> 와 [`typedpython-kernel-ir.md`](../investigations/typedpython-kernel-ir.md) 에 있다. 두 조사는 실제 기기
+> 성능을 재지 않았다. 아래 "측정" 표기가 붙은 항목은 첫 조각에서 확인한다.
### 5.1 하드 제약: AOT
**iOS 는 런타임 코드 생성을 금지한다.** torchnative `docs/design/DESIGN.md` 는 이 이유로 Triton 을
기각했다. Android 에도 인앱 GPU 컴파일러 툴체인이 없고, autotuning 워밍업은 짧은 세션에서 상각되지
-않는다. 따라서 **GPU 커널은 빌드 타임에 컴파일된다.** JIT 은 데스크톱 개발 편의로만 허용한다.
+않는다. 따라서 **커널은 빌드 타임에 컴파일된다.** JIT 은 데스크톱 개발 편의로만 허용한다. 컴파일러는
+빌드 도구에만 들어가고 앱에는 컴파일된 커널만 실린다. 그래서 컴파일러 쪽 의존성의 무게는 앱 크기가
+아니라 툴체인의 문제다.
-### 5.2 커널 모델
+### 5.2 커널 모델: 타일 우선, SIMT 는 폴백
-Triton · NVIDIA Warp 처럼 파이썬 함수에 `@kernel` 을 붙인다.
+파이썬 함수에 `@kernel` 을 붙인다.
-- **처음에는 SIMT** (스레드 인덱스 기반). 컴퓨트 셰이더에 그대로 대응된다.
-- 타일 추상화(Triton · cuTile 식)는 그다음.
-- 커널 안: 고정 dtype 스칼라, 버퍼·텐서 뷰, 산술, 제어 흐름, 공유 메모리, 배리어.
+- **기본은 타일 수준이다.** 타일 복사, 타일 행렬곱, 단계별 파이프라이닝, 공유 메모리·레지스터 조각
+ 할당을 1급 연산으로 둔다 (TileLang 의 `T.copy` · `T.gemm` · `T.Pipelined` 와 같은 층위). AI 커널의
+ 성능은 행렬 유닛(텐서 코어, Apple simdgroup_matrix · Metal 4 cooperative tensor, Vulkan
+ `VK_KHR_cooperative_matrix`)과 메모리 계층을 쓰는 데서 나오고, 그것을 표현하는 단위가 타일이다.
+- **SIMT(스레드 인덱스 기반)는 폴백이자 저수준 탈출구다.** 행렬 유닛이 없는 기기가 실제로 많다
+ (Mali 대부분, 구형 Adreno). 타일 연산은 행렬 유닛이 없으면 SIMT 코드로 낮춰진다.
+- 커널 안: 고정 dtype 스칼라(f32, f16, bf16, int8 포함), 버퍼·텐서 뷰, 산술, 제어 흐름, 배리어.
객체 생성·힙 할당·예외·파이썬 호출은 없다. 위반은 등급 1 과 같은 게이트에서 컴파일 에러.
-- 커널 함수도 유효한 파이썬이다. CPU 에서 참조 구현으로 실행해 수치를 대조할 수 있다.
-
-### 5.3 컴파일 경로
-
- @kernel 서브셋 → 자체 소형 IR → SPIR-V ─┬─ Vulkan (Android, 데스크톱)
- ├─ SPIRV-Cross → MSL (iOS, macOS)
- └─ naga → WGSL (웹)
- └──────────── PTX (데스크톱 CUDA, 별도)
-
-한 소스에서 모바일·데스크톱·웹 타깃이 나온다.
+- **커널 함수도 유효한 파이썬이다.** 타일 연산은 CPU 참조 구현을 가진 일반 파이썬 함수이고, 커널은
+ CPython 에서 그대로 실행되어 수치를 대조할 수 있다. TileLang · Triton · JAX Pallas 와 갈리는 지점이다
+ (Pallas 는 CPU 에서 `interpret=True` 로만, JAX 위에서 돈다).
+- **참조 구현은 numpy 에 의존하지 않는다 (결정 2026-10-04).** 타일 연산의 CPU 참조는 순수 파이썬(표준
+ 라이브러리의 `array`, `memoryview`, `struct`)으로 쓴다. 참조의 목적은 정답이지 속도가 아니다. 커널을
+ JAX · torch · numpy 없이 CPython 만으로 실행할 수 있다는 것이 Pallas 대비 차별점이고, 첫 조각의
+ 시험으로 박는다 (§6 의 4).
+
+### 5.3 CPU SIMD: `@compiled` 의 벡터화
+
+Mojo 의 절반은 CPU SIMD 다. 등급 1 `@compiled` 의 C 백엔드(§4.3)가 이 자리를 맡는다.
+
+- 첫 단계는 **C 벡터 확장**(`__attribute__((vector_size))`)으로 NEON · AVX 를 내는 것이다.
+- 행렬곱 같은 핫 커널은 손으로 쓴 NEON 경로를 둔다.
+- 벡터 폭과 dtype 을 표현하는 타입은 등급 2 의 타일·dtype 표기와 같은 어휘를 쓴다.
+
+이 절은 조사가 얕다(investigations 의 [I] 표기). 첫 GPU 조각 다음에 별도로 설계한다.
+
+### 5.4 IR: 우리 것, MLIR 에 사상되게
+
+프론트엔드(유효한 파이썬 커널을 읽는 부분)와 IR 은 우리가 만든다. "CPython 에서 그대로 실행" 원칙을
+지키는 길이 이것뿐이다. 다만 IR 은 **MLIR 의 gpu · vector · linalg 구조에 그대로 사상되게** 설계한다.
+나중에 MLIR 로 옮기거나 둘 사이를 오가는 비용을 낮추기 위해서다.
+
+- **MLIR 파이썬 휠은 선택적 보조 도구다.** SPIR-V · PTX 직렬화, 검증, 교차 대조에 쓴다. 공식 휠은
+ LLVM 의 nightly(eudsl, alpha)이고 PyPI 에 없으며, 3.14t 휠은 있으나 **3.15t 휠은 아직 없다**(조사
+ 시점 2026-10-04). 그래서 3.14t 별도 프로세스에서 돌리고, 날짜를 고정한 휠을 잠금 파일에 둔다. 빠지면
+ SPIR-V 를 직접 내는 쪽으로 돌아간다.
+- MLIR 의 타일링 · 벡터 · transform 패스를 쓸지는 **첫 조각이 동작한 뒤** 정한다.
+- 채택하지 않는 것(참고 자료로만): TileLang/TVM (Vulkan 백엔드 없음, 커널이 일반 파이썬으로 실행되지
+ 않음), CubeCL (진입점이 Rust 매크로, JIT 전용), IREE (모델 단위 입력과 자체 출력 형식, SPIR-V 코드
+ 생성만 떼어 쓸 공식 경로 없음), xDSL (spirv 방언·SPIR-V/MSL/PTX 출력기 없음).
+- IREE 기각은 **커널 컴파일러로서의** 평가다. **모델 통째 배포** 관점에서는 JAX 가 StableHLO 를 내고
+ IREE 가 그것을 모바일로 AOT 컴파일하는 길이 경쟁자로 남는다 (§5.10).
+
+### 5.5 출력기: 대상별로 교체 가능
+
+ @kernel (유효한 파이썬) → 프론트엔드 → 우리 IR ─┬─ SPIR-V ──── Vulkan (Android, 데스크톱)
+ ├─ MSL 직접 ─ Metal (iOS, macOS)
+ ├─ WGSL ───── WebGPU (웹)
+ ├─ PTX ────── CUDA (데스크톱)
+ └─ C + 벡터 확장 ─ CPU (참조 · 폴백)
+
+- **Apple 은 MSL 을 직접 낸다.** SPIRV-Cross 를 거치면 행렬 유닛이 8x8 float simdgroup 까지만 가고,
+ Metal 4 텐서 연산(MSL 4 전용)에는 닿지 않는다.
+- **MSL 과 WGSL 출력기는 어느 IR 을 택해도 우리가 쓴다.** MLIR 상류에 두 대상이 없다.
+- SPIR-V 와 PTX 는 직접 내거나 MLIR 휠로 직렬화한다 (§5.4).
+- **커널 번들은 IR 버전과 출력기 버전을 기록한다.** 런타임은 맞지 않는 번들을 거부한다. `jax.export` 가
+ 호환 창을 명시하는 것과 같은 자리다.
+
+### 5.6 런타임과 착지점: torchnative
+
+**별도 런타임을 만들지 않는다.** 커널은 torchnative 의 장치 계층에서 실행된다.
+
+- **Vulkan:** torchnative `torch_c/src/vulkan.rs` 는 이미 SPIR-V 를 싣고(`include_bytes!`), 파이프라인을
+ 만들고, push constant 로 shape 을 넘겨 실행한다. 첫 조각은 glslc 로 만든 셰이더 자리에 우리
+ 컴파일러가 만든 SPIR-V 를 넣는 일이다.
+- **Metal:** candle-metal 경로. 우리 MSL 커널을 거기에 붙이는 방식은 torchnative 와 정한다 (§8).
+- **wgpu 는 웹 전용이다.** torchnative 가 기기에서 두 방식을 나란히 재 보았다: ash 는 크레이트 4 개
+ 719 KB, wgpu 는 60 개 6.77 MB 이고 torchnative 는 ash 를 권고했다(`docs/devices/VULKAN.md` §5.3).
+ wgpu 를 네이티브 런타임으로 쓰면 행렬 유닛이 실험 기능 뒤에 갇힌다.
+- **착지:** 컴파일된 커널은 `torch.library` 커스텀 op, 또는 torchnative 가 빌드 타임으로 옮긴 HF
+ `kernels` 번들 리졸버의 항목으로 등록된다. 그러면 `transformers` 코드가 수정 없이 그 커널을 쓴다.
+ `kernels` 규격에 `vulkan` 슬롯이 없는 구멍을 이 커널이 메운다.
+- **수치 기준:** torchnative CPU 경로(bf16/fp16 에서 비트 일치하는 `flash.rs` 등)가 정답지다. 커널의
+ 일반 파이썬 실행, torchnative CPU, GPU 출력의 세 쪽을 대조한다.
+- **자동 융합은 뒤로 미룬다.** torchnative 의 그래프 캡처로 `torch.compile` 을 대신하는 길(캡처한
+ 그래프의 융합 구간을 커널 컴파일러로 넘김)은 가치가 크지만, 그 캡처는 동적 shape · in-place 변경 ·
+ 제어 흐름을 거부하고 LLM 의 KV 캐시 갱신이 in-place 라 정면으로 걸린다. 그전까지는 리졸버를 통한
+ 레이어 교체로 착지한다 (§5.9).
+
+### 5.7 빌드 타임 오토튜닝
+
+같은 커널도 타일 크기, 파이프라인 단계 수, 워크그룹 크기에 따라 몇 배씩 차이가 난다. Triton ·
+TileLang · MLC-LLM 은 이것을 자동 탐색한다. 모바일에서는 실행 중에 탐색할 수 없으므로(§5.1) 탐색을
+**빌드 타임으로 옮긴다.**
+
+- 커널마다 조정 가능한 매개변수(타일 크기, 단계 수, 워크그룹 크기, 행렬 유닛 사용 여부)를 선언한다.
+- **GPU 계열별로 실기기에서 탐색한다:** Adreno 세대, Mali, Apple GPU 세대, 데스크톱. 실기기 팜이나
+ CI 의 기기 러너가 필요하다.
+- 결과는 **설정표로 번들에 실리고**, 런타임은 기기를 식별해 표에서 고른다. 표에 없는 기기는 보수적인
+ 기본 설정과 SIMT 폴백을 쓴다.
+- 탐색 결과는 커널 소스, 컴파일러 버전, 드라이버 계열을 키로 캐시한다. 소스가 바뀌지 않으면 다시
+ 탐색하지 않는다.
+
+탐색 없이는 첫 커널이 손으로 튜닝한 코드보다 느릴 가능성이 크다. 그래서 오토튜닝은 단계 5(타일
+행렬곱)의 일부다.
+
+### 5.8 성능 기준선
+
+이 설계는 성능 상한을 막는 장애물을 치운 것이지, 속도를 보장하지 않는다. 경쟁력은 측정으로만
+주장한다.
+
+- **비교 대상:** 같은 기기에서 llama.cpp(Vulkan, Metal 백엔드)와 Apple MLX 의 softmax, matmul,
+ attention. 모바일에서 실제 경쟁 상대는 Mojo 가 아니라 이들과 MLC-LLM, ExecuTorch 다. 데스크톱 CUDA
+ 에서는 Triton · TileLang 이 기준이다.
+- **첫 조각부터** 측정 테스트로 박는다(AGENTS.md §14). 기록하는 값은 커널 시간과 기준 대비 비율이고,
+ 수치 오차는 §5.6 의 세 쪽 대조로 따로 본다.
+- **목표(안):** 모바일에서 llama.cpp · MLX 의 손 튜닝 커널에 근접하는 것. 구체적인 비율은 첫 측정 뒤
+ 메인테이너가 정한다.
+- 측정은 AGENTS.md §8 을 따른다: 단독 실행, 부하 확인, 필터 없이.
+
+### 5.9 자동 융합 (torchnative #68)
+
+LLM 디코딩은 작은 연산이 많아 연산 사이의 디스패치와 메모리 왕복이 커널만큼 시간을 먹는다.
+`torch.compile` 이득의 대부분이 융합이다. §5.6 이 미룬 그래프 캡처 기반 융합(동적 shape, in-place KV
+갱신 문제)은 torchnative #68 에서 다룬다. 그전까지 모델 전체 수준의 속도 경쟁력은 제한된다.
+
+### 5.10 JAX 와의 관계: 어디서 이기고, 무엇을 torch 에 맡기는가
+
+> **결정 (2026-10-04, 메인테이너):** 설계는 JAX 대비 장점이 분명해야 한다. **torch 가 JAX 만큼
+> 성능을 내는 부분만 torch 에 맡기고**, 그렇지 않은 부분은 목록으로 두어 따로 판단한다. 근거는
+> [`typedpython-vs-jax.md`](../investigations/typedpython-vs-jax.md).
+
+**포지셔닝.** "JAX 보다 낫다" 가 아니라 **"JAX 가 가지 않는 곳에서 이긴다"** 로 쓴다. 근거가 이 범위에서만
+선다.
+
+| 우리가 이기는 곳 | 근거 (조사 2026-10-04, 웹 요약 경유) |
+|---|---|
+| 모바일 GPU(Vulkan, Metal)가 1급 대상 | Pallas 의 GPU 하강은 Mosaic GPU(Hopper 이상)이고 Triton 백엔드는 deprecated, Vulkan/Metal 백엔드 없음. jax-metal 은 macOS 15.6 에서 실패하는 이슈가 열려 있다 |
+| 파이썬 의미 보존 | `jit` 은 트레이싱이라 부작용이 트레이스 때만 실행되고, 값 의존 분기는 에러, 배열은 불변, 범위 밖 인덱스는 clamp 된다. Pallas 커널은 JAX 없이 실행되지 않는다 |
+| 빌드 타임 타입 게이트 | jaxtyping 은 실행(트레이스) 시점 검사이고 정적 검사기에는 `Array` 로만 보인다. 단, 우리도 shape 오류는 정적으로 잡지 못한다 |
+| 생태계 착지 | transformers v5 가 TF 와 Flax 를 제거하고 PyTorch 만 남겼다. torch op 착지(§5.6)가 그 생태계에 붙는다 |
+
+**torch 에 맡기는 조건.** JAX 의 강점 중 우리가 직접 만들지 않는 것은 torch 가 맡는다. 다만 **torch 가
+JAX 와 같은 수준의 성능을 낸다는 근거가 있을 때만** 맡긴다고 확정하고, 근거가 없으면 아래 목록에 둔다.
+2026-10-04 현재 어느 항목도 측정 근거가 없으므로 **전부 "판단 대기"** 다.
+
+| 기능 | JAX | torch 대응 | 판단 |
+|---|---|---|---|
+| 전체 프로그램 융합 | XLA | 데스크톱 `torch.compile`(Inductor). 모바일은 불가, torchnative 그래프 캡처로 대체 예정(#68, §5.9) | 판단 대기. **모바일에서는 알려진 격차** |
+| 자동 미분 (`grad`) | 함수 변환, `jit` 과 합성 | `torch.autograd`, `torch.func.grad` | 판단 대기 (학습 성능 비교 없음. 온디바이스 목표상 추론이 우선) |
+| 벡터화 (`vmap`) | `jit` 안에서 융합까지 | `torch.func.vmap` | 판단 대기 (`vmap` + 컴파일 조합의 성능 비교 없음) |
+| 변환의 합성 (`jit`·`grad`·`vmap`) | 1급 | `torch.func` + `torch.compile` | 판단 대기 |
+| 샤딩, 다중 기기 | `shard_map`, pjit | DTensor, FSDP | 판단 대기 (온디바이스 목표 밖일 가능성) |
+| TPU · Hopper/Blackwell 커널 성숙도 | Mosaic TPU/GPU | torch_xla, Triton | 판단 대기 (데스크톱 CUDA 는 §5.8 의 Triton·TileLang 기준) |
+| 내보낸 산출물의 호환 보장 | `jax.export` 후방 6 개월, 전방 3 주 | `torch.export`, ExecuTorch | 판단 대기. 우리 커널 번들은 IR 버전과 출력기 버전을 기록한다(§5.5) |
-### 5.4 착지점 — torchnative
+각 항목은 측정(같은 작업을 JAX 와 torch 로 실행한 시간)이나 메인테이너 판단으로 "torch 에 맡김", "우리가
+만든다", "범위 밖" 중 하나로 닫는다 (§8 의 8).
-컴파일된 커널을 **`torch.library` 커스텀 op 로 등록**하고 mps / vulkan 구현을 붙인다. 그러면
-`transformers` 코드가 수정 없이 그 커널을 쓴다.
+**불분명한 것.**
-torchnative 의 설계는 이미 이 자리를 비워두고 있다. 융합 어텐션 같은 커널 계층을 "prefill 문제의
-예정된 해법" 으로 지목했고, 커널 소스 후보로 손으로 쓴 Metal/NEON, CubeCL, AOT Triton 을 올려
-두었다. TypedPython 등급 2 는 **같은 자리를 파이썬으로 채우는 네 번째 후보**다.
+- **JAX + IREE 모델 배포.** `jax.export` 의 StableHLO 를 IREE 가 AOT 컴파일해 모바일에 싣는 길은 이론상 있다.
+ IREE 는 §5.4 에서 *커널 컴파일러로서* 기각했지만(코드 생성만 떼어 쓸 공식 경로 없음), *모델 통째 배포*
+ 관점에서는 경쟁자다. 실제로 iOS/Android GPU 에서 도는 사례는 찾지 못했다.
+- **성능.** 양쪽 모두 모바일 측정이 없다. "JAX 보다 빠르다" 고 주장할 근거는 지금 없다 (§5.8).
+- **데스크톱 CUDA(PTX) 와 Mosaic GPU.** 비교 근거가 없다.
-### 5.5 선례 — Taichi
+### 5.11 선례, Taichi
파이썬 문법으로 Vulkan / Metal 커널을 AOT 컴파일해 모바일에 실었던 가장 가까운 선례다. 지금은
활동이 크게 줄었다. 기술보다는 **독자 런타임과 독자 생태계로 고립된 것**이 원인으로 보인다.
@@ -268,18 +592,22 @@ TypedPython 은 그래서 자체 텐서 런타임을 만들지 않고 **torch op
| 1b | 빌드 기본 단계로 연결 | PythonMultiplatform Gradle 플러그인이 사용자 파이썬 소스에 게이트를 항상 돌리고, 에러면 빌드 실패 | 1 |
| 2 | typed IR → C 백엔드 (#41) | 프론트엔드·검증기·C 생성·런타임, 데스크톱에서 끝까지 | 없음 (pypackpack 슬롯은 3 의 의존) |
| 3 | Kotlin 직접 호출 | 스텁 타입 기반 C ABI 호출 생성 | 스텁 배선 완료 |
-| 4 | 커널 하나 | 융합 softmax 를 Vulkan · Metal 양쪽에서 upstream 과 수치 일치 | torchnative 착지 승인 |
-| 5 | 커널 컴파일러 일반화 | SIMT 서브셋 전체, 이후 타일 | 4 |
+| 4 | 첫 커널 조각 | f16 원소별 연산 또는 softmax 하나를 SPIR-V 로 컴파일해 torchnative `vulkan.rs` 로 실행, 기기에서 일반 파이썬 실행 · torchnative CPU 와 수치 일치, llama.cpp 대비 시간 기록 (§5.8), 커널이 JAX · torch · numpy 없이 CPython 만으로 실행됨 (§5.2) (추정 2~4 주) | torchnative 의 임의 SPIR-V 실행 진입점 |
+| 4b | Metal 조각 | 같은 커널을 MSL 로 직접 내 iOS · macOS 에서 수치 일치 | 4, candle-metal 연결 방식 (§8) |
+| 5 | 타일 행렬곱 | 행렬 유닛(cooperative matrix, simdgroup_matrix)을 쓰는 타일 matmul, 없는 기기는 SIMT 폴백, 빌드 타임 오토튜닝 (§5.7) (추정 2~4 개월, MLIR 패스 사용 시 1~3 개월) | 4, 4b |
+| 6 | 착지 | 커널을 `torch.library` op · `kernels` 번들 항목으로 등록, `transformers` 무수정 사용 | 5 |
+| 7 | CPU SIMD | `@compiled` C 백엔드의 벡터 확장 · NEON 경로 (§5.3) | 2 |
-각 단계는 AGENTS.md §5·§14 의 TDD 와 측정 테스트 규정을 따른다. 특히 3 은 경계 비용 감소를, 4 는
-upstream 대비 수치 오차를 기준선으로 박아 둔다.
+각 단계는 AGENTS.md §5·§14 의 TDD 와 측정 테스트 규정을 따른다. 특히 3 은 경계 비용 감소를, 4 와 5 는
+upstream 대비 수치 오차와 커널 시간을 기준선으로 박아 둔다. 4 와 5 의 기간은 조사의 추정이지 측정이
+아니다.
### 6.1 단계 1 의 현재 상태 (2026-10-03, #16)
- **위치:** `python-multiplatform-ksp/src/main/python/typedpython/`, 테스트는
`python-multiplatform-ksp/src/test/python/`. 사용자 지시("ksp랑 통합")에 따라 KSP 모듈 안에 둔다.
KSP 프로세서는 Kotlin 심볼만 보고 파이썬 파일 변경으로는 다시 돌지 않으므로, **실행**은 1b 에서
- Gradle 태스크가 맡는다 — KSP 모듈은 소스와 배포(jar 리소스)의 자리다.
+ Gradle 태스크가 맡는다, KSP 모듈은 소스와 배포(jar 리소스)의 자리다.
- **구현된 규칙:** SPEC N-7. Pyrefly 의 `Any` 유출 kind 6개를 에러로 올리고, Pyrefly 의
per-expression 타입 리포트(`--report-pysa`)로 사용자 코드 안의 `Any` 식을 찾는다(`any-flow`).
`cast()` 인자, 버려지는 식 문장의 값, 함수 시그니처 자체는 제외한다.
@@ -288,12 +616,12 @@ upstream 대비 수치 오차를 기준선으로 박아 둔다.
`; x` 를 붙인 사본을 만들어(줄 번호 불변) 한 번 더 돌리고, 그 위치의 타입으로 판정한다. 두 번
이상 대입되는 이름이나 컨테이너 디스플레이가 있는 파일에서만 돈다. `for` 대상과 컴프리헨션
변수는 아직 탐침하지 않는다.
-- **측정:** 1800줄 합성 모듈에서 plain `pyrefly check` 160–235 ms, 게이트 1.3–1.9 s — 두 번 잰 비율이
- 모두 약 8배다. 탐침 패스를 별도 실행으로 붙이면 약 15배(232 ms → 3.6 s, load 41)였다 — 합성 모듈의
+- **측정:** 1800줄 합성 모듈에서 plain `pyrefly check` 160–235 ms, 게이트 1.3–1.9 s, 두 번 잰 비율이
+ 모두 약 8배다. 탐침 패스를 별도 실행으로 붙이면 약 15배(232 ms → 3.6 s, load 41)였다, 합성 모듈의
`total += ...` 가 탐침 대상이라 두 번째 실행이 돌았기 때문이다. 지금은 탐침 대상이 있으면
**탐침 사본(과 같은 검사의 나머지 파일)에 대해 Pyrefly 를 한 번만** 돌리고, 에러·`any-flow`·
타입을 모두 그 한 번에서 읽는다(에러 열은 삽입 길이만큼 왼쪽으로 되돌린다). 같은 합성 모듈에서
- 825 ms → 6.2 s, 약 7.6배(load 30 — 유효한 측정이 아니다. load 4 초과). 단독 측정이 아니다(두 번째는 load average 18.6). 절대값보다 비율을 기준으로 본다. 차이의
+ 825 ms → 6.2 s, 약 7.6배(load 30, 유효한 측정이 아니다. load 4 초과). 단독 측정이 아니다(두 번째는 load average 18.6). 절대값보다 비율을 기준으로 본다. 차이의
대부분은 타입 리포트가 typeshed 사본까지 디스크에 쓰는 비용으로 보인다(추정, 미분해). 1b 에서
빌드마다 돌리기 전에 줄일 대상이다.
- **고정:** Pyrefly `==1.3.2`. 타입 리포트는 Pyrefly 가 버전을 매기는 포맷이지 공개 API 가 아니다.
@@ -306,29 +634,47 @@ upstream 대비 수치 오차를 기준선으로 박아 둔다.
그 목록이 2단계 IR 의 요구사항이 된다.
- **등급 2 컴파일러의 규모.** 가장 큰 작업이다. 커널 하나를 끝까지 하는 것(§6 의 4)을 첫 검증
단위로 삼아 범위를 제한한다.
+- **행렬 유닛의 실제 지원 범위.** Adreno 는 신형 칩에서 확장 두 개가 필요하고 Mali 는 대부분 없다.
+ SIMT 폴백이 기본 경로가 되는 기기가 많을 수 있다. 기기별 측정 전에는 성능을 약속하지 않는다.
+- **오토튜닝에 필요한 실기기.** GPU 계열별 탐색(§5.7)은 기기 팜이나 기기 러너 없이는 못 한다.
+ 확보 전에는 계열 하나(개발 기기)의 설정표로 시작한다.
+- **속도 경쟁력은 아직 주장할 수 없다.** 설계는 상한만 열었다. §5.8 의 기준선 측정 전에는 성능을
+ 약속하지 않고, 자동 융합(§5.9)이 없으면 모델 전체 속도는 제한된다.
+- **MLIR 휠의 변동.** nightly · alpha 이고 3.15t 휠이 없다. 선택적 보조로만 두고 날짜 고정 휠을 쓴다.
+ 빠져도 SPIR-V 를 직접 내는 경로가 남아야 한다 (§5.4).
- **기본 검사의 오탐.** 검사가 기본값이므로, 오탐 하나가 모든 사용자의 빌드를 막는다. 1b 를 켜기 전에
이 저장소의 실제 사용자 측 파이썬 코드(`sample/`, pythonx-compose 의 소비 예시)에 게이트를 돌려
에러 수와 그 원인을 기준선으로 박아 둔다.
- **서드파티 스텁 부재.** 스텁이 없는 의존성을 많이 쓰면 `Any` 출처가 늘어 에러가 잦아진다. 이 경우를
에러로 둘지, 경계 한 곳의 명시적 캐스트로 해소하게 할지가 채택을 가른다.
+- **메모리 기대치.** 타입을 확정한 데이터만 Rust 근처로 간다. `dict`·`str`·일반 클래스 위주의 코드와 인터프리터
+ 바닥은 크게 줄지 않는다 (§4.3.5).
- **파이썬 의미론과의 괴리.** 금지 목록이 실제 코드에서 얼마나 흔한지 측정해야 한다. 너무 흔하면
`@compiled` 가 채택되지 않는다.
## 8. 미결 사항 (사용자 결정)
-1. ~~`compiled` 의 표기~~ — **결정됨 (2026-10-03, 메인테이너):** `compiled` 는 **내장 이름**이다. 패키지
+1. ~~`compiled` 의 표기~~, **결정됨 (2026-10-03, 메인테이너):** `compiled` 는 **내장 이름**이다. 패키지
아래에 두지 않고(`@typedpython.compiled` 아님) import 없이 `@compiled` 로 쓴다. 런타임은 임베디드
인터프리터 시작 시 `builtins.compiled` 를 설치하고(#42), 일반 CPython 에서는 `typedpython` wheel 의
`typedpython_builtins.pth` 가 `builtins` 에 없을 때만 항등 함수를 설치한다(런타임이 넣은 것을 덮어쓰지
- 않는다). 타입 검사기에는 `__builtins__.pyi` 로 알린다 — Pyrefly 1.3.2 는 검색 경로의 그 파일을 추가
+ 않는다). 타입 검사기에는 `__builtins__.pyi` 로 알린다, Pyrefly 1.3.2 는 검색 경로의 그 파일을 추가
내장으로 읽는다(실측). 모듈 단위 표기는 `# typedpython: compiled` 주석을 유지한다: `# pyright: strict` 와 같은
도구 pragma 형태이고, 실행 시 이름 조회가 필요 없어 일반 CPython 에서 부작용이 없으며, 데코레이터 없이
모듈 전체를 지정할 수 있다.
-2. ~~고정폭 정수의 오버플로 의미~~ — **결정됨:** `int` 는 i64 로 컴파일하고 오버플로 시 인터프리터 재실행으로 큰 정수로 승격(의미 보존).
-3. ~~pypackpack Cython 슬롯 인터페이스 소유~~ — **결정됨:** pypackpack 이 정한다. TypedPython 은 생성한 C 소스, 빌드 플래그, C 를 낸 모듈 목록을 넘긴다. 확장이 원본 소스를 품으므로 별도 `.py` 폴백은 없다(Cython 폐기 후 갱신).
-4. GPU 착지를 torchnative 커스텀 op 로 할 것인가 (다른 레포 변경).
-5. ~~TypedPython 을 별도 레포로 분리할 것인가~~ — **결정됨 (2026-10-02):** PythonMultiplatform 의
+2. ~~고정폭 정수의 오버플로 의미~~, **결정됨:** `int` 는 i64 로 컴파일하고 오버플로 시 인터프리터 재실행으로 큰 정수로 승격(의미 보존).
+3. ~~pypackpack Cython 슬롯 인터페이스 소유~~, **결정됨:** pypackpack 이 정한다. TypedPython 은 생성한 C 소스, 빌드 플래그, C 를 낸 모듈 목록을 넘긴다. 확장이 원본 소스를 품으므로 별도 `.py` 폴백은 없다(Cython 폐기 후 갱신).
+4. ~~GPU 착지를 torchnative 커스텀 op 로 할 것인가~~, **결정됨 (2026-10-04, 메인테이너):** 커널은
+ torchnative 장치 계층에서 실행되고 torch op · `kernels` 번들로 착지한다 (§5.6). torchnative 와 정할
+ 것은 남는다: Metal 커널을 candle-metal 에 붙이는 방식, 등록 경로를 ATen 디스패처와 `kernels`
+ 리졸버 중 무엇으로 정식화할지, Vulkan f16 · 행렬 유닛을 장치 계층에 넣을지.
+5. ~~TypedPython 을 별도 레포로 분리할 것인가~~, **결정됨 (2026-10-02):** PythonMultiplatform 의
기본 동작. 검사는 기본, 컴파일은 opt-in.
-6. 기본 검사에서 빠져나가는 방법을 둘 것인가 — 예: 동적 기능이 꼭 필요한 모듈 하나를 `checked` 에서
+6. 기본 검사에서 빠져나가는 방법을 둘 것인가, 예: 동적 기능이 꼭 필요한 모듈 하나를 `checked` 에서
빼는 표기. 두지 않으면 보장이 강해지고, 두면 이식이 쉬워진다.
7. §3.1 금지 목록을 `checked` 에서 경고로 둘 것인가, 에러로 둘 것인가.
+8. **§5.10 의 "판단 대기" 목록.** 융합, 자동 미분, `vmap`, 변환 합성, 샤딩, TPU · 최신 GPU 성숙도, 내보낸
+ 산출물의 호환 보장 각각을 "torch 에 맡김" (torch 가 JAX 만큼 성능을 낸다는 근거가 있을 때만),
+ "우리가 만든다", "범위 밖" 중 하나로 정한다.
+9. ~~메모리와 인터프리터 바닥 (D1~D8)~~, **결정됨 (2026-10-05, 메인테이너):** D1 예, D2 예(조건부), D3 예(조건부),
+ D4 아니오, D5 결함 수정(#193), D6 예(앱 배포 빌드만), D7 예, D8 지금은 아니오. 내용은 §4.3.5.
diff --git a/docs/design/upcall.md b/docs/design/upcall.md
index aaf9a4076..761fd17c6 100644
--- a/docs/design/upcall.md
+++ b/docs/design/upcall.md
@@ -1,4 +1,4 @@
-# Upcalls — Python → Kotlin
+# Upcalls, Python → Kotlin
How Python code imports Kotlin packages, constructs Kotlin classes, calls their functions (including
`suspend` ones) and reads their properties. The opposite direction is
@@ -60,8 +60,8 @@ The equivalent here:
| Once per name | Python passes a name (`"demo.Counter.increment"`); **Kotlin resolves it** in the table and returns a `CallableHandle` |
| Every call after that | The handle crosses, never the string |
-Passing a string per call costs `PyUnicode` → UTF-8 (an allocation) + the boundary + hashing + comparison —
-hundreds of ns — whatever the table looks like. `UpcallTable.resolve` returns a `CallableHandle` whose raw
+Passing a string per call costs `PyUnicode` → UTF-8 (an allocation) + the boundary + hashing + comparison,
+hundreds of ns, whatever the table looks like. `UpcallTable.resolve` returns a `CallableHandle` whose raw
value packs the table **epoch** (high 32 bits) and the index (low 32), so a handle cached across a table
reinstall is rejected rather than calling a different function
(`python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ExposedCallable.kt`,
@@ -105,15 +105,17 @@ Every `public` declaration is registered; `@PythonInternal` opts a class or memb
(`@PythonAPI`) was rejected (AGENTS.md §12.7; [`binding-policy.md`](binding-policy.md)). This is the
premise the tree-shaking decision (§2.6) rests on and must not be re-litigated there.
-### 1.6 Kotlin names only
+### 1.6 Kotlin namespaces, Kotlin names and their Pythonic aliases
-A Kotlin package is importable under its own fully-qualified name and nothing else. Functions on a
+A Kotlin package is importable under its own fully-qualified name and nothing else: no namespace is
+renamed and no synthetic `pythonx` module exists (INTENT §2.2–2.3, AGENTS.md §12.1–12.2). Functions on a
Kotlin-named module take keyword arguments by **Kotlin parameter names**, honour Kotlin defaults for
omitted parameters, and expose their signature through `inspect.signature` and
`python_multiplatform.describe` (SPEC U-8; contract in the KDoc of
-`python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt`). Nothing is
-renamed: no snake_case, no synthetic `pythonx` module (INTENT §2.2–2.3, AGENTS.md §12.1–12.2). Making the
-surface Pythonic is pythonx-compose's job, built on this metadata. See §4.3.
+`python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt`). Beside
+every Kotlin name, the declaration, member or keyword is also reachable by its Pythonic (snake_case)
+alias when that alias is unambiguous (SPEC U-12, issue #131). This was removed in `238119b7` on a
+misreading of §12.1 and restored; §12.1 now says it governs namespaces only. See §4.3.
---
@@ -122,7 +124,7 @@ surface Pythonic is pythonx-compose's job, built on this metadata. See §4.3.
### 2.1 Fragments, one per module
The KSP processor (`python-multiplatform-ksp/`) is shipped as an artifact and runs in **every** module that
-exposes Kotlin to Python — including the user's own modules, since exposing the user's classes is the
+exposes Kotlin to Python, including the user's own modules, since exposing the user's classes is the
point. Each module emits one **fragment object** into the well-known package
`python.multiplatform.generated.fragments`:
@@ -130,8 +132,8 @@ point. Each module emits one **fragment object** into the well-known package
// GENERATED by python-multiplatform-ksp. Do not edit.
package python.multiplatform.generated.fragments
-object Fragment_io_github_thisisthepy_ksp_fixtures_library : python.multiplatform.reflection.FunctionTableFragment {
- override val moduleName: String = "io_github_thisisthepy_ksp_fixtures_library"
+object Fragment_io_github_thisisthepy_python_multiplatform_ksp_fixtures_library : python.multiplatform.reflection.FunctionTableFragment {
+ override val moduleName: String = "io_github_thisisthepy_python_multiplatform_ksp_fixtures_library"
override fun entries(): List = listOf(
ExposedCallable(name = "fixture.library.greet", arity = 1, paramTypes = listOf(TypeTag.STRING),
returnType = TypeTag.STRING, paramNames = listOf("name"), callable = { args -> fixture.library.greet(args[0] as String) }),
@@ -145,8 +147,8 @@ object Fragment_io_github_thisisthepy_ksp_fixtures_library : python.multiplatfor
- **Name**: `Fragment_`, where `moduleName` is the KSP option
`python.multiplatform.moduleName` with `.` and `-` replaced by `_` (`Constants.kt`). The Gradle plugin
- derives it from the **Maven group plus the project path** (`:ksp-fixtures:app` with group
- `io.github.thisisthepy` → `io_github_thisisthepy_ksp_fixtures_app`), because fragments from every
+ derives it from the **Maven group plus the project path** (`:python-multiplatform-ksp:fixtures:app` with group
+ `io.github.thisisthepy` → `io_github_thisisthepy_python_multiplatform_ksp_fixtures_app`), because fragments from every
artifact share one package and two libraries both called `:core` would otherwise collide. The name is
not load-bearing for discovery (the whole package is scanned) but prevents collisions.
- **Visibility: `public`.** An `internal` fragment does not work: `internal` is enforced per Kotlin
@@ -189,10 +191,10 @@ object Fragment_io_github_thisisthepy_ksp_fixtures_library : python.multiplatfor
| `@PythonInternal` | no | opt-out |
Asserted against KSP-generated fragments, not hand-written ones:
-`ksp-fixtures/app/src/desktopTest/.../GeneratedDeclarationKindsTest.kt`,
-`GeneratedSuspendTest.kt`, `GeneratedTableTest.kt` (which runs `UpcallTableTest`'s scenarios — constructor
+`python-multiplatform-ksp/fixtures/app/src/desktopTest/.../GeneratedDeclarationKindsTest.kt`,
+`GeneratedSuspendTest.kt`, `GeneratedTableTest.kt` (which runs `UpcallTableTest`'s scenarios, constructor
/ method / getter / setter round trip through `HandleTable`, `@PythonInternal` exclusion, narrow
-`Int`/`Float` widening, `tp_traverse` field detection — against a generated table), and
+`Int`/`Float` widening, `tp_traverse` field detection, against a generated table), and
`python-multiplatform-ksp/src/test/.../SourceRenderingTest.kt`.
### 2.3 Aggregation in the app module
@@ -223,8 +225,8 @@ different fragments is an error**, detected before anything is mutated (SPEC U-1
**Discovery** uses `resolver.getDeclarationsFromPackage("python.multiplatform.generated.fragments")`, in two
KSP rounds:
-1. **Round 1** — generate the app's own `Fragment_`. The new file triggers round 2.
-2. **Round 2** — `getDeclarationsFromPackage` now sees the app's fresh fragment **and** every dependency's
+1. **Round 1**: generate the app's own `Fragment_`. The new file triggers round 2.
+2. **Round 2**: `getDeclarationsFromPackage` now sees the app's fresh fragment **and** every dependency's
fragment, which is on the compilation classpath as compiled classes (JAR) or klib metadata. Generate
`FunctionTable`.
@@ -235,17 +237,17 @@ expensive because it scans the whole classpath; the package query is cheap. (A m
`getDeclarationsFromPackage` is `@KspExperimental`. It is the documented cross-module API, nothing
non-experimental does the job, and it has been stable since KSP 1.x. Discovery sits behind the
-`FragmentDiscovery` interface (`FragmentDiscovery.kt`) so a fallback — each module writing a metadata file
-under `META-INF/`, read by the aggregator — can replace it without changing the fragment or table shape.
+`FragmentDiscovery` interface (`FragmentDiscovery.kt`) so a fallback, each module writing a metadata file
+under `META-INF/`, read by the aggregator, can replace it without changing the fragment or table shape.
The same mechanism serves every target:
| Platform | Discovery source | Status |
|---|---|---|
-| JVM desktop | JARs on the classpath | `ksp-fixtures/app` desktop tests |
-| Android | JAR/AAR classpath | `ksp-fixtures/android` (`GeneratedAndroidTableTest`, unit and instrumented) |
+| JVM desktop | JARs on the classpath | `python-multiplatform-ksp/fixtures/app` desktop tests |
+| Android | JAR/AAR classpath | `python-multiplatform-ksp/fixtures/android` (`GeneratedAndroidTableTest`, unit and instrumented) |
| GraalVM native image | classpath at build time; the table is statically reachable, so no reflection config | verified manually, [`graal-native-image-verification.md`](../platforms/graal-native-image-verification.md) (SPEC U-6) |
-| Kotlin/Native | klib metadata | `ksp-fixtures/{library,app}` on `androidNativeArm64`: round 2 found the library's fragment in its `.klib`, `FunctionTable` compiled and linked, and `ksp-fixtures/app/src/androidNativeArm64Test/.../NativeSmokeTest.kt` runs it (SPEC U-3) |
+| Kotlin/Native | klib metadata | `python-multiplatform-ksp/fixtures/{library,app}` on `androidNativeArm64`: round 2 found the library's fragment in its `.klib`, `FunctionTable` compiled and linked, and `python-multiplatform-ksp/fixtures/app/src/androidNativeArm64Test/.../NativeSmokeTest.kt` runs it (SPEC U-3) |
`ServiceLoader` could work on JVM/Android, but one aggregator everywhere avoids a platform split and runtime
classpath scanning.
@@ -307,10 +309,10 @@ derives `moduleName`. Notes from building it:
through one reflective lookup of `arg(String, String)`.
- **Test configurations are matched as a camel-case word `Test`.** AGP puts the build type last
(`kspAndroidTestDebug`), so an `endsWith("Test")` check once put the processor on a unit-test
- compilation, which emitted a duplicate fragment shadowing `main`'s. `ksp-fixtures/android` carries the
+ compilation, which emitted a duplicate fragment shadowing `main`'s. `python-multiplatform-ksp/fixtures/android` carries the
Android plugin for this reason ([ROADMAP](../roadmap/ROADMAP.md) §13).
-### 2.6 Tree shaking — decision: accept the cost (measured)
+### 2.6 Tree shaking, decision: accept the cost (measured)
If the aggregator references every fragment and every fragment references every `public` callable, the
linker sees the entire public API as reachable. Each lambda `{ args -> greet(args[0] as String) }` is a
@@ -319,7 +321,7 @@ static reference to `greet`; a reachable fragment makes every target reachable.
cannot all hold: (1) blacklist exposure, (2) a full table referenced at build time (required on
Kotlin/Native), (3) tree shaking of unused entries.
-**Chosen: give up (3) — accept the cost**, because (1) is settled (§1.5) and (2) is forced by §2.3. The
+**Chosen: give up (3), accept the cost**, because (1) is settled (§1.5) and (2) is forced by §2.3. The
remaining options, one line each:
| Option | Verdict |
@@ -329,8 +331,8 @@ remaining options, one line each:
| D. `ServiceLoader` on JVM, aggregator on Native | A platform split for no gain: the JVM does not tree-shake anyway |
| E. Two-tier table (skeleton + lazily initialised body) | Saves runtime lambda objects, **not binary size**: the `when` arms still statically reference every target on Kotlin/Native |
-**Measured.** `ksp-fixtures/library` got 200 synthetic top-level functions (`fun bulkN(x: Long): Long = x + N`);
-`ksp-fixtures/app`'s `androidNativeArm64` debug test binary was linked before and after
+**Measured.** `python-multiplatform-ksp/fixtures/library` got 200 synthetic top-level functions (`fun bulkN(x: Long): Long = x + N`);
+`python-multiplatform-ksp/fixtures/app`'s `androidNativeArm64` debug test binary was linked before and after
(`linkDebugTestAndroidNativeArm64`) and stripped with the NDK's `aarch64-linux-android-strip`:
| | bytes | delta |
@@ -342,27 +344,27 @@ remaining options, one line each:
This is a **floor**: each function is a one-line expression, so almost all of the 1.84 KB is the
`ExposedCallable`, its lambda, its name string and Kotlin/Native per-function metadata. A real library adds
-its function bodies on top. For 200 entries the floor is ≈11% of a 3.26 MB stripped baseline — noticeable,
+its function bodies on top. For 200 entries the floor is ≈11% of a 3.26 MB stripped baseline, noticeable,
not disqualifying. Whether that is acceptable for a real library is a product judgement; the only real
mitigation would be changing the exposure model, which is settled.
-### 2.7 Incremental KSP — measured, `ALL_FILES` stays
+### 2.7 Incremental KSP, measured, `ALL_FILES` stays
`Dependencies(false)` would never regenerate the aggregator and miss a newly added fragment;
`Dependencies.ALL_FILES` is correct but reprocesses on every change. A narrower declaration buys nothing.
-Measured with `ksp.incremental.log=true` on `:ksp-fixtures:app` (two files), modifying one:
+Measured with `ksp.incremental.log=true` on `:python-multiplatform-ksp:fixtures:app` (two files), modifying one:
| aggregator `Dependencies` | dirty / all |
|---|---|
| `ALL_FILES` | 100.00% |
| `Dependencies(aggregating = true, )` | 100.00% |
-A module's own `Fragment_` is an *aggregating* output over every source file — correctly, since under
-blacklist exposure any file can add an entry — so any change regenerates it and KSP marks every source that
-maps to it dirty. A classpath-only change behaves the same (adding a function to `:ksp-fixtures:library`
+A module's own `Fragment_` is an *aggregating* output over every source file, correctly, since under
+blacklist exposure any file can add an entry, so any change regenerates it and KSP marks every source that
+maps to it dirty. A classpath-only change behaves the same (adding a function to `:python-multiplatform-ksp:fixtures:library`
dirtied both app files with `CP changes` listing `Fragment_ksp_fixture_library`). The only route to real
incrementality is **per-file fragments**, which changes fragment naming, `UpcallTable`'s per-module
-idempotency and where duplicate names are detected — a design change, not done.
+idempotency and where duplicate names are detected, a design change, not done.
### 2.8 Alternatives rejected
@@ -410,7 +412,7 @@ Argument passing needs exactly one C shape:
PyObject *pm_invoke(long callableHandle, PyObject *args) // (long, long) -> long
Arity and types travel inside the tuple and the table entry, never in the C signature. A stub specialised
-per signature would need one per `(arity, tag-vector)` — unbounded and impossible to pre-generate for a
+per signature would need one per `(arity, tag-vector)`, unbounded and impossible to pre-generate for a
closed world. The remaining slots were already in the downcall vocabulary:
| Slot | C signature | Carrier shape | |
@@ -423,7 +425,7 @@ closed world. The remaining slots were already in the downcall vocabulary:
| name → handle | `long(const char *)` | `(long) -> long` | pre-existing |
| release / cancel | `int(long)` | `(long) -> int` | pre-existing (`_pm_release`, `_pm_cancel`) |
-`newfunc` is unaccounted for only if `tp_new` is ever bound directly; it is not — construction goes
+`newfunc` is unaccounted for only if `tp_new` is ever bound directly; it is not, construction goes
through a `CONSTRUCTOR` entry called from Python (§4.2).
### 3.3 What crosses, per tag
@@ -435,25 +437,25 @@ The `Array` an entry receives has one representation per `TypeTag`, which
|---|---|---|
| `INT` | `int` | `Long` (a declared `Int`/`Short`/`Byte` is narrowed by generated code) |
| `FLOAT` | `float` | `Double` |
-| `BOOLEAN` | `bool` | `Boolean` — a distinct Python type, not folded into `INT` |
+| `BOOLEAN` | `bool` | `Boolean`, a distinct Python type, not folded into `INT` |
| `STRING` | `str` | `String` |
| `BYTES` | `bytes` | `ByteArray` (NUL bytes survive; SPEC U-2) |
| `UNIT` | `None` | `Unit` |
| `OBJECT` | `int` handle, **or** any Python object | the Kotlin instance from `HandleTable`, **or** a `PyObject` |
-| — | `None` | `null`, whatever the tag |
+| - | `None` | `null`, whatever the tag |
`OBJECT` is resolved by what Python actually sent: Python cannot hold a Kotlin reference, so a Kotlin
object crosses as an `ObjectReference` integer (`(generation shl 32) | slot`, generation ≥ 1); anything
else is a Python object and reaches a parameter declared `PyObject`.
-`BYTES` is correct and slow — one item at a time — because `PyBytes_AsString` is bound as a NUL-terminated
+`BYTES` is correct and slow, one item at a time, because `PyBytes_AsString` is bound as a NUL-terminated
string read and `PyBytes_AsStringAndSize` is in no platform's `EmbedAPI` yet (`UpcallTrampoline.toByteArray`
KDoc). When it is, both directions collapse to one call.
### 3.4 Conventions that are not negotiable
- **Arguments are borrowed.** `PyTuple_GetItem` lends, so a `PyObject` built over one takes
- `borrowed = true`. `borrowed = false` gives back a reference nobody took — one per call — and the free
+ `borrowed = true`. `borrowed = false` gives back a reference nobody took, one per call, and the free
lands somewhere unrelated. That bug crashed this repository twice.
- **The result is a new reference**; Python takes ownership. Measured end to end through each platform's
real entry point: a hundred calls move the returned object's refcount by zero
@@ -479,8 +481,8 @@ runtimes the moment upcalls exist ([`androidMain/README.md`](../../python-multip
### 3.6 Handles and their lifetimes
-- `CallableHandle` — table index + epoch (§1.2). `-1` is `NONE`; a valid handle is never negative.
-- `HandleTable` / `ObjectReference` — a Kotlin object given to Python is rooted in `HandleTable` under a
+- `CallableHandle`, table index + epoch (§1.2). `-1` is `NONE`; a valid handle is never negative.
+- `HandleTable` / `ObjectReference`, a Kotlin object given to Python is rooted in `HandleTable` under a
generation-tagged handle; releases are generational, so a second release is a no-op and a stale handle
never frees a reused slot (SPEC M-4, `HandleTableTest`, `ProxyHandleLifetimeTest`,
`OwnedResultLifetimeTest`). Generated proxies give their handle back in `__del__` (§4.2).
@@ -513,7 +515,7 @@ came out of it: the generator's assumptions about bootstraps are only proven by
### 3.8 Android: the boundary runs the other way, and threads attach once
-`RegisterNatives` binds a JVM `external fun` to a C function — the **downcall** direction. A
+`RegisterNatives` binds a JVM `external fun` to a C function, the **downcall** direction. A
`PyMethodDef`'s `ml_meth` must be a real C function pointer and Kotlin/JVM on ART can produce none. So the
entry points are C functions in `jni_onload.def`, and *they* call Kotlin with `CallStaticLongMethod` against
`UpcallCallbacks`, a class looked up once in `JNI_OnLoad` and held as a global ref. `RegisterNatives` appears
@@ -526,8 +528,8 @@ for `tp_traverse`/`tp_clear` (`ProxyCallbacks`), down to reusing `pmp_attach`.
`threading.Thread` and asserts it did not land on the instrumentation thread.
**Attach once per thread, detach at thread death.** Attaching and detaching per call was the most expensive
-thing on the Android upcall path (§7.6). The belief that forced it — "ART aborts if a thread exits without
-detaching" — is false: `Thread::ThreadExitCallback` (`runtime/thread.cc`, same shape in `android-8.0.0_r1`
+thing on the Android upcall path (§7.6). The belief that forced it, "ART aborts if a thread exits without
+detaching", is false: `Thread::ThreadExitCallback` (`runtime/thread.cc`, same shape in `android-8.0.0_r1`
and `main`) warns on its first invocation and only reaches `LOG(FATAL)` on a second, which only an `#else`
branch Android does not compile could arm; bionic clears a key's value before its destructor and never
re-reads it. `UpcallThreadAttachTest.aThreadThatExitsWithoutDetachingLeaksItsPeerRatherThanAbortingArt`
@@ -537,7 +539,7 @@ lets an attached pthread exit undetached on `pmp_api26` and `pmp_api36` with no
`pthread_key_create` destructor (`pmp_thread_exit_detach`) that defers to its second invocation so it cannot
run before ART's own exit callback.
-**Holding attachments is not a new risk.** Peak simultaneous attachments do not change — under the per-call
+**Holding attachments is not a new risk.** Peak simultaneous attachments do not change, under the per-call
scheme every worker upcalling at an instant is attached at that instant; the ceiling is the number of live
Python threads either way. What changes is that release must be reliable:
`manyConcurrentWorkersAreEachAttachedOnceAndAllReleased` holds 32 workers on a `threading.Barrier`, checks
@@ -546,7 +548,7 @@ emulators). The instrument reads GC reachability of the peer, not `ThreadGroup.e
does not report an attached native thread's peer at all; a positive control (peer unreclaimable while its
worker is parked mid-upcall) is kept in the test because it is what caught that.
-The C shims take no GIL — CPython holds it when calling a `PyCFunction` — and the trampoline takes its own
+The C shims take no GIL, CPython holds it when calling a `PyCFunction`, and the trampoline takes its own
pair (§3.5).
### 3.9 iOS and androidNative: the `@CName` + `ctypes.CDLL(None)` route, measured
@@ -557,8 +559,8 @@ binary. On iOS **neither half holds**:
| Binary | `pm_upcall_invoke` / `pm_upcall_resolve` / `pm_upcall_release_object` |
|---|---|
| androidNative `libmultiplatform_python3.14.so` | **exported** (`T` in `nm -D`) |
-| iOS `PythonMultiplatform.framework` | **absent** — a K/N framework exports the Objective-C surface plus the Konan runtime |
-| Kotlin/Native test executable, either target | **absent**, also from per-file caches — never emitted |
+| iOS `PythonMultiplatform.framework` | **absent**, a K/N framework exports the Objective-C surface plus the Konan runtime |
+| Kotlin/Native test executable, either target | **absent**, also from per-file caches, never emitted |
and this project's iOS `Python.framework` binary has no `_ctypes`, so `import ctypes` raises
`ModuleNotFoundError` (Android's CPython ships `_ctypes.so`). The `@CName` functions
@@ -577,7 +579,7 @@ puts the export in CPython's table (`pmpRegisterUpcall`) and builds `PyCFunction
objects over it.
A `PyCFunction` carries `self` as well as the function pointer, so one export already backs any number of
-distinct callables. Putting a value no handle can take in `self` turns it into a dispatcher — **no second
+distinct callables. Putting a value no handle can take in `self` turns it into a dispatcher, **no second
export, and no op argument**:
| `self` | Name | Shape |
@@ -598,7 +600,7 @@ guard ("the raw upcall entry points are not bound …"), so the passing run depe
The bare mechanism (`call_indirect`, no arguments) was measured at 3.1 ns in the former standalone
`wasm-experiment` (against 10.9 ns for `addFunction` around a JS closure); that experiment is now
`wasmJsTest/.../WasmUpcallRouteOverheadTest.kt`, measured from Python as in production. **3.1 ns must not be
-quoted for an upcall that carries arguments** — that is ~300 ns (§7.2).
+quoted for an upcall that carries arguments**, that is ~300 ns (§7.2).
---
@@ -622,34 +624,34 @@ installed table; `install()` executes it. Not a KSP-emitted `.py` resource, beca
### 4.2 Shapes
-- **Module functions** — published into `sys.modules` under the Kotlin package (`_pm_module`), so
+- **Module functions**: published into `sys.modules` under the Kotlin package (`_pm_module`), so
`from demo.calc import doubleLater` works with no import hook (CPython checks `sys.modules` before any
finder). Each is wrapped by `python_multiplatform.kotlin_function` (§4.3).
-- **Classes** — a plain Python `class` whose `__init__` calls the `CONSTRUCTOR` entry and stores the handle in
+- **Classes**: a plain Python `class` whose `__init__` calls the `CONSTRUCTOR` entry and stores the handle in
`self._pm_handle`; methods and properties pass it as `args[0]`. A `private set` property refuses
assignment. Interfaces and abstract classes have no constructor.
-- **Handle release** — `__del__` (`tp_finalize`, run by `tp_dealloc`) calls `_pm_release`, looked up once at
+- **Handle release**: `__del__` (`tp_finalize`, run by `tp_dealloc`) calls `_pm_release`, looked up once at
class-definition time and carried as a default argument (globals are `None` during finalisation). Without
it, `GeneratedProxyCostTest`'s constructor row ended at 78 002 live handles. Alternatives measured on CPython
3.13, construct + destruct net of a plain object: `__del__` +66 ns; per-instance holder +158 ns;
`weakref.ref` registry +260 ns; `weakref.finalize` +555 ns. The accepted weakness: a subclass that defines
`__del__` without calling `super().__del__()` leaks its handle (measured). `ProxyHandleLifetimeTest` pins
release, including through a cycle.
-- **GC base** — where `ProxyTypeFactory.installGcBase()` succeeds it publishes the cycle-collecting
+- **GC base**: where `ProxyTypeFactory.installGcBase()` succeeds it publishes the cycle-collecting
`PyType_FromSpec` type as `_pm_proxy_base`, and generated classes subclass it through a `PyMemberDef` named
`_pm_handle`, so `self._pm_handle = …` writes into the C slot `tp_traverse` reads (§6). Otherwise they fall
back to `_PmObject`. Every platform's `actual` now implements `installGcBase` (code read; the `expect`'s
KDoc saying Android and wasm return `false` is stale).
-- **Class statics** (companion / `object` properties, enum entries) — a descriptor on a **metaclass** rendered
+- **Class statics** (companion / `object` properties, enum entries), a descriptor on a **metaclass** rendered
beside the class, so instances cannot see them (Kotlin's rule). **Companion functions** go on the metaclass
too: a `staticmethod` in the class body would be reachable through an instance, and an accessor pair would
disagree with the Kotlin declaration.
-- **Top-level `val`/`var`** — a `property` (a *data* descriptor) on a `ModuleType` subclass generated **per
+- **Top-level `val`/`var`**: a `property` (a *data* descriptor) on a `ModuleType` subclass generated **per
module** (one shared type would answer the attribute on every module). The previous shape, a
`__getattr__`/`__setattr__` pair on one shared subclass, cost ~50× more per read because `__getattr__` is
the fallback hook: `module_getattro` first raises a fully formatted `AttributeError` that the hook discards
(§7.7).
-- **`suspend` functions** — `async def` wrappers that await the result only if it is awaitable:
+- **`suspend` functions**: `async def` wrappers that await the result only if it is awaitable:
async def _pm_f_0(a0):
_pm_r = _pm_invoke(_pm_h_0, (a0,))
@@ -672,9 +674,10 @@ lives at `python_multiplatform.binding`). Its KDoc is the contract (SPEC U-8):
- `inspect.signature(fn)` (built lazily via `__wrapped__`) and `python_multiplatform.describe(fn)` give the
metadata. The answer is the same whichever installer ran first (stamped epochs).
-No member or parameter is renamed and the binder creates no `pythonx` module; a real `pythonx` package on
-disk is what `import pythonx` loads. Tests: `desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`,
-`ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt`. `.pyi` stubs are generated by the Gradle plugin
+No namespace is renamed and the binder creates no `pythonx` module; a real `pythonx` package on
+disk is what `import pythonx` loads. Members and keywords also answer to their Pythonic aliases, and the
+signature shows the Pythonic keyword (SPEC U-12). Tests: `desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`,
+`python-multiplatform-compose/fixtures/compose/.../KotlinSignatureMetadataTest.kt`. `.pyi` stubs are generated by the Gradle plugin
under Kotlin module paths only (SPEC B-7).
### 4.4 Inheritance
@@ -714,24 +717,24 @@ demonstrates it (§5.8); `planned` on wasm.
### 5.1 Why a suspend function cannot simply be called
CPython calls a C function pointer whose frame must return a `PyObject *` synchronously; a suspension has
-nothing to return. `runBlocking` is not a solution: it blocks the thread the upcall arrived on — on Android a
-CPython-created pthread — so the interpreter waiting for the answer is the thing being blocked. The only
+nothing to return. `runBlocking` is not a solution: it blocks the thread the upcall arrived on, on Android a
+CPython-created pthread, so the interpreter waiting for the answer is the thing being blocked. The only
option: **return something else now and deliver the value later.**
### 5.2 Three candidates; (C) chosen, with (B) as its mechanism
-All three share the Kotlin half — start the coroutine inside the synchronous frame and park the result where
+All three share the Kotlin half, start the coroutine inside the synchronous frame and park the result where
it can be addressed later.
| Candidate | Verdict |
|---|---|
| **(A) handle + polling** (`is_done(h)` / `result(h)`) | **Rejected.** On wasmJs Python and Kotlin share one JS thread and resumption only happens when control returns to the host, so a polling loop that never yields prevents its own progress (inferred). Also burns a core and re-takes the GIL at every switch interval, and KSP cannot expose a generic `Deferred` |
-| **(B) callback** — Python passes a callable, Kotlin calls it on completion | Works: a Kotlin-created thread can take the GIL and call Python while the main thread is inside `run_until_complete` (measured, `AsyncCompletionProbeTest.aKotlinCreatedThreadCanResolveAnAsyncioFutureTheInterpreterIsWaitingOn`, which passes only after observing `loop.is_running()`). Hands all synchronisation to the user |
-| **(C) asyncio** — Kotlin creates a `Future` on the running loop and resolves it with `loop.call_soon_threadsafe` | **Chosen.** Needs no new binding: `PyImport_ImportModule`, `PyObject_GetAttrString` and `PyObject_Call*` are enough (measured, `AsyncCompletionProbeTest.everyApiThisNeedsIsAlreadyBoundSoAnAsyncioConventionAddsNoNewBinding`). `await kotlin_fn(x)` is syntax Python users already know |
+| **(B) callback**, Python passes a callable, Kotlin calls it on completion | Works: a Kotlin-created thread can take the GIL and call Python while the main thread is inside `run_until_complete` (measured, `AsyncCompletionProbeTest.aKotlinCreatedThreadCanResolveAnAsyncioFutureTheInterpreterIsWaitingOn`, which passes only after observing `loop.is_running()`). Hands all synchronisation to the user |
+| **(C) asyncio**, Kotlin creates a `Future` on the running loop and resolves it with `loop.call_soon_threadsafe` | **Chosen.** Needs no new binding: `PyImport_ImportModule`, `PyObject_GetAttrString` and `PyObject_Call*` are enough (measured, `AsyncCompletionProbeTest.everyApiThisNeedsIsAlreadyBoundSoAnAsyncioConventionAddsNoNewBinding`). `await kotlin_fn(x)` is syntax Python users already know |
(C) is mechanically (B): `call_soon_threadsafe` is a downcall from the completion thread. `Py_AddPendingCall`
(in the Stable ABI, not bound in `EmbedAPI`) was not needed. The cost of (C): the application must be written
-async — the loop must be running.
+async, the loop must be running.
**The fast path comes before the convention.** `suspend` is a signature, not a promise to suspend: a body
that reaches no suspension point is complete before `PendingCall.start` returns
@@ -741,7 +744,7 @@ directly with no `Future` and no event loop.
What would reverse this choice: if a target's loop could not be made to yield to its host, (B) would be the
better public surface there; if a completion thread on Android were found to need an ART attach, the
`pmp_thread_exit_detach` destructor (§3.8) would have to be replicated on the completion side. (A Kotlin/JVM
-thread is already known to ART, so no attach is expected — inferred; the ART sample in §5.8 resumes from a
+thread is already known to ART, so no attach is expected, inferred; the ART sample in §5.8 resumes from a
`java.lang.Thread` and works.)
### 5.3 KSP: a different body, not an exclusion
@@ -755,8 +758,8 @@ The braces are a `suspend` lambda; it compiles with the stdlib only. `kind` is u
member is still `METHOD`, receiver in `args[0]`) and `returnType` is the **declared** return type, so the
fast path marshals exactly like a synchronous entry. `ExposedCallable.isSuspend` is a flag rather than
`SUSPEND_*` variants of `CallableKind` because the axes are orthogonal: `kind` says where the arguments are,
-`isSuspend` what comes back. Pinned by `ksp-fixtures/app/.../GeneratedSuspendTest.kt` against
-`ksp-fixtures/library/.../Suspending.kt`, which pairs every suspending declaration (top-level, member,
+`isSuspend` what comes back. Pinned by `python-multiplatform-ksp/fixtures/app/.../GeneratedSuspendTest.kt` against
+`python-multiplatform-ksp/fixtures/library/.../Suspending.kt`, which pairs every suspending declaration (top-level, member,
companion, interface, object) with a non-suspending control in the same scope.
A `suspend` **function type** (`val h: suspend (Long) -> Long`, a parameter or return of that type, nullable,
@@ -772,11 +775,11 @@ that; it is stale.)
- **No `kotlinx.coroutines` dependency.** `startCoroutine` and `Continuation` are stdlib; the dispatcher a
user's `suspend fun` needs is already on the user's classpath.
- **The context is the `PendingCall` itself** (a `CoroutineContext.Element`), with no dispatcher, so the body
- runs on the calling thread until its first real suspension — which is where the fast path comes from, and
+ runs on the calling thread until its first real suspension, which is where the fast path comes from, and
why work before the first suspension runs **inside the C frame, holding the GIL**.
- **Nothing escapes**: body and listener failures are parked, as in the trampoline.
- **No new handle machinery**: it is an ordinary object in `HandleTable`.
-- **Unsynchronised**, on the boundary's rule that every mutation happens on a GIL-holding thread; a
+- **Unsynchronised**: on the boundary's rule that every mutation happens on a GIL-holding thread; a
completion that reaches Python takes the GIL anyway.
### 5.5 Delivery
@@ -795,10 +798,10 @@ really ensures; a completion inside a later upcall correctly nests. The Kotlin w
reference to the `Future`; the completion lambda keeps the wrapper alive until it fires.
**The fast path is wider than "did not suspend".** If the completion thread resumes the continuation while the
-upcall frame is still running — `CFUNCTYPE` releases the GIL for the upcall itself — `deliver` sees `isDone`
+upcall frame is still running, `CFUNCTYPE` releases the GIL for the upcall itself, `deliver` sees `isDone`
and returns the value. Correct, but the synchronous-completion ratio is a function of the race, and a test of
the slow path must observe that a `Future` actually crossed (`AsyncUpcallDeliveryTest` records
-`type(r).__name__`; the proxy tests count `create_future` calls instead and assert `0` on the fast path —
+`type(r).__name__`; the proxy tests count `create_future` calls instead and assert `0` on the fast path,
`PythonProxyInstallTest.theSameGeneratedProxyBuildsNoFutureWhenTheKotlinBodyNeverSuspends`).
**No running loop:** a call that really suspends fails with `RuntimeError: no running event loop`, and the
@@ -812,7 +815,7 @@ A suspending entry that never suspends works with no loop at all
**A completion landing on a cancelled `Future` is dropped, not raised.** Measured before the fix
(`AsyncUpcallCancellationTest`): `await` got `CancelledError`; the loop's `call_exception_handler` got one
`InvalidStateError: invalid state`; the completion thread's error indicator was clean and the next unrelated
-upcall worked — no contamination, just an unactionable log entry. Two guards, only the second a guarantee:
+upcall worked, no contamination, just an unactionable log entry. Two guards, only the second a guarantee:
`AsyncUpcall.resolve` checks `done()` before scheduling (cheap, racy), and the scheduled callback is
`_pm_settle`, which checks `done()` **again on the loop thread**. Removing only `_pm_settle`'s guard turns
exactly the race test red; the race is forced, not left to timing (the Python coroutine spins without
@@ -823,7 +826,7 @@ the coroutine's *completion*; the suspension point's continuation belongs to who
`suspendCoroutine`, their dispatcher, `kotlinx.coroutines`). `PendingCall` never sees it, the stdlib cannot
reach it, and resuming twice is undefined. Forced cancellation is what a `Job` tree does. What works:
`ensureActive()` (top-level, reads `coroutineContext[PendingCall]`) throws `CancellationException` once the
-call is cancelled — the same contract as `kotlinx.coroutines`, where a body that never checks is never
+call is cancelled, the same contract as `kotlinx.coroutines`, where a body that never checks is never
cancelled either. The element is not a `ContinuationInterceptor`, so the fast path is untouched.
suspend fun slowSum(n: Long): Long {
@@ -852,7 +855,7 @@ at completion), then resumes once and requires `CancellationException` and `isDo
| Releaser | Only it covers |
|---|---|
-| Python done callback | a cancelled body that never cooperates — the Kotlin completion never runs |
+| Python done callback | a cancelled body that never cooperates, the Kotlin completion never runs |
| Kotlin `resolve` | the loop stopping before the scheduled done callback ran |
Generational release makes the second a no-op and a late `_pm_cancel(stale)` harmless. Measured with
@@ -864,11 +867,11 @@ Generational release makes the second a no-op and a late `_pm_cancel(stale)` har
| after cancel, coroutine not yet finished | baseline (Python released it) |
| after cancel and body exit | baseline |
| after normal completion | baseline |
-| fast path | baseline — nothing registered (`theFastPathRegistersNoHandleAtAll`) |
+| fast path | baseline, nothing registered (`theFastPathRegistersNoHandleAtAll`) |
Registration happens after `deliver`'s `isDone` early return, so the fast path touches neither `HandleTable`,
`__main__` nor `add_done_callback`. The slow path pays two `__main__` lookups and one `_pm_watch` call. Not
-reclaimed: a call whose `Future` never settles and whose coroutine never ends — that has leaked its
+reclaimed: a call whose `Future` never settles and whose coroutine never ends, that has leaked its
continuation already.
### 5.7 Desktop and Kotlin/Native tests
@@ -876,7 +879,7 @@ continuation already.
The desktop tests use `java.lang.Thread` and `java.util.concurrent`, so they cannot move to `commonTest`; the
claims are re-verified on Kotlin/Native with `nativeTest/.../NativeThread.kt`, a thin `pthread_create`
wrapper. Not `Worker`: the question is whether a completion may come from a thread the Kotlin/Native runtime
-never attached itself, which `Worker` would sidestep — the same situation
+never attached itself, which `Worker` would sidestep, the same situation
`CycleCollectionTest.testDeallocOnAThreadCPythonCreated` measures for `tp_dealloc`.
| `nativeTest` | Desktop counterpart | Re-verifies |
@@ -887,33 +890,33 @@ never attached itself, which `Worker` would sidestep — the same situation
All passed on the iOS simulator, three repeated runs. They are in `nativeTest`, which
`androidNativeArm64Test` also runs, and they predate the androidNative full-suite runs recorded in §5.8
-(316 tests, 0 failures) — but no per-test androidNative result for them was recorded (unverified).
-`AsyncUpcallPortabilityTest` (`commonTest`) keeps only the two paths that return before touching asyncio — fast
-path and failure-before-suspend — which need no thread and run on all targets.
+(316 tests, 0 failures), but no per-test androidNative result for them was recorded (unverified).
+`AsyncUpcallPortabilityTest` (`commonTest`) keeps only the two paths that return before touching asyncio, fast
+path and failure-before-suspend, which need no thread and run on all targets.
### 5.8 Per platform
-**Desktop** — everything above (`AsyncUpcallDeliveryTest`, `AsyncUpcallCancellationTest`,
+**Desktop**, everything above (`AsyncUpcallDeliveryTest`, `AsyncUpcallCancellationTest`,
`AsyncUpcallEarlyCancellationTest`, `PythonProxyDeliveryTest`).
-**iOS simulator** — asyncio is available. The iOS `Python.framework` binary has no `lib-dynload`, but asyncio
+**iOS simulator**, asyncio is available. The iOS `Python.framework` binary has no `lib-dynload`, but asyncio
comes from the separately extracted BeeWare stdlib (`build/python-stdlib/ios-simulator/lib/python3.14/` with
`asyncio/` and `lib-dynload/_asyncio…so`, `select…so`, `_socket…so`).
`iosSimulatorArm64Test/.../AsyncioAvailabilityProbeTest.kt` (isolated in its own file) runs
`asyncio.new_event_loop().run_until_complete(...)` and passes. Delivery, early cancellation and the silent drop
pass (§5.7).
-**Android (ART)** — proxies, including `async` ones, run on `pmp_api26` and `pmp_api36`
+**Android (ART)**, proxies, including `async` ones, run on `pmp_api26` and `pmp_api36`
(`connectedDebugAndroidTest`: 336 tests, 0 failures on each; `PythonProxyInstallTest`'s 11 on the real path).
The sample app on both emulators showed:
| Section | Output |
|---|---|
-| 3 — upcall | `handle 4294967327 -> 0 · with args -> presses x3 = 0` (called from Python via `_pm_resolve`/`_pm_invoke`) |
-| 5 — class proxy | `installed over PyMethodDef via JNI: 466 lines, 2 proxy classes`, `Greeter('Kotlin').greet(2) -> hello Kotlin! hello Kotlin!`, `g.greetings = 99 -> AttributeError (private set held)` |
-| 6 — companion | `Greeter.forget() -> 100, then built=0`, `Greeter('x').built -> AttributeError (companion is class-only)` |
-| 7 — fast path | `await g.greetNow(1) -> hello fast path!`, `Futures created -> 0` |
-| 7 — really suspends | `await g.greetLater(2) -> hello slow path! hello slow path!`, `Futures created -> 1`, `raised -> None`, `completer thread -> clean` |
+| 3, upcall | `handle 4294967327 -> 0 · with args -> presses x3 = 0` (called from Python via `_pm_resolve`/`_pm_invoke`) |
+| 5, class proxy | `installed over PyMethodDef via JNI: 466 lines, 2 proxy classes`, `Greeter('Kotlin').greet(2) -> hello Kotlin! hello Kotlin!`, `g.greetings = 99 -> AttributeError (private set held)` |
+| 6, companion | `Greeter.forget() -> 100, then built=0`, `Greeter('x').built -> AttributeError (companion is class-only)` |
+| 7, fast path | `await g.greetNow(1) -> hello fast path!`, `Futures created -> 0` |
+| 7, really suspends | `await g.greetLater(2) -> hello slow path! hello slow path!`, `Futures created -> 1`, `raised -> None`, `completer thread -> clean` |
The last row resumes the parked continuation from a `java.lang.Thread`, as on desktop. (The original record
adds that `pmp_attach` runs on that path too; a `java.lang.Thread` is already known to ART, so that is
@@ -921,7 +924,7 @@ adds that `pmp_attach` runs on that path too; a `java.lang.Thread` is already kn
`Worker` or memory-model decision in the sample; the library's `PythonProxyNativeDeliveryTest` does it with
`pthread_create`).
-**wasmJs** — the synchronous proxy surface runs (10 of `PythonProxyInstallTest`'s 11); `await` does not yet:
+**wasmJs**, the synchronous proxy surface runs (10 of `PythonProxyInstallTest`'s 11); `await` does not yet:
1. *The trap is fixed.* `import selectors` / `import asyncio` used to kill the Node process with
`RuntimeError: unreachable`. That was the third symptom: `select.poll().poll(0)` raised
@@ -934,7 +937,7 @@ adds that `pmp_attach` runs on that path too; a `java.lang.Thread` is already kn
flag to disable it); Gradle's runner uses Node 26. `selectors` was the first victim because it *calls*
`poll(0)` at import time; `asyncio` died only through it. **Fix:** `cpython.mjs`
(`python-multiplatform/src/wasmJsMain/resources/`) deletes `WebAssembly.promising` and
- `WebAssembly.Suspending` before calling the Emscripten factory, putting Node 26 on the synchronous path —
+ `WebAssembly.Suspending` before calling the Emscripten factory, putting Node 26 on the synchronous path,
which is what this library needs, since every call arrives from Kotlin as a synchronous wasm call. A
`sys.modules['selectors']` shim was rejected: it only moves the death to the first loop iteration, hands
users a fake module, and fixes the wrong layer. `wasmJsTest/.../WasmSelectorsImportTest.kt` (4 tests):
@@ -968,8 +971,8 @@ more generated field (`FragmentScanner.traverseBody`, `ClassModel.traverseBody`)
reflection-based binding cannot afford this, which is why mature ones document "do not create cycles"
instead. Generated proxies reach the traversable type through the GC base of §4.2.
-Status: SPEC M-3 — implemented on desktop (`desktopTest/.../ref/CycleCollectionTest.kt`,
-`ksp-fixtures/app/.../RefHolderCycleCollectionTest.kt`), partial elsewhere. Mechanism and the hard parts
+Status: SPEC M-3, implemented on desktop (`desktopTest/.../ref/CycleCollectionTest.kt`,
+`python-multiplatform-ksp/fixtures/app/.../RefHolderCycleCollectionTest.kt`), partial elsewhere. Mechanism and the hard parts
(traverse during collection, `tp_clear` mutating Kotlin state, cycles closing on the Kotlin side) are in
[`object-lifetime.md`](object-lifetime.md).
@@ -1029,11 +1032,11 @@ Controls from the same cut:
The previous hand-cut round (warmup 100 000; five full suites per device, eleven on the hosts) read:
desktop 510–560 ns, wasmJs 290–304 ns, iOS 2266–2301 ns, androidNative `pmp_api36` 3228–3275 ns,
`pmp_api26` 3234–3310 ns, ART `pmp_api36` 944–1063 ns, ART `pmp_api26` 1146–1233 ns. (The predecessor document
-labelled this list "warmup 3,000"; that was wrong — the 3 000-warmup desktop and wasmJs figures were 674 ns and
+labelled this list "warmup 3,000"; that was wrong, the 3 000-warmup desktop and wasmJs figures were 674 ns and
322 ns.) In that round each host had exactly one outlying run in eleven, kept rather than dropped: desktop's
GIL-held trampoline once read 186.81 ns against 76–91 ns (its ratio 2.89x against 5.62–7.02x); one wasmJs run
inflated every boundary row together (upcall 432.55, downcall 144.64, trampoline 264.44 ns) while the empty GIL
-scope stayed at 24.67 ns, so its ratio (2.99x) stayed in band — the case for preferring the ratio on wasm.
+scope stayed at 24.67 ns, so its ratio (2.99x) stayed in band, the case for preferring the ratio on wasm.
### 7.2 Warmup: why 100 000, and what the old figures were measuring
@@ -1052,7 +1055,7 @@ and ruled out CPython-side state are in [`downcall-design.md`](downcall-design.m
674 → 537 ns and wasmJs 322 → 292 ns. 13 desktop and 12 wasmJs full-suite runs, 3 of each with the lever
pulled.
-**JIT hosts fell, AOT hosts did not** — the prediction that separates the explanations:
+**JIT hosts fell, AOT hosts did not**, the prediction that separates the explanations:
| re-measured at 100 000 | upcall, old → new | downcall, old → new |
|---|---|---|
@@ -1065,7 +1068,7 @@ moved, 1209–1329 → 1146–1233 ns). ART has a JIT and moved like desktop and
no warmup": from cold, androidNative's first 10 000 upcalls read ~1.3× the plateau; the old Kotlin/Native rows
landed near it only because the rest of the suite had warmed the path.
-**Device sweep** — 40 consecutive reps of 10 000 calls from cold; first-rep ratio = rep 1 / mean of reps 21–40:
+**Device sweep**, 40 consecutive reps of 10 000 calls from cold; first-rep ratio = rep 1 / mean of reps 21–40:
| Target | rep 1 / plateau (Python-driven upcall) | flat from | margin at 100 000 |
|---|---|---|---|
@@ -1073,10 +1076,10 @@ landed near it only because the rest of the suite had warmed the path.
| androidNative `pmp_api36` | 1.27x | ~40 000 | 2.5x |
| androidNative `pmp_api26` | 1.30x | ~30 000 | 3.3x |
| ART `pmp_api26` | 1.93x | ~20 000, noisily | 5x |
-| **ART `pmp_api36`** | **8.56x** | **~90 000–100 000** | **~1x — none** |
+| **ART `pmp_api36`** | **8.56x** | **~90 000–100 000** | **~1x, none** |
ART `pmp_api36`'s sweep: 9194.8, 3218.9, 2288.7, 1648.3, 1218.3, 1173.3, 1129.5, 1532.7, 1057.8, 1044.6 ns against
-a plateau of 1074.2 — still 9% high at 70 000. Its downcall is flat from ~80 000 and its GIL-held trampoline
+a plateau of 1074.2, still 9% high at 70 000. Its downcall is flat from ~80 000 and its GIL-held trampoline
from ~50 000, so the Python-driven upcall sets the requirement. The controls are flat from the first rep on all
five device configurations (iOS 10.5 → 10.2 ns and 37.9 → 38.0 ns; ART `pmp_api36` 17.3 → 17.3 ns and
49.7 → 50.4 ns), so the sweep measures the boundary, not the interpreter. The host sweeps behind `WARMUP`'s doc:
@@ -1090,7 +1093,7 @@ ART `pmp_api36`, a slower device or busier host may read the boundary before it
**Filtered runs.** A desktop `--tests` run of this class alone reads 506–550 ns, inside the full-suite band, and
its controls match (8.4–8.6 vs 8.4 ns). **wasmJs is the exception**: filtered it reads 321–353 ns and its
-*pure-Python* controls read 23.1 and 83.1 ns against the suite's 15.0 and 47.2 — CPython is itself a wasm
+*pure-Python* controls read 23.1 and 83.1 ns against the suite's 15.0 and 47.2, CPython is itself a wasm
module there, so a short-lived Node process runs the interpreter 55–74% slower. Tripling the warmup to 300 000
does not move it (host-lifetime-bound). wasmJs's ratios survive filtering (2.66–3.13x vs 2.91–3.14x); its
absolutes are a full-suite figure only. The devices do not have the effect (three filtered runs vs five
@@ -1116,7 +1119,7 @@ against a `threading.Thread` caller.
pushes `test.kexe` and the CPython prefix (`Py_Initialize` aborts without a stdlib) to `/data/local/tmp`, runs
it under the TeamCity logger and converts the service messages to JUnit XML under
`build/test-results/androidNativeArm64Test/`, on every connected device with a matching ABI. First runs: 252
-tests, 0 failures, both emulators, five runs — `artMain` and the androidNative cinterop came up clean.
+tests, 0 failures, both emulators, five runs, `artMain` and the androidNative cinterop came up clean.
### 7.3 The trampoline column needs a control
@@ -1135,17 +1138,17 @@ simulator, 0.82–0.85x and 0.77–0.78x on androidNative `pmp_api36`/`pmp_api26
The **GIL-held** row is the comparable denominator (§7.1 uses it). Desktop and wasmJs never showed the effect
because their GIL scope is 24–57 ns against 276–1221 ns on a device. At the old 3 000 warmup desktop's empty
-scope read 124–152 ns and wasmJs's 79–81 ns — the cheapest operation is the one an unwarmed loop overstates
+scope read 124–152 ns and wasmJs's 79–81 ns, the cheapest operation is the one an unwarmed loop overstates
most. wasmJs's GIL-held trampoline is dearer than desktop's although its GIL scope is half the price, so wasm
pays for the trampoline body, not the GIL.
### 7.4 Where the differences come from
-- **iOS and androidNative: the scaffolding, not the boundary.** There is no runtime boundary on these targets —
+- **iOS and androidNative: the scaffolding, not the boundary.** There is no runtime boundary on these targets,
a `staticCFunction` in the same binary. What is expensive is per-call scaffolding every C API call shares: an
empty `withPython` scope is 720–1002 ns there against 50–57 ns on desktop and 24–28 ns on wasm, and the same
inflation hits the downcall denominator, which is why the ratio sits near 1.4. (In the round that measured
- `Py_IncRef + Py_DecRef` — two calls, two GIL scopes — it cost 1551–2032 ns on iOS and 2010–2132 ns on
+ `Py_IncRef + Py_DecRef`, two calls, two GIL scopes, it cost 1551–2032 ns on iOS and 2010–2132 ns on
androidNative against 110–120 ns on desktop and 44–49 ns on wasm; those device figures were unwarmed, so the
multiple is not quotable, but the gap is wide enough that the conclusion survives.) Work to move either number
belongs in the scaffolding, not in `UpcallEntry`.
@@ -1153,13 +1156,13 @@ pays for the trampoline body, not the GIL.
ns is the Panama upcall stub plus the `ctypes` shim, the largest relative boundary measured (5.62–7.02x in that
round, 5.54–6.46x in the cost table). Desktop's `_pm_invoke` is a `ctypes.CFUNCTYPE` (and in this benchmark a
Python `lambda *a: _pm_invoke(_pm_h, a)`), where other targets bind a `PyCFunction`, so desktop's row is an
- **upper bound** on its boundary — it includes ctypes' argument conversion and, in the benchmark, one extra
+ **upper bound** on its boundary, it includes ctypes' argument conversion and, in the benchmark, one extra
Python call (26.7–27.2 ns).
-- **wasmJs: ~300 ns with arguments**, of which 122–129 ns is the trampoline; the rest is the Python-side callable
+- **wasmJs: ~300 ns with arguments**: of which 122–129 ns is the trampoline; the rest is the Python-side callable
and the crossing with a real tuple. Its scaffolding is the cheapest and most stable (24.47–27.52 ns for an
empty scope across eleven runs), as there is no OS thread machinery; that row reads 34–37 ns when the class is
run alone, for the reason in §7.2.
-- **ART: one JNI upcall per call**, plus the per-thread attach the first time a CPython-created thread calls
+- **ART: one JNI upcall per call**: plus the per-thread attach the first time a CPython-created thread calls
(§7.6). Neither is on the marshalling path.
### 7.5 What a user calls: the generated proxy over the boundary
@@ -1167,7 +1170,7 @@ pays for the trampoline body, not the GIL.
`GeneratedProxyCostTest` (`commonTest`) prices each proxy surface against the raw `_pm_invoke` call it wraps,
measured through the same object, in the same run, each row a zero-argument Python function called by one
shared loop (the floor is subtracted). Each row is the **minimum of three timed loops**: the quantity is a
-difference of tens of ns on a boundary of hundreds and noise is one-sided — with one loop per row, four runs put
+difference of tens of ns on a boundary of hundreds and noise is one-sided, with one loop per row, four runs put
the instance-method delta at −17, −31, −60 and −71 ns. The last row of each table is the report's own
resolution: two raw rows are the same call (`_pm_invoke(h, ())`, different handle), so their difference is what
cannot be resolved. Three runs; taken in the round before §7.2's warmup fix, so read internally (raw against
@@ -1184,7 +1187,7 @@ proxy), not against §7.1's absolutes.
| `Counter.created = 12` static write | 466–507 ns | 511–573 ns | 1.07–1.13x | +34…+66 ns |
| `demo.calc.tally` top-level read | 449–485 ns | 484–521 ns | 1.03–1.07x | +14…+36 ns |
| `demo.calc.tally = 9` top-level write | 466–507 ns | 520–553 ns | 1.08–1.11x | +44…+54 ns |
-| `Counter(10)` constructor | 682–761 ns | 588–627 ns | — | not separable (below) |
+| `Counter(10)` constructor | 682–761 ns | 588–627 ns | - | not separable (below) |
| *resolution* | | | | *−3…+9 ns* |
| **iOS simulator** (arm64) | | | | |
| `demo.calc.ping()` module function | 1801–1890 ns | 1829–1895 ns | 1.00–1.01x | +6…+28 ns |
@@ -1236,7 +1239,7 @@ an empty call: plain module attribute 14 ns; PEP 562 module `__getattr__` 214 ns
| `demo.calc.tally = 9` write | +109…+136 ns (1.21–1.29x) | +44…+54 ns (1.08–1.11x) |
`PythonProxyInstallTest` passed unchanged across it, including the read-only refusal; nothing in the source
-says which shape is fifty times dearer — the measurement found it.
+says which shape is fifty times dearer, the measurement found it.
**The await fast path costs one coroutine frame:**
@@ -1285,7 +1288,7 @@ Attach once per thread (after, commit `409da6fc`):
| upcall / downcall of the same shape, worker | **0.99–1.09x** | **2.00–2.34x** |
Re-run unchanged over five full suites per emulator after `09bf2397` put `commonTest` on the device (so
-`UpcallBoundaryCostTest`'s several hundred thousand upcalls ran first and left the JIT warm — the observation the
+`UpcallBoundaryCostTest`'s several hundred thousand upcalls ran first and left the JIT warm, the observation the
warmup fix came from: the API 36 instrumentation row differs fourfold at the same warmup and emulator):
| re-run, 5 suites each | API 26 | API 36 |
@@ -1296,10 +1299,10 @@ warmup fix came from: the API 36 instrumentation row differs fourfold at the sam
| first upcall on a fresh worker | 88 250–242 375 ns | 41 208–611 416 ns |
On API 36 the claim sharpens (the gap is tight around zero). On API 26 a worker costs a little more (+43–249 ns
-against a ~1250 ns call) — not a return of the per-call attach. **The first upcall on each worker still pays the
+against a ~1250 ns call), not a return of the per-call attach. **The first upcall on each worker still pays the
attach in full** (56–142 µs in the first round, 41–611 µs in the re-run): once per thread, not once per call. A
worker that upcalls once gains nothing; one that upcalls in a loop is 20–50× better off. The structural assertion
-the test enforces — one ART thread per Python worker — is a count and unaffected by warmup.
+the test enforces, one ART thread per Python worker, is a count and unaffected by warmup.
---
diff --git a/docs/guide/assets/style.css b/docs/guide/assets/style.css
index bf63eb2ec..adb06056a 100644
--- a/docs/guide/assets/style.css
+++ b/docs/guide/assets/style.css
@@ -1,4 +1,4 @@
-/* python-multiplatform guide — shared thisisthepy tokens, accent #3b6cff */
+/* python-multiplatform guide, shared thisisthepy tokens, accent #3b6cff */
:root {
--bg: #fbfbfa; --fg: #16181d; --muted: #5b6270; --line: #e6e6e3; --code-bg: #f3f3f1;
--radius: 10px; --maxw: 72rem;
diff --git a/docs/guide/concepts.html b/docs/guide/concepts.html
index d45a88645..de25f7f0c 100644
--- a/docs/guide/concepts.html
+++ b/docs/guide/concepts.html
@@ -77,13 +77,13 @@ 04ThreadsEvery C API call holds the GIL. Parallelism will come from free-threaded CPython (3.14t works on desktop with -PpythonFreeThreaded=true; the default is still the GIL), not from sub-interpreters: extensions that do not declare multi-interpreter support fail to import in a sub-interpreter.모든 C API 호출은 GIL 을 잡는다. 병렬성은 서브 인터프리터가 아니라 free-threaded CPython(desktop 에서 -PpythonFreeThreaded=true 로 3.14t 동작, 기본값은 여전히 GIL)에서 온다. 멀티 인터프리터 지원을 선언하지 않은 확장은 서브 인터프리터에서 import 가 실패하기 때문이다.
05Names이름
- A Kotlin package is a Python module under the same name: androidx.compose.ui in Python is androidx. The binder never renames a namespace. Pythonic layers like pythonx are real Python packages that import those modules and reshape them — they live in their own repository.Kotlin 패키지는 같은 이름의 Python 모듈이다. Python 의 androidx.compose.ui 는 androidx 다. 바인더는 네임스페이스를 바꾸지 않는다. pythonx 같은 파이써닉 계층은 그 모듈을 가져와 다듬는 실제 Python 패키지이고, 자기 저장소에 있다.
+ A Kotlin package is a Python module under the same name: androidx.compose.ui in Python is androidx. The binder never renames a namespace. Pythonic layers like pythonx are real Python packages that import those modules and reshape them, they live in their own repository.Kotlin 패키지는 같은 이름의 Python 모듈이다. Python 의 androidx.compose.ui 는 androidx 다. 바인더는 네임스페이스를 바꾸지 않는다. pythonx 같은 파이써닉 계층은 그 모듈을 가져와 다듬는 실제 Python 패키지이고, 자기 저장소에 있다.
Design records for each topic are in docs/design.주제별 설계 기록은 docs/design 에 있다.
diff --git a/docs/guide/ecosystem.html b/docs/guide/ecosystem.html
index ec5089fcd..87343bb0b 100644
--- a/docs/guide/ecosystem.html
+++ b/docs/guide/ecosystem.html
@@ -49,7 +49,7 @@ One boundary, several projects
diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html
index 2336f8197..61f3d1b07 100644
--- a/docs/guide/getting-started.html
+++ b/docs/guide/getting-started.html
@@ -72,15 +72,15 @@ 03Your first call04Verify a change변경 검증하기
./gradlew :python-multiplatform:compileKotlinAndroidNativeArm64 --console=plain > .tmp/build.log 2>&1; echo "EXIT=$?"
./gradlew :python-multiplatform:desktopTest --rerun --console=plain > .tmp/test.log 2>&1; echo "EXIT=$?"
-./gradlew :ksp-fixtures:app:desktopTest --rerun
-./gradlew :ksp-fixtures:compose:desktopTest --rerun
-./gradlew :ksp-fixtures:artifact:desktopTest --rerun
+./gradlew :python-multiplatform-ksp:fixtures:app:desktopTest --rerun
+./gradlew :python-multiplatform-compose:fixtures:compose:desktopTest --rerun
+./gradlew :python-multiplatform-gradle-plugin-fixtures:artifact:desktopTest --rerun
Run the fixture modules one by one: a single invocation has hidden an ordering dependency before. Read exit codes from a file, never through a pipe.픽스처 모듈은 하나씩 따로 돌린다. 한 번에 돌리면 순서 의존성이 가려진 적이 있다. 종료 코드는 파이프가 아니라 파일에서 읽는다.
diff --git a/docs/guide/guide-binding-libraries.html b/docs/guide/guide-binding-libraries.html
index cb52f2712..31378973a 100644
--- a/docs/guide/guide-binding-libraries.html
+++ b/docs/guide/guide-binding-libraries.html
@@ -56,7 +56,7 @@ 02What you get
diff --git a/docs/guide/guide-kotlin-to-python.html b/docs/guide/guide-kotlin-to-python.html
index c3e73eb5c..6d63b8295 100644
--- a/docs/guide/guide-kotlin-to-python.html
+++ b/docs/guide/guide-kotlin-to-python.html
@@ -59,13 +59,13 @@ 02Statements and expressions
03Collections are Kotlin collections컬렉션은 Kotlin 컬렉션이다
PyList implements MutableList, PyDict implements MutableMap, PySet implements MutableSet, PyTuple implements List. Build them with fromList, fromMap, fromSet.PyList 는 MutableList, PyDict 는 MutableMap, PySet 은 MutableSet, PyTuple 은 List 를 구현한다. fromList, fromMap, fromSet 으로 만든다.
04Errors오류
- A Python exception raised by exec or eval is thrown as PyException, carrying the real exception type and message. exec is built on PyRun_String rather than PyRun_SimpleString, which would print and clear the error before Kotlin could see it.exec 나 eval 에서 일어난 Python 예외는 실제 예외 타입과 메시지를 담은 PyException 으로 던져진다. exec 는 PyRun_SimpleString 이 아니라 PyRun_String 위에 있다 — 앞의 것은 Kotlin 이 보기 전에 오류를 출력하고 지워 버린다.
+ A Python exception raised by exec or eval is thrown as PyException, carrying the real exception type and message. exec is built on PyRun_String rather than PyRun_SimpleString, which would print and clear the error before Kotlin could see it.exec 나 eval 에서 일어난 Python 예외는 실제 예외 타입과 메시지를 담은 PyException 으로 던져진다. exec 는 PyRun_SimpleString 이 아니라 PyRun_String 위에 있다, 앞의 것은 Kotlin 이 보기 전에 오류를 출력하고 지워 버린다.
Specification: L-1, O-1 to O-7. Tests: Python3Test, PyListTest, PyExceptionTest.스펙: L-1, O-1 ~ O-7. 테스트: Python3Test, PyListTest, PyExceptionTest.
diff --git a/docs/guide/guide-platforms.html b/docs/guide/guide-platforms.html
index c0d0f0441..b4406b500 100644
--- a/docs/guide/guide-platforms.html
+++ b/docs/guide/guide-platforms.html
@@ -56,7 +56,7 @@ 04Web (experimental)
diff --git a/docs/guide/guide-python-to-kotlin.html b/docs/guide/guide-python-to-kotlin.html
index 24f76aa40..5d6459ddb 100644
--- a/docs/guide/guide-python-to-kotlin.html
+++ b/docs/guide/guide-python-to-kotlin.html
@@ -72,7 +72,7 @@ 05Native images
diff --git a/docs/guide/guides.html b/docs/guide/guides.html
index 886a07113..75589bded 100644
--- a/docs/guide/guides.html
+++ b/docs/guide/guides.html
@@ -46,7 +46,7 @@ Task by task작업별로
diff --git a/docs/guide/index.html b/docs/guide/index.html
index 579c48fd0..0d8b0c737 100644
--- a/docs/guide/index.html
+++ b/docs/guide/index.html
@@ -34,7 +34,7 @@
Kotlin Multiplatform × CPythonKotlin Multiplatform × CPython
-
Real CPython inside Kotlin — each language calls the other, on every target.Kotlin 안의 진짜 CPython — 두 언어가 서로를, 모든 타깃에서 부른다.
+
Real CPython inside Kotlin, each language calls the other, on every target.Kotlin 안의 진짜 CPython, 두 언어가 서로를, 모든 타깃에서 부른다.
python-multiplatform embeds the CPython interpreter itself in a Kotlin Multiplatform app. Kotlin sees Python as typed Kotlin objects; Python sees Kotlin classes as ordinary Python.python-multiplatform 은 CPython 인터프리터 그 자체를 Kotlin Multiplatform 앱에 임베딩한다. Kotlin 에게 Python 은 타입이 있는 Kotlin 객체이고, Python 에게 Kotlin 클래스는 평범한 Python 이다.
Get started시작하기
@@ -74,7 +74,7 @@
Philosophy철학
The real interpreter, not an imitation흉내가 아니라 진짜 인터프리터CPython itself is embedded, so C extensions and the whole Python ecosystem keep working.CPython 그 자체를 임베딩하므로 C 확장과 Python 생태계 전체가 그대로 동작한다.
Each language stays itself각 언어는 자기 모습 그대로A PyList is a Kotlin MutableList; a Kotlin class is constructed with Greeter('Kotlin'); an extension function is a method on its receiver.PyList 는 Kotlin 의 MutableList 이고, Kotlin 클래스는 Greeter('Kotlin') 으로 생성하며, 확장 함수는 수신 객체의 메서드다.
-
Built ahead of time, works in a closed world미리 생성하므로 닫힌 세계에서도 동작No runtime reflection. A function table generated at build time is why upcalls work on Kotlin/Native and in GraalVM native images — an upcall that passes only on the JVM is not done.런타임 리플렉션을 쓰지 않는다. 빌드 타임에 생성한 함수 테이블 덕분에 업콜이 Kotlin/Native 와 GraalVM 네이티브 이미지에서도 동작한다 — JVM 에서만 통과하는 업콜은 완료가 아니다.
+
Built ahead of time, works in a closed world미리 생성하므로 닫힌 세계에서도 동작No runtime reflection. A function table generated at build time is why upcalls work on Kotlin/Native and in GraalVM native images, an upcall that passes only on the JVM is not done.런타임 리플렉션을 쓰지 않는다. 빌드 타임에 생성한 함수 테이블 덕분에 업콜이 Kotlin/Native 와 GraalVM 네이티브 이미지에서도 동작한다, JVM 에서만 통과하는 업콜은 완료가 아니다.
Names are never rewritten이름은 바꾸지 않는다A Kotlin package is imported in Python under its own name. Pythonic layers such as pythonx are real Python packages built on top, in their own repository.Kotlin 패키지는 Python 에서 자기 이름 그대로 import 된다. pythonx 같은 파이써닉 계층은 그 위에 세운 실제 Python 패키지이며, 자기 저장소에 있다.
Every crossing is measured모든 경계 통과는 측정한다Each boundary mechanism ships with overhead tests; the cost table is rendered from real runs on every target.경계 메커니즘마다 오버헤드 테스트가 함께 있고, 비용 표는 모든 타깃의 실제 실행에서 렌더링된다.
@@ -93,14 +93,14 @@
ContributingCheck that the change serves the project's intent (docs/INTENT.md on the develop branch).변경이 프로젝트의 의도(develop 브랜치의 docs/INTENT.md)에 맞는지 확인한다.
Describe the behaviour in the specification (docs/SPEC.md), marked planned.동작을 스펙(docs/SPEC.md)에 planned 로 적는다.
Write the test, run it, and include the red output in the pull request.테스트를 쓰고 돌려서, 빨간 출력을 풀 리퀘스트에 함께 올린다.
-
Implement; include the androidNative compile and each ksp-fixtures module, run separately, in your verification.구현한다. 검증에는 androidNative 컴파일과 ksp-fixtures 모듈 각각(따로 실행)을 포함한다.
+
Implement; include the androidNative compile and each fixtures/ module, run separately, in your verification.구현한다. 검증에는 androidNative 컴파일과 fixtures/ 모듈 각각(따로 실행)을 포함한다.
Work on a work/<topic> branch and open the pull request against develop.work/<topic> 브랜치에서 작업하고 develop 을 대상으로 풀 리퀘스트를 연다.
diff --git a/docs/guide/status.html b/docs/guide/status.html
index c171fcf1c..d528808c4 100644
--- a/docs/guide/status.html
+++ b/docs/guide/status.html
@@ -71,7 +71,7 @@ Why is androidx not renamed to something P
diff --git a/docs/investigations/android-unregistered-surface.md b/docs/investigations/android-unregistered-surface.md
index 26ce36956..b080238f1 100644
--- a/docs/investigations/android-unregistered-surface.md
+++ b/docs/investigations/android-unregistered-surface.md
@@ -21,7 +21,7 @@ Kotlin/Native `String` parameter, or a boxed `java.lang.Long`.
`everyDeclarationIsRegisteredOrIsOneOfTheFourNamedExceptions` parses `bindings.kt` and
`jni_onload.def` on every desktop build and fails if a declaration has no entry or the exception
set above goes stale. The rule is written in `androidMain/README.md` ("Every `external fun` here
-needs a table entry — no exceptions by reachability"). The migration this report recommended
+needs a table entry, no exceptions by reachability"). The migration this report recommended
(strings first, then core object model, then exceptions, then the rest) is complete.
## Numbers (all historical)
diff --git a/docs/investigations/cost-table.md b/docs/investigations/cost-table.md
index ed9b30d15..adf65b5b1 100644
--- a/docs/investigations/cost-table.md
+++ b/docs/investigations/cost-table.md
@@ -2,16 +2,16 @@
Kotlin ↔ Python 경계의 비용을, **여섯 타깃 전부에 대해 한 번에** 뽑아 놓은 표.
-> **결론과 상태 (2026-10-03 확인).** 업콜 1건은 같은 모양의 다운콜의 약 0.9~3.1 배다 — 호스트 JVM 에서
+> **결론과 상태 (2026-10-03 확인).** 업콜 1건은 같은 모양의 다운콜의 약 0.9~3.1 배다, 호스트 JVM 에서
> 약 543–576 ns (다운콜 260–266), iOS 시뮬레이터 약 2.3 µs, androidNative 약 3.4 µs, ART API 26/36 약 1.0–1.3 µs,
> wasmJs 약 300 ns. 모든 숫자는 아래 마커 사이의 표(커밋 `958c0082b294`, 2026-08-14, Apple M1, 타깃당 3회의
> min–max, 워밍업 100,000)에서 온 것이고, 그 표의 숫자는 이 문장보다 우선한다. 하네스(`benchmarks/cost-table.sh`,
-> `benchmarks/cost_table.py`)는 이 문서의 마커 사이만 다시 쓴다 — 마커와 그 바깥의 "다시 뽑는 법" 절은 하네스가
+> `benchmarks/cost_table.py`)는 이 문서의 마커 사이만 다시 쓴다, 마커와 그 바깥의 "다시 뽑는 법" 절은 하네스가
> 기대하는 형식이므로 손으로 고치지 않는다. 이 표는 **측정을 기록할 뿐 임계값을 단언하지 않는다**(SPEC X-1).
> 표는 2026-08-14 컷이므로 그 뒤의 커밋을 반영하지 않는다. 다시 뽑으려면 아래 "다시 뽑는 법".
`docs/design/upcall.md` 와 `docs/design/downcall-design.md` 는 각자의 조사 과정에서 나온 표를 갖고 있고,
-그 표들은 **조사가 끝난 시점의 기록**이다. 이 문서는 다르다 — 여기 있는 표는 사람이 옮겨 적은 것이
+그 표들은 **조사가 끝난 시점의 기록**이다. 이 문서는 다르다, 여기 있는 표는 사람이 옮겨 적은 것이
아니라 `benchmarks/cost_table.py` 가 실행 결과에서 직접 렌더링한 것이고, 아래 "다시 뽑는 법" 한
줄이면 통째로 갱신된다.
@@ -40,7 +40,7 @@ ANDROID_HOME=/Users/ibrew/Library/Android/sdk ./benchmarks/cost-table.sh --runs
| 조건 | 없으면 |
|---|---|
-| **기계가 조용할 것** — 1분 load average 가 4.0 미만 | 수집이 **거부된다.** `--allow-busy` 로 넘길 수 있으나, 그 경우 산출물과 표 머리에 오염 표시가 붙고 그 숫자는 인용 대상이 아니다 |
+| **기계가 조용할 것**, 1분 load average 가 4.0 미만 | 수집이 **거부된다.** `--allow-busy` 로 넘길 수 있으나, 그 경우 산출물과 표 머리에 오염 표시가 붙고 그 숫자는 인용 대상이 아니다 |
| `ANDROID_HOME` 이 platform-tools 가 있는 SDK 를 가리킬 것 | ART 두 타깃과 `androidNativeArm64` 가 **skipped 로 기록된다** (조용히 빠지지 않는다) |
| 에뮬레이터 `pmp_api26`, `pmp_api36` 이 떠 있을 것 | 해당 타깃이 skipped 로 기록되고, 띄우는 명령이 출력과 JSON 에 함께 남는다 |
| Xcode 라이선스 동의 | `iosSimulatorArm64` 가 실패로 기록된다 |
@@ -67,7 +67,7 @@ python3 benchmarks/cost_table.py render --check # 문서가 최신 JSO
```
`--targets` 로 좁히는 것은 **타깃**을 좁히는 것이지 스위트를 좁히는 것이 아니다. 하네스는 어떤
-경우에도 `--tests` 를 쓰지 않는다 — 스위트를 좁히면 측정 영역 자체가 바뀌고, 이 저장소는 그것으로
+경우에도 `--tests` 를 쓰지 않는다, 스위트를 좁히면 측정 영역 자체가 바뀌고, 이 저장소는 그것으로
한 회차를 버렸다 (양 끝이 1300~1400 ns 로 올라갔다).
### 나눠 뜬 것을 하나의 표로 합치기
@@ -100,10 +100,10 @@ python3 benchmarks/cost_table.py render --in benchmarks/results/host.json,benchm
| `artApi26` | `:python-multiplatform:connectedDebugAndroidTest` | `build/outputs/androidTest-results/connected/debug/<기기>/logcat-*.txt` | **에뮬레이터** (`pmp_api26`) |
| `artApi36` | `:python-multiplatform:connectedDebugAndroidTest` | 〃 | **에뮬레이터** (`pmp_api36`) |
-기본 여섯 외에 `androidNativeArm64Api26` 이 하나 더 선언되어 있다 — `docs/design/upcall.md` 의 표가
+기본 여섯 외에 `androidNativeArm64Api26` 이 하나 더 선언되어 있다, `docs/design/upcall.md` 의 표가
androidNative 를 API 레벨별로 나눠 기록하기 때문이고, `--targets` 로 이름을 대면 돈다.
-**ART 만 결과를 읽는 경로가 다르다.** AGP 가 쓰는 JUnit XML 에는 `` 이 없다 —
+**ART 만 결과를 읽는 경로가 다르다.** AGP 가 쓰는 JUnit XML 에는 `` 이 없다,
251개 testcase 에 0개였다. 대신 AGP 는 테스트별 `logcat-<클래스>-<메서드>.txt` 를 옆에 남기고,
벤치마크의 `println` 은 거기 `I System.out:` 줄로 들어간다. 하네스는 그 줄에서 접두사를 떼고 읽는다.
@@ -113,7 +113,7 @@ androidNative 를 API 레벨별로 나눠 기록하기 때문이고, `--targets`
| 파일 | 소스셋 | 재는 것 | 파서가 읽는 형태 |
|---|---|---|---|
-| `overhead/Benchmark.kt` | `commonTest` | 하네스 자체 (`run`/`measure`) — 벤치마크가 아니다 | — |
+| `overhead/Benchmark.kt` | `commonTest` | 하네스 자체 (`run`/`measure`), 벤치마크가 아니다 | - |
| `overhead/BenchmarkTest.kt` | `commonTest` | 포인터 박싱, refcount 왕복, 문자열 마샬링(8/256/8192자), 정수 마샬링, `PyObject` 래퍼, `PyObject_GetAttrString` | `--- Benchmark Report ---` 표 |
| `native/ffi/UpcallBoundaryCostTest.kt` | `commonTest` | **업콜 1건**과 그것을 재는 데 필요한 다섯 기준선(같은 실행 안에서) | `--- Upcall boundary cost: <플랫폼> ---` 블록 |
| `ffi/upcall/GeneratedProxyCostTest.kt` | `commonTest` | 생성된 프록시 계층이 붙이는 비용 | `--- Generated proxy cost... ---` 블록 (JSON·TSV 에는 들어가나 아래 표에는 쓰지 않는다) |
@@ -126,14 +126,14 @@ androidNative 를 API 레벨별로 나눠 기록하기 때문이고, `--targets`
**다운콜은** `BenchmarkTest` 와 `UpcallBoundaryCostTest.measureDowncalls()` 에서,
**업콜은** `UpcallBoundaryCostTest` 와 `reflection/UpcallOverheadTest` 에서 나온다.
-아래 표 두 개는 앞의 둘만 쓴다 — 여섯 타깃 전부에서 같은 모양으로 도는 것이 그 둘이기 때문이다.
+아래 표 두 개는 앞의 둘만 쓴다, 여섯 타깃 전부에서 같은 모양으로 도는 것이 그 둘이기 때문이다.
파서는 라벨을 하드코딩하지 않는다. 위 블록 형식(`--- 제목: 플랫폼 ---`, 조건 줄, 들여쓴 `라벨 값 ns`)
을 따르는 것은 전부 JSON·TSV 로 들어간다. 표에 어떤 라벨을 쓸지는 렌더러가 고른다.
### 워밍업은 타깃의 성질이 아니라 **테스트 클래스**의 성질이다
-`Benchmark.printReport` 로 출력하는 클래스가 여럿이고, 각자 워밍업이 다르다 —
+`Benchmark.printReport` 로 출력하는 클래스가 여럿이고, 각자 워밍업이 다르다,
`BenchmarkTest` 는 100,000, `reflection/UpcallOverheadTest` 는 `Benchmark.run` 의 기본값 1,000,
`GeneratedProxyCostTest` 는 5,000. 그래서 하네스는 모든 행에 **출력한 테스트 클래스**를 함께
기록하고, 렌더러는 `BenchmarkTest` 행만 그 표에 넣는다. 나머지는 "출력한 테스트" 열을 달아 별도
@@ -152,7 +152,7 @@ androidNative 를 API 레벨별로 나눠 기록하기 때문이고, `--targets`
공백 구분자를 요구하는 파서는 **다운콜 표를 이루는 다섯 행을 통째로 놓친다.** 하네스는 값을 뒤쪽
단위(`ns`)에 앵커해 읽으므로 영향받지 않는다. 다만 사람이 읽는 출력으로서는 결함이고,
-`commonTest` 파일을 고치는 일이라 여기서는 손대지 않고 기록만 해 둔다 — 고칠 때는 `padEnd(54)` 를
+`commonTest` 파일을 고치는 일이라 여기서는 손대지 않고 기록만 해 둔다, 고칠 때는 `padEnd(54)` 를
가장 긴 라벨(58자)보다 크게 올리면 된다.
---
@@ -181,7 +181,7 @@ androidNative 를 API 레벨별로 나눠 기록하기 때문이고, `--targets`
| 파일 | 내용 |
|---|---|
-| `benchmarks/results/cost-table-<시각>.json` | 실행별 전체 기록 — 조건, 스위트 집계, 파싱된 모든 지표, 건너뛴 타깃과 그 이유 |
+| `benchmarks/results/cost-table-<시각>.json` | 실행별 전체 기록, 조건, 스위트 집계, 파싱된 모든 지표, 건너뛴 타깃과 그 이유 |
| `benchmarks/results/cost-table-<시각>.tsv` | 같은 것의 long-format. `target run status source section label value unit iterations platform commit contaminated` |
| `benchmarks/results/logs/<타깃>-run-<시각>.log` | 그 실행의 Gradle 출력 전체 |
| 이 문서의 마커 사이 | 위 JSON 에서 렌더링된 표 |
@@ -192,7 +192,7 @@ CPU·코어 수·OS, **시작/종료 load average 와 ceiling**, 타깃당 실
`WARMUP_BULK`, **기기·시뮬레이터 식별자**(AVD 이름, 시리얼, API 레벨, ABI / 부팅된 시뮬레이터),
플랫폼이 스스로 보고한 이름, 스위트 통과·실패·스킵 수, 벽시계 시간, 그리고 태스크 종료 코드.
-`BenchmarkTest` 의 워밍업만 소스에서 읽는다 — 그 테스트는 자기 워밍업을 출력하지 않기 때문이다.
+`BenchmarkTest` 의 워밍업만 소스에서 읽는다, 그 테스트는 자기 워밍업을 출력하지 않기 때문이다.
(`UpcallBoundaryCostTest` 는 출력한다.) 워밍업 없는 숫자가 이 표를 한 번 무효화했으므로 추정하지
않고, 읽지 못하면 그 사실을 기록한다.
@@ -206,17 +206,17 @@ _Generated by `benchmarks/cost_table.py` from `cut-hostless.json, cut-api36.json
Every number below is a function of these. A cost table without them is a record of whatever else was running.
-- **commit** `958c0082b294` — Merge branch 'work/objecttype' into develop
+- **commit** `958c0082b294`, Merge branch 'work/objecttype' into develop
- **branch** `develop`, worktree `/Volumes/macMini/thisisthepy/PythonMultiplatform`
-- **captured** 2026-08-14T16:31:30+09:00 — the first of several cuts; each one's own time is in the source table below
+- **captured** 2026-08-14T16:31:30+09:00, the first of several cuts; each one's own time is in the source table below
- **host** Apple M1, 8 cores, Darwin 25.5.0 arm64
-- **load average** [1.97, 2.29, 5.92] at start, [2.47, 3.28, 5.19] at end (ceiling 4.0, guard `ok`) — of the first cut only; per-cut figures below
-- **runs per target** 3 — ranges below are min–max over them
+- **load average** [1.97, 2.29, 5.92] at start, [2.47, 3.28, 5.19] at end (ceiling 4.0, guard `ok`), of the first cut only; per-cut figures below
+- **runs per target** 3, ranges below are min–max over them
- **`UpcallBoundaryCostTest`** warmup and iteration counts are self-reported per target in the table below (the test prints them)
-- **`BenchmarkTest`** warmup, read from the source at this commit: `WARMUP` = 100000, `WARMUP_BULK` = 10000 (the ≥4096-char string rows use the second — see that file's own caveat)
+- **`BenchmarkTest`** warmup, read from the source at this commit: `WARMUP` = 100000, `WARMUP_BULK` = 10000 (the ≥4096-char string rows use the second, see that file's own caveat)
- **suite scope** full task, never `--tests`-filtered: narrowing the suite changes what is being measured
-This table is merged from several cuts — which target came from which:
+This table is merged from several cuts, which target came from which:
| cut | commit | captured | load at start | contaminated | targets |
|---|---|---|---|---|---|
@@ -226,9 +226,9 @@ This table is merged from several cuts — which target came from which:
| target | task | status | device / simulator | platform, self-reported | tests/fail/skip | wall s | loop counts |
|---|---|---|---|---|---|---|---|
-| `desktop` | `:python-multiplatform:desktopTest` | ok | — | MacOS 26.5.1 (aarch64) / JVM 21.0.12 | 396/0/1 | 30.0/12.7/12.5 | iterations per loop: 10000, warmup: 100000 |
-| `iosSimulatorArm64` | `:python-multiplatform:iosSimulatorArm64Test` | ok | — | iOS 26.2 (SDK 260200, arm64) / Native | 350/0/0 | 66.1/36.7/36.5 | iterations per loop: 10000, warmup: 100000 |
-| `wasmJs` | `:python-multiplatform:wasmJsNodeTest` | ok | — | Web emscripten (wasm32) / Wasm Kotlin/Wasm | 364/0/0 | 21.3/5.9/5.7 | iterations per loop: 10000, warmup: 100000 |
+| `desktop` | `:python-multiplatform:desktopTest` | ok | - | MacOS 26.5.1 (aarch64) / JVM 21.0.12 | 396/0/1 | 30.0/12.7/12.5 | iterations per loop: 10000, warmup: 100000 |
+| `iosSimulatorArm64` | `:python-multiplatform:iosSimulatorArm64Test` | ok | - | iOS 26.2 (SDK 260200, arm64) / Native | 350/0/0 | 66.1/36.7/36.5 | iterations per loop: 10000, warmup: 100000 |
+| `wasmJs` | `:python-multiplatform:wasmJsNodeTest` | ok | - | Web emscripten (wasm32) / Wasm Kotlin/Wasm | 364/0/0 | 21.3/5.9/5.7 | iterations per loop: 10000, warmup: 100000 |
| `androidNativeArm64` | `:python-multiplatform:androidNativeArm64Test` | ok | `pmp_api36` (emulator-5554, API 36, arm64-v8a) | Android 16 (SDK 36, aarch64) / Native | 353/0/0 | 76.4/46.7/46.3 | iterations per loop: 10000, warmup: 100000 |
| `artApi26` | `:python-multiplatform:connectedDebugAndroidTest` | failed | `pmp_api26` (emulator-5556, API 26, arm64-v8a) | Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 | 381/3/0 | 153.8/149.1/148.3 | iterations per loop: 10000, warmup: 100000 |
| `artApi36` | `:python-multiplatform:connectedDebugAndroidTest` | failed | `pmp_api36` (emulator-5554, API 36, arm64-v8a) | Android 16 (SDK 36, aarch64) / ART VM 0.9 | 381/2/0 | 262.9/148.7/146.7 | iterations per loop: 10000, warmup: 100000 |
@@ -246,7 +246,7 @@ This table is merged from several cuts — which target came from which:
| `artApi26` | 1196.52–1272.28 ns | 1253.41–1295.79 ns | 1800.86–1863.97 ns | 0.92–1.01x | 1.05–1.18x |
| `artApi36` | 995.61–1154.10 ns | 804.27–838.60 ns | 1100.87–1167.17 ns | 1.18–1.43x | 1.10–1.29x |
-Controls, from the same block — these are what say whether a row moved because the boundary moved or because the whole host did:
+Controls, from the same block, these are what say whether a row moved because the boundary moved or because the whole host did:
| target | empty Python loop | pure-Python callee | GIL-held trampoline |
|---|---|---|---|
@@ -306,22 +306,22 @@ These also come through `Benchmark.printReport`, and they are **not comparable w
| `UpcallOverheadTest` | `arrayOf(1L) allocation + boxing` | 1,000,000 | 6.44–6.64 ns | 67.25–68.87 ns | 8.66–8.98 ns | 76.93–77.80 ns | 9.31–10.23 ns | 18.86–20.10 ns |
| `UpcallOverheadTest` | `invoke with a pre-built args array` | 1,000,000 | 8.64–8.91 ns | 173.09–179.16 ns | 23.07–24.06 ns | 240.92–251.28 ns | 34.76–37.70 ns | 60.44–61.46 ns |
| `UpcallOverheadTest` | `direct Kotlin call (floor)` | 1,000,000 | 2.26–2.95 ns | 15.02–15.39 ns | 4.45–4.63 ns | 15.15–15.49 ns | 4.14–4.21 ns | 13.79–14.69 ns |
-| `WasmFinalizationTest` | `cleaner: registerCleaner + close (the whole hook)` | 50,000 | — | — | 77.70–90.21 ns | — | — | — |
-| `WasmFinalizationTest` | `cleaner: toJsReference() alone (the externref crossing)` | 50,000 | — | — | 32.70–35.38 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `marshal: malloc+encodeToByteArray+copy+free (old)` | 200,000 | — | — | 76.95–80.44 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `marshal: malloc+free only` | 200,000 | — | — | 13.41–14.15 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `marshal: encodeToByteArray only` | 200,000 | — | — | 50.10–52.39 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `marshal: Wasm.scratchUtf8` | 200,000 | — | — | 28.44–29.48 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `marshal: Wasm.internedUtf8 (hit)` | 200,000 | — | — | 20.71–21.71 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `marshal 54-char name: Wasm.scratchUtf8` | 200,000 | — | — | 145.61–152.15 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `marshal 54-char name: Wasm.internedUtf8 (hit)` | 200,000 | — | — | 24.73–25.88 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `getattr: C string allocated per call (old)` | 50,000 | — | — | 241.35–259.20 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `getattr: interned C string (new)` | 50,000 | — | — | 186.85–200.06 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `getattr via actual: C string per call (old)` | 50,000 | — | — | 222.20–239.95 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `getattr via actual: interned (new)` | 50,000 | — | — | 191.19–205.26 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `getattr: scratch C string` | 50,000 | — | — | 192.62–204.75 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `marshal 4000 B: malloc+encode+copy+free (old)` | 20,000 | — | — | 14285.62–14866.91 ns | — | — | — |
-| `WasmMarshallingOverheadTest` | `marshal 4000 B: Wasm.scratchUtf8` | 20,000 | — | — | 9420.60–9830.11 ns | — | — | — |
+| `WasmFinalizationTest` | `cleaner: registerCleaner + close (the whole hook)` | 50,000 | - | - | 77.70–90.21 ns | - | - | - |
+| `WasmFinalizationTest` | `cleaner: toJsReference() alone (the externref crossing)` | 50,000 | - | - | 32.70–35.38 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `marshal: malloc+encodeToByteArray+copy+free (old)` | 200,000 | - | - | 76.95–80.44 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `marshal: malloc+free only` | 200,000 | - | - | 13.41–14.15 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `marshal: encodeToByteArray only` | 200,000 | - | - | 50.10–52.39 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `marshal: Wasm.scratchUtf8` | 200,000 | - | - | 28.44–29.48 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `marshal: Wasm.internedUtf8 (hit)` | 200,000 | - | - | 20.71–21.71 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `marshal 54-char name: Wasm.scratchUtf8` | 200,000 | - | - | 145.61–152.15 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `marshal 54-char name: Wasm.internedUtf8 (hit)` | 200,000 | - | - | 24.73–25.88 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `getattr: C string allocated per call (old)` | 50,000 | - | - | 241.35–259.20 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `getattr: interned C string (new)` | 50,000 | - | - | 186.85–200.06 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `getattr via actual: C string per call (old)` | 50,000 | - | - | 222.20–239.95 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `getattr via actual: interned (new)` | 50,000 | - | - | 191.19–205.26 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `getattr: scratch C string` | 50,000 | - | - | 192.62–204.75 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `marshal 4000 B: malloc+encode+copy+free (old)` | 20,000 | - | - | 14285.62–14866.91 ns | - | - | - |
+| `WasmMarshallingOverheadTest` | `marshal 4000 B: Wasm.scratchUtf8` | 20,000 | - | - | 9420.60–9830.11 ns | - | - | - |
diff --git a/docs/investigations/fork-desktop-paths-macos.md b/docs/investigations/fork-desktop-paths-macos.md
new file mode 100644
index 000000000..bbdc507c1
--- /dev/null
+++ b/docs/investigations/fork-desktop-paths-macos.md
@@ -0,0 +1,129 @@
+# The fork's desktop paths on macOS (issue #191, investigation #242)
+
+What python-multiplatform needs before a pythonx-compose app runs on the two desktop paths
+`compose-multiplatform-core-extended` and `compose-multiplatform-extended` add: a GraalVM native
+image and a Kotlin/Native executable, macOS arm64 first. Read on 2026-10-17 from the fork's
+`origin/extended` branches (core `61906995435`, plugin fork `fca1473a`), `~/.m2/repository`, and
+this repository's `develop` (`af4debac`). Nothing here was built; every fact cites where it was read.
+
+## 1. What the fork has on macOS
+
+| | GraalVM native image | Kotlin/Native |
+|---|---|---|
+| Fork's claim | `extended/README.md` (core, line 12): "macOS works on both: the Kotlin/Native window (AppKit and Metal) and the GraalVM native-image window." | same line |
+| Window layer | `extended/window/graalvm/graalvm-macos` (JVM lib, AppKit through `@CFunction`, `upcalls.txt` table, no reflection) | `extended/window/native/macos` (`macosArm64`: AppKit, Metal, `MetalSurface.kt`) |
+| Compose host | **none for this path yet**: `extended/window/scene` (JVM and `macosArm64`) hosts a Compose scene through `runComposeWindow(platform, config, surfaceFor, content)` (`scene/src/.../ComposeWindow.kt:285`), but `graalvm-macos` provides no `WindowSurface` to draw into | `scene` with `native/macos`'s `MetalSurface` |
+| Measured with Compose content | `extended/native-image/hello` (plugin fork): Material 3 in an **AWT** window (`windowing = Awt`), 82 MB, "draws the same content through Skia offscreen into a PNG byte for byte the one the JVM run draws" (`extended/native-image/README.md`, 2026-10-02) | no Gradle demo in the fork; compose-rust's Amper renderer (`darkpyonix/compose-rust/renderer/macos`) consumes `native-macos` |
+| AWT-free demo | `extended/native-image/hello-awtfree-link`: opens, pumps and presents an AppKit window; **draws no Compose** | none |
+| Build | the plugin fork's `org.jetbrains.compose` `1.11.1-extended-dev`: `compose.desktop.application { nativeImage { graalvmHome; skikoStaticDirectory } }`, `packageNativeImage`, `runNativeImageAgent`; skiko as a static archive (`extended/skiko/build-skiko-static-jvm.sh`) | `extended/gradle-plugin-kn`: `createDistributableNative...`, `runNative...`, `packageKotlinNative` |
+
+**Published to mavenLocal** (JetBrains coordinates kept, as #191 says):
+
+- `org.jetbrains.compose:ui*`, `foundation*`, `ui-text*` at `1.11.1001` (2026-10-17), including the
+ `macosarm64` klibs of `ui` and `foundation`. No `material3` macOS klib.
+- `org.jetbrains.compose:compose-gradle-plugin:1.11.1-extended-dev`.
+- `org.thisisthepy.compose.window:*:0.1.0`: `common`, `graalvm-macos`, `graalvm-linux`, `native-macos`. Not
+ `scene`, and not `native-linux`.
+- `org.jetbrains.skiko:skiko-awt:0.144.6.1`.
+- No publish script producing `1.11.1001` was found in either fork repository; the "Extended window
+ publish" workflow and compose-rust's `scripts/publish-window.sh` publish the window modules.
+
+## 2. What this repository has
+
+- **Targets.** `python-multiplatform` declares `jvm("desktop")`, Android, iOS, androidNative and wasm;
+ no `macosArm64`, `linuxX64` or `mingwX64` (`python-multiplatform/build.gradle.kts`; the plugin's
+ `hostDesktopPlatform` knows macOS only as a JVM host). The sample has the same set.
+- **A native image already runs Python** (`docs/platforms/graal-native-image-verification.md`,
+ 2026-08-17, `4687070a`): `:sample:nativeCompile` (an `Exec` of Liberica NIK 25.0.4's `native-image`,
+ `--no-fallback --enable-native-access=ALL-UNNAMED`) builds a headless `NativeImageMainKt`; 28
+ downcalls and 5 upcalls registered, `UPCALL_OK`/`PROXY_OK`, no `libjvm`, `libpython` `dlopen`ed.
+ It is **headless on purpose**: the sample's `application {}` needs AWT. Not automated, not re-run.
+- **The upcall table on a native image.** Desktop upcalls are FFM (`Panama.kt`), registered by the
+ generated `META-INF/native-image/org.thisisthepy/python-multiplatform/reachability-metadata.json`
+ (`generateDesktopReachabilityMetadata`) plus `native-image/reachability-metadata.base.json`. That
+ metadata ships in the jar, so any native-image build that has the jar on its classpath picks it up.
+ (The verification note says no reflection registration is needed, while its own log shows 1,822
+ types registered and the base file registers `UpcallTarget`; the note should be corrected.)
+- **PYTHONHOME.** `stagePythonHome` sets `PYTHONHOME` for `JavaExec`/`Test` tasks and copies the prefix
+ into Compose's `prepare*AppResources`; the native demo is given `PYTHONHOME` by its run task. A
+ native image has no `java.home` and no packaged-resources directory, so nothing stages a prefix
+ **beside the executable** or finds it from there at start.
+- **The notebook E2E** (`python-multiplatform-compose/fixtures/notebook-e2e`) needs no window: it
+ draws `PythonAppView` into an offscreen `ImageComposeScene` (`NotebookHost.kt:132`) and compares
+ pixels. It is a JVM `desktopTest` suite with `compose.desktop.currentOs` (Skia natives) and
+ `java.awt.headless=true`; no `java.awt`/`javax.swing` import.
+
+## 3. What each path needs from this side
+
+### GraalVM native image
+
+1. **A desktop app built by the plugin fork's `packageNativeImage`** rather than the sample's hand-made
+ `Exec`: the fork already solves Compose's own native-image configuration and static skiko. The
+ generated upcall table and FFM registrations come along in the jar's `META-INF/native-image`, so
+ **the upcall table carries over unchanged** (build-time table, no reflection: AGENTS.md §12.3, §13).
+ To check: that the fork's plugin passes `--enable-native-access=ALL-UNNAMED` (or lets an app add it)
+ and does not set `--no-fallback`-incompatible options that hide a missing FFM registration.
+2. **PYTHONHOME beside the executable.** A task that stages the prefix and the installed wheels next to
+ `build/compose/native-image/main/`, and a runtime lookup (`ProcessHandle`'s executable path, a
+ sibling `python/` directory) when `PYTHONHOME` is unset, like `IosPythonHome` does for an app bundle.
+ **Staging does not carry over as is**: today's paths are a Gradle task's environment and the JVM
+ packaging's resources directory.
+3. **The walker over the fork's jars.** Same public API, so the walk must produce the same bindings;
+ guarded by the stub diff of pythonx-compose #54.
+4. **Selecting the path.** A Gradle plugin option (upstream Compose by default, the fork's native image
+ as a choice), #191's first bullet.
+
+### Kotlin/Native
+
+1. **An interpreter target**: `macosArm64` in `python-multiplatform` (cinterop against the macOS
+ python-build-standalone prefix the desktop JVM already downloads, a static or shipped
+ `libpython`), with the native `HandleTable`/trampoline path iOS and androidNative already use.
+ This is new work, not a switch.
+2. **The klib walker** (B-2) over the fork's `macosarm64` klibs: since #234/#237 it carries handles and
+ interface members, but not constructors, properties or composables, and Compose's surface is mostly
+ composables and extensions; the K/N path needs the walker to reach about where the jar walker is.
+3. **PYTHONHOME** beside the executable, as for the native image.
+4. `material3` for `macosArm64`: no fork klib is published (the fork says only `ui` and `foundation`
+ differ from JetBrains; JetBrains' own `material3` macOS klib would have to resolve beside them).
+
+## 4. First milestone: the notebook E2E in a macOS GraalVM native image
+
+The E2E draws offscreen, so the milestone needs **no window** and none of the AWT-free pieces:
+
+1. A `notebook-e2e` native-image entry point (a `main` running the existing scenarios and printing a
+ pass/fail line per scenario, the way the fork's `hello` self-checks through `HELLO_SELF_CHECK`),
+ because a JUnit `desktopTest` does not run inside an image.
+2. Built with the plugin fork's `packageNativeImage` (`windowing = Awt`, which is what the fork measured;
+ the scenes are offscreen either way), `--enable-native-access=ALL-UNNAMED`, the fork's static skiko.
+3. PYTHONHOME and the pythonx-compose wheel staged beside the executable (§3, GraalVM 2).
+4. Pass: the same 20 scenarios as the JVM run (pixel comparisons included), the binary run alone from
+ an empty directory with `JAVA_HOME` unset, and `otool -L` showing no `libjvm`. Then a CI job on a
+ macOS runner, as `notebook-e2e` has on the JVM (#213).
+
+Milestone 2 is the same app in the AWT-free window, once the fork has the pieces in §5. Kotlin/Native
+follows the interpreter target (§3, K/N 1).
+
+## 5. Requests for the fork (through the darkpyonix lead)
+
+1. **Publish `org.thisisthepy.compose.window:scene`** (its `module.yaml` already enables publishing;
+ it is missing from mavenLocal and from the "Extended window publish" workflow's output).
+2. **A `WindowSurface` for `graalvm-macos`** (Metal or GL into a Skia surface, as `native/macos`'s
+ `MetalSurface`), so `runComposeWindow` can host Compose content on the GraalVM path; and a demo that
+ draws Compose through it (`hello-awtfree-link` draws nothing today).
+3. **A documented, reproducible publish of `1.11.1001`** (no script producing that version was found)
+ and one version line for the Compose libraries, the window modules and the Gradle plugin, so a
+ consumer pins one set.
+4. **The native-image flags an app can add**: confirm that `packageNativeImage` lets an app pass
+ `--enable-native-access=ALL-UNNAMED` and merges a library's `META-INF/native-image` metadata (FFM
+ `foreign` registrations included).
+5. **A hook to stage extra files beside the image** (a prefix directory), or confirmation that a
+ consumer task after `packageNativeImage` is the supported way.
+6. For Kotlin/Native: whether `material3` for `macosArm64` is expected to come from JetBrains, and a
+ Gradle (not Amper-only) example of `scene` plus `native-macos` hosting Compose content.
+
+## 6. Open questions
+
+- Whether CPython's own native-image behaviour holds with the fork's Compose in the same image (one
+ process, Skia and libpython both `dlopen`ed or linked); not tried.
+- Where the native-image E2E runs in CI: GitHub's macOS runners have no GraalVM by default, and the
+ fork's static skiko must be built or cached.
diff --git a/docs/investigations/gc-scheduling-investigation.md b/docs/investigations/gc-scheduling-investigation.md
index 31b9b373a..5be003937 100644
--- a/docs/investigations/gc-scheduling-investigation.md
+++ b/docs/investigations/gc-scheduling-investigation.md
@@ -20,7 +20,7 @@ Conclusions (current):
`ob_tid` instead of a refcount (header layout differs), and heap types are deferred-ref-counted,
so the type's count is observable only after a collection. An eval-loop checkpoint provably does
not fix it; two `PyGC_Collect()` calls in the test do. (§3)
-- **The checkpoint fixes §1 on the GIL build; free-threaded it often reclaims nothing**, because
+- **The checkpoint fixes §1 on the GIL build; free-threaded it often reclaims nothing**: because
`_Py_RunGC` re-asks `gc_should_collect`, which gates generation 0 on growth of the **whole
process's** memory footprint (the JVM's). Python-side allocation cannot move it. Not caused by
per-call `PyGILState_Ensure/Release` scopes (tested, rejected). (§8, §8d)
@@ -116,7 +116,7 @@ one `_Py_HandlePending` (BRC merge, QSBR sweep, scheduled collection if any). No
`Python3.autoDrainInterval` outermost scopes, and only if `ReleaseCounter.released` has moved.
`reachEvalCheckpointHoldingGIL` declines while `PyErr_Occurred()` is non-null. It is not taken from
the cleaner thread (the queue belongs to the owning thread; running Python there is the ROADMAP §1
-deadlock) — pinned by `EvalCheckpointTest.testCleanerActivityAloneTakesNoCheckpoint`.
+deadlock), pinned by `EvalCheckpointTest.testCleanerActivityAloneTakesNoCheckpoint`.
`PyGC_Collect` is the one Stable ABI call that reclaims on behalf of other threads (it stops the
world and merges each thread state), at heap-walk cost, and the only thing that materialises a
deferred reference.
@@ -128,7 +128,7 @@ The test asserts the *ordering*, not the numbers.
| | ns/op | |
|---|---:|---|
-| `withGIL { }` — attach and detach | 170.51 | floor |
+| `withGIL { }`, attach and detach | 170.51 | floor |
| `Python3.drainPendingReleases()` | 302.70 | ~132 ns over floor |
| `withGIL { Py_MakePendingCalls() }` | 197.34 | cheap, does not merge |
| `Python3.exec("pass")` | 7 037.04 | 23× the checkpoint |
@@ -211,14 +211,14 @@ Experiments (all in `FreeThreadedGCGateTest`; residue of 20,000 and collections
| experiment | free-threaded | GIL |
|---|---|---|
-| `gc.get_count()[0]` shape across workload | sawtooth `[1728, 1488, 976, 464, 2464, 1488, 976, 464, 2464, 1488]`, 0 collections, residue 20,000 (memory gate declines) | — |
-| threshold `(2000,10,10)` | 20,000 — 0 collections | 1,968 — 9 collections |
-| `(2000,0,0)` (`old[0].threshold == 0` short-circuits `:2126`) | **1,318 — 8 collections** | 1,966 — 5 collections |
-| `(2000,10,10)` again (A/B/A, one JVM) | 20,000 — 0 | 1,970 — 9 |
+| `gc.get_count()[0]` shape across workload | sawtooth `[1728, 1488, 976, 464, 2464, 1488, 976, 464, 2464, 1488]`, 0 collections, residue 20,000 (memory gate declines) | - |
+| threshold `(2000,10,10)` | 20,000, 0 collections | 1,968, 9 collections |
+| `(2000,0,0)` (`old[0].threshold == 0` short-circuits `:2126`) | **1,318, 8 collections** | 1,966, 5 collections |
+| `(2000,10,10)` again (A/B/A, one JVM) | 20,000, 0 | 1,970, 9 |
| `threshold0` 2000 / 500 / 100 (bar 80,000 / 20,000 / 4,000) | 20,000 / 20,000 / **1,466 (3 collections)** | scales smoothly: 9 / 36 / 172 collections |
-| baseline | 20,000 — 0 | 1,972 — 9 |
-| 400 MB ballast faulted in midway | **9,754 — 1 collection** | 1,988 — 9 |
-| ballast dropped | 20,000 — 0 | 1,968 — 9 |
+| baseline | 20,000, 0 | 1,972, 9 |
+| 400 MB ballast faulted in midway | **9,754, 1 collection** | 1,988, 9 |
+| ballast dropped | 20,000, 0 | 1,968, 9 |
The ballast result reproduced identically in three consecutive JVMs: JVM memory growth, and nothing
else, collected Python cycles. Probe pitfall recorded: sampling `gc.get_count()` every 500
diff --git a/docs/investigations/jni-call-convention-audit.md b/docs/investigations/jni-call-convention-audit.md
index 2c2e6bde6..5d50239d6 100644
--- a/docs/investigations/jni-call-convention-audit.md
+++ b/docs/investigations/jni-call-convention-audit.md
@@ -3,7 +3,7 @@
Android binds CPython through `RegisterNatives`. `@CriticalNative` and `@FastNative` both stop the
ART collector for the duration of the call, and `@CriticalNative` receives no `JNIEnv`. So neither
may be used for a function that can re-enter the runtime, which means any function that can run
-Python (`__del__`, `__getattr__`, a module's top-level code) — with upcalls live, that Python can
+Python (`__del__`, `__getattr__`, a module's top-level code), with upcalls live, that Python can
be Kotlin.
## Conclusion
@@ -45,13 +45,13 @@ All eight are leaves on the success path; all can allocate a GC-tracked exceptio
path. That second column is conditional and second-order (the exception instance is GC-tracked, so
it can cross the collection threshold, so the collector can run a `__del__`); it is not a reason
to demote them, they are the measured hot path. It is why "provably a leaf" is not the promotion
-test — the honest test is *leaf on the success path, and measured worth it*.
+test, the honest test is *leaf on the success path, and measured worth it*.
| call site | success path | failure path |
|---|---|---|
-| `Py_IsInitialized` | reads a global | — |
-| `Py_GetVersion` | static `const char*` | — |
-| `PyErr_Occurred` | reads thread state, borrowed | — |
+| `Py_IsInitialized` | reads a global | - |
+| `Py_GetVersion` | static `const char*` | - |
+| `PyErr_Occurred` | reads thread state, borrowed | - |
| `PyLong_FromLongLong` | non-GC allocation | preallocated `MemoryError` |
| `PyUnicode_FromString` | non-GC allocation | `UnicodeDecodeError` |
| `PyUnicode_AsUTF8` | caches UTF-8 form | `UnicodeEncodeError` on lone surrogates |
@@ -62,7 +62,7 @@ test — the honest test is *leaf on the success path, and measured worth it*.
Change in per-call cost, net of the Kotlin floor, from moving a function off ordinary JNI
(source: `docs/design/downcall-design.md`, `androidMain/README.md`; ART emulators / device per those
-documents — conditions are in them, not re-measured here):
+documents, conditions are in them, not re-measured here):
| API | ordinary → `@FastNative` (ns) | ordinary → `@CriticalNative` (ns) |
|---|---|---|
@@ -77,14 +77,14 @@ documents — conditions are in them, not re-measured here):
`list → LongArray`, 1000 elements, at 50065.89 ns on API 36 hardware (50 ns/element) against a
`@CriticalNative` net cost of 44.05 ns there (an upper bound on the convention's share, since the
50 ns also covers the loop and pointer conversion). Predicted after the twin: 44.05 → 3.55 ns per
-element on API 36 — a prediction, not a recorded measurement.
+element on API 36, a prediction, not a recorded measurement.
## The three rejected promotions that were not leaves
| | audit said | actually |
|---|---|---|
| `PyGILState_Release` | "unlocks mutex (non-blocking)" | the release that drops the counter to zero deletes the thread state; clearing it decrefs its dict and exception state, so `__del__` runs |
-| `PyThreadState_GetDict` | "reads struct pointer" | allocates the thread dict with `PyDict_New` on first call per thread — a GC-tracked allocation |
+| `PyThreadState_GetDict` | "reads struct pointer" | allocates the thread dict with `PyDict_New` on first call per thread, a GC-tracked allocation |
| `PyEval_InitThreads` | "initializes locking" | a no-op kept for the stable ABI; promoting it buys only the convention delta |
The genuine leaves among the eight are `Py_IncRef`, `Py_NewRef`, `Py_XNewRef`,
diff --git a/docs/investigations/typedpython-kernel-backend.md b/docs/investigations/typedpython-kernel-backend.md
new file mode 100644
index 000000000..b3fc7f75d
--- /dev/null
+++ b/docs/investigations/typedpython-kernel-backend.md
@@ -0,0 +1,206 @@
+# TypedPython 커널 백엔드 조사 (2026-10-04)
+
+표기: **[V]** = verified (출처 URL 병기, 이번 조사에서 직접 fetch/search 결과로 확인). **[I]** = unverified / my inference.
+출처 중 WebSearch 요약은 검색 엔진 요약문이므로, 숫자나 버전은 채택 전에 원문 재확인이 필요하다.
+
+결론의 반영: `docs/design/typedpython.md` §5 (2026-10-04 결정). IR 선택은 후속 조사 [`typedpython-kernel-ir.md`](typedpython-kernel-ir.md) 가 다듬었다.
+
+## 0. 조사 당시의 계획 (읽은 문서, 이후 §5 가 바뀌었다)
+
+- typedpython.md §2.1: 유효한 파이썬만. 커널도 수정 없이 CPython 에서 실행돼 참조 구현이 된다.
+- §5: iOS 는 런타임 코드 생성 금지이므로 AOT 가 하드 제약. 경로는 `@kernel` 서브셋 -> 자체 IR -> SPIR-V -> (Vulkan | SPIRV-Cross->MSL | naga->WGSL), PTX 별도. 착지점은 torchnative 의 `torch.library` 커스텀 op. SIMT 먼저, 타일은 나중.
+- torchnative DESIGN.md: Triton 기각(JIT), 후보는 손으로 쓴 Metal/NEON, CubeCL("성숙도 확인 필요"), AOT Triton. 안드로이드 GPU 는 "Vulkan/wgpu 백엔드 도입 또는 작성" 으로 비어 있다.
+
+## 1. TileLang (tile-ai/tilelang)
+
+- 백엔드 [V github.com/tile-ai/tilelang]: CUDA(주), ROCm/HIP, Ascend, Apple Metal 은 supported. LLVM CPU, CuTe DSL, WebGPU 는 README 상 experimental. v0.1.13 (2026-08-03 출시라는 검색 요약), 문서 사이트는 0.1.14.
+- 불일치 [V]: 공식 docs(tilelang.com/get_started/targets.html)는 Metal, LLVM, WebGPU, C 를 모두 "Fully supported" 로 적지만 README 는 WebGPU 를 experimental 로 적는다. 문서 쪽이 낙관적이라고 보는 게 안전하다 [I].
+- Vulkan/SPIR-V: 타깃 문서에 항목 없음. 로드맵 이슈 #56 (2025-01-26 개설) 에 "planned" 로만 있고 진행 표시 없음 [V github.com/tile-ai/tilelang/issues/56]. 즉 Android Vulkan 경로는 **없다** (Mali/Adreno 불가) [V 부재 확인, 완료 여부는 이슈 본문 한계로 100% 는 아님].
+- 라이선스: MIT [V raw.githubusercontent.com/tile-ai/tilelang/main/LICENSE]. 2024-12~2025-03 기간 Microsoft 협업 부가 조항 언급이 있다. TVM 은 Apache-2.0 [I, 기억 기반].
+- AOT: README 가 "AOT export" 를 기능으로 언급 [V]. 단 무엇이 export 되는지(소스 vs 바이너리, iOS 에 싣는 형태)는 확인 못함. 타깃 문서에 `C` 타깃(소스만 출력)이 있어 소스 생성은 가능 [V].
+- 순수 파이썬 CPU 실행: LLVM CPU 타깃이 있으나 "빌드 시 USE_LLVM=ON, LLVM 15+" 필요, 검색 요약상 experimental [V 검색요약]. 파이썬 인터프리터가 커널 본문을 그대로 돌리는 참조 실행(우리 §2.1 요건)은 TileLang 의 `T.prim_func` 가 TVM script 빌더라서 **그대로는 불가** [I: 커널이 파이썬 AST 를 직접 실행하지 않고 DSL 빌더로 구성된다는 구조적 추론, 실행해 보지 않음].
+- TVM 의 단점(빌드 복잡도, 바이너리 크기, 컴파일 시간, 포크 변경, free-threading 지원): **이번 조사에서 확인하지 못함**. README 가 TVM 을 핵심 의존성으로 명시한다는 것만 [V]. 나머지는 [I] 이며 측정이 필요하다 (§7).
+- 우리 요건과 맞는 점: 타일 추상화, 텐서 코어/simdgroup 접근이 1급. 어긋나는 점: 새 DSL 문법(`T.` API), "유효한 파이썬만" 은 만족하나 CPython 에서 의미 있게 실행되지는 않음, iOS/Android Vulkan 부재.
+
+## 2. wgpu + naga
+
+### 2.1 현재 상태
+- wgpu 문서 v30.0.1 [V docs.rs/wgpu/latest/wgpu/struct.Features.html]:
+ - SUBGROUP: Vulkan, DX12, Metal 에서 지원, "WebGPU 표준으로 곧 이동 예상".
+ - SHADER_F16: Vulkan, Metal, DX12, WebGPU 모두.
+ - EXPERIMENTAL_COOPERATIVE_MATRIX: 문서상 8x8 f32 로 제한, **native 전용** (Metal, Vulkan).
+ - PASSTHROUGH_SHADERS: Vulkan, DX12, Metal, WebGPU 에서 셰이더를 파싱 없이 직접 제출 가능. 즉 우리가 SPIR-V/MSL 을 직접 만들어 wgpu 런타임에만 얹는 길이 열려 있다.
+ - 해당 문서에 PUSH_CONSTANTS, int8 dot 항목은 보이지 않음 (이 문서 요약 기준. 부재의 증거가 아님) [V 한계 있음].
+- 협력 행렬 PR #8251: 2025-12-22 merge, Vulkan(KHR_cooperative_matrix)과 Metal(simdgroup) 대상, 후속 PR 로 정수(i32/u32/i8/u8)와 비정방 타일 [V github.com/gfx-rs/wgpu/pull/8251, /pull/9490, /pull/10414]. 문서의 "8x8 f32 만" 과 후속 PR 이 어긋나므로 어느 릴리스에 어디까지 들어갔는지는 미확정.
+- WebGPU 표준: subgroups 는 Chrome 134 에서 출시 [V developer.chrome.com/blog/new-in-webgpu-134], Chrome 144 에서 subgroup_id/num_subgroups 추가 [V 검색요약]. subgroup matrix 제안은 2026 에도 활발히 수정 중(2026-09 D3D12 반영 PR) 이므로 **표준 미확정, 브라우저 출시 전** [V github.com/gpuweb/gpuweb/issues/4195, PR #10964; 출시 여부는 확인 못함].
+- naga: subgroupElect 외 subgroup 연산 지원 [V github.com/gfx-rs/wgpu/issues/7396, 이슈 시점 기준].
+
+### 2.2 성능 증거
+- Burn 블로그 요약: Vulkan(SPIR-V) 컴파일러가 WGSL 보다 matmul 에서 크게 낫다. 이유는 SPIR-V 쪽에서 텐서 코어와 f16 을 쓸 수 있기 때문 [V 검색요약, burn.dev/blog/sota-multiplatform-matmul]. 같은 맥락에서 "CUDA/Vulkan 이 WebGPU 보다 prefill 최대 10x, decode 2.5x 빠르고 Metal 이 WebGPU 보다 prefill 2x 이상" 이라는 문장이 나왔으나, 어느 모델/논문의 수치인지 출처 귀속이 불분명하다 (Llamas on the Web arXiv 2605.20706 로 추정되나 PDF 본문에서 수치를 직접 확인하지 못함) [V 검색요약, 귀속 미확인].
+- Meganeura (arXiv 2608.01563): wgpu 를 거치지 않고 Vulkan/Metal 로 직접 컴파일하며, wgpu 층의 오버헤드를 이유로 든다고 요약됨 [V 요약, 수치 미확인].
+- 결론 [I]: ML 커널에서 "wgpu 의 WGSL 경로" 는 협력 행렬/int8/f16 접근에서 native 에 뒤진다. wgpu 를 **런타임** 으로만 쓰고 셰이더는 PASSTHROUGH 로 넣는 것이 중간 길이다.
+
+### 2.3 모바일, torchnative 공존
+- Android Vulkan, iOS Metal 은 wgpu 가 지원하는 백엔드 [I: 일반 지식, 이번에 문서로 재확인하지 않음].
+- torchnative 의 `mps`/`vulkan` 은 torch 디바이스이고, wgpu 는 별도 디바이스/버퍼 소유권을 가진다. 같은 MTLBuffer/VkBuffer 를 공유하려면 interop 이 필요하다 [I]. 커스텀 op 가 자체 Vulkan/Metal 호출을 쓰는 편이 버퍼 공유가 쉽다 [I].
+
+## 3. CubeCL (tracel-ai/cubecl)
+
+- 백엔드: CUDA, ROCm(HIP), wgpu 를 통한 Vulkan(SPIR-V)/WebGPU(WGSL)/Metal, CPU(SIMD). 라이선스 MIT/Apache-2.0. 상태 "Alpha, Burn 이 프로덕션 사용" [V github.com/tracel-ai/cubecl].
+- 네이티브 Metal: cubecl-metal 이 0.11.0-pre.4 에 추가되고 MSL 경로가 WGSL 폴백과 병존. 관련 이슈들(컴파일 실패, 폴백 혼선)이 최근 열려 있어 **이 경로는 아직 불안정** [V 검색요약, github.com/tracel-ai/cubecl/pull/1734, /issues/1722, /issues/1730].
+- 타일/매트릭스: cubek/cubecl 의 matmul 이 텐서 코어, double buffering, 벡터화로 cuBLAS/CUTLASS 와 경쟁(RTX 4080 에서 Simple 변형이 대체로 앞섬). Apple M2 Pro 에서는 Specialization 변형이 Metal 의 plane 제어 부족으로 뒤짐 [V burn.dev/blog/sota-multiplatform-matmul].
+- AOT: 공식 입장이 "JIT, 실제 실행되는 변형만 생성" [V github 요약]. 즉 **iOS 에서 그대로는 불가**. 다만 IR 에서 소스(WGSL/MSL/SPIR-V)를 빌드 타임에 뽑아 저장하는 길은 코드 구조상 가능해 보인다 [I, 확인 안 함].
+- 파이썬 프런트엔드 -> CubeCL IR: CubeCL 의 입구는 Rust `#[cube]` 매크로가 IR 빌더 호출로 바꾸는 것 [V]. IR 은 Rust 크레이트 내부 API 라 파이썬에서 직접 만들려면 Rust 쪽에 IR 을 직렬화하는 얇은 계층을 우리가 써야 한다 [I]. 또 comptime 특수화는 Rust 제네릭에 묶여 있어 파이썬 쪽 표현이 어렵다 [I].
+- CPU 참조 실행: CubeCL 자체 CPU 런타임이 있으나 이는 Rust 쪽이고 파이썬 인터프리터 실행이 아니다 [I].
+
+## 4. MLIR 계열 (Triton 스택, IREE, Mojo)
+
+- Triton: JIT 이 전제. Apple 용 외부 백엔드(triton-metal, triton-msl, triton-ext PR #126/#127)가 존재하며 MSL 을 생성하고 upstream test_core 를 통과한다고 주장하나, 개인 프로젝트이며 fp8/mxfp 행렬 유닛 없음, 1024 스레드/32KB threadgroup 상한 등 하드웨어 한계가 있다 [V 검색요약, github.com/bledden/triton-msl]. Vulkan/SPIR-V 백엔드는 이번 검색에서 찾지 못함 [V 부재는 검색 한계]. 이전 조사(FlexLA, ICLR 2026)는 Triton 위 AOT 디스패처 선례 [V torchnative DESIGN.md 인용].
+- IREE: AOT 중심, Vulkan/Metal/CUDA/CPU, "Android, iOS, 베어메탈 지원", 런타임은 임베디드에서 약 30KB 까지 작아질 수 있음 [V 검색요약, iree.dev]. 그러나 이는 최소 구성이고 GPU 백엔드 포함 모바일 앱의 실제 크기와 컴파일러 빌드 부담은 확인 못함 [I]. 입력 단위가 모델(그래프) 이라, 단일 커널 DSL 을 임베드하려면 linalg/TOSA/StableHLO 로 내려야 한다 [I].
+- Mojo: Apple GPU 는 nightly 에서 초기 지원, 경로가 Mojo -> LLVM IR -> AIR 비트코드 -> metallib. MAX 그래프/모델은 아직 Apple GPU 에서 미지원(조사 시점 검색 요약) [V forum.modular.com/t/apple-silicon-gpu-support-in-mojo/2295]. Modular 25.6 이 NVIDIA/AMD/Apple 통합을 표방 [V modular.com 블로그 제목]. 모조의 SIMD 구현 세부(SIMD[dtype,width] 1급 타입 등)는 이번에 재확인하지 않음 [I, 일반 지식].
+- 이 계열의 공통 한계: 컴파일러 빌드/배포가 무겁고, 우리 "파이썬 AST -> IR" 프런트엔드를 MLIR 로 올리려면 MLIR 바인딩을 끌어와야 한다 [I].
+
+## 5. 자체 소형 IR -> SPIR-V (현재 계획)
+
+만들어야 하는 것 [I, 다른 시스템 경험에 근거한 추정. 규모는 측정 아님]:
+1. 프런트엔드: `@kernel` 파이썬 AST 서브셋 -> 타입된 IR. (등급 1 의 Pyrefly 게이트와 공유 가능)
+2. SIMT 코드 생성: SPIR-V 를 직접 emit (rspirv 같은 크레이트나 직접 binary writer). 구조화된 제어 흐름 규칙, 메모리 모델, 용량 한정자 처리가 필요.
+3. 공유 메모리, 배리어, 원자 연산, 서브그룹 연산.
+4. 행렬 유닛: SPIR-V `OpCooperativeMatrix*KHR` 직접 emit (Vulkan 경로). Metal 은 SPIRV-Cross 가 simdgroup_matrix 로 내려주는지 불확실. SPIRV-Cross 의 협력 행렬 지원은 이번에 확인 못함 [I]. 한편 naga 는 협력 행렬 입력 WGSL, 출력 SPIR-V/MSL/WGSL 이 구현 중 [V wgpu PR #8251].
+5. 타일 추상화를 올리려면 레이아웃 추론, 소프트웨어 파이프라이닝(cp.async 대응), 레지스터/공유 메모리 swizzle, autotuning 대체물(AOT 이므로 빌드 타임 탐색)이 필요. 이것이 Triton/TileLang 이 수년 투자한 부분.
+6. Metal 4 `matmul2d`/cooperative_tensor 는 SPIR-V 경로로 접근 불가 (MSL 4 전용, MPP 라이브러리) [V WWDC26 330, developer.apple.com]. Apple Neural Accelerator(M5) 를 쓰려면 SPIR-V -> MSL 변환이 아니라 MSL 직접 emit 이 필요하다 [I].
+7. Vulkan 쪽 협력 행렬 하드웨어: Adreno 는 VK_KHR_cooperative_matrix + VK_QCOM_cooperative_matrix_conversion 이 있는 신형에서만 llama.cpp 가 활성화 중, Mali 는 일부(PanVK) 외에는 없음으로 보고됨 [V llama.cpp PR #29328 검색요약]. 따라서 **Android 에서는 협력 행렬이 기본값이 아니라 선택 경로**이고 SIMT/서브그룹 폴백이 반드시 필요하다.
+
+## 6. CPU SIMD 쪽
+
+- 우리 `@compiled` 는 자체 typed IR -> C (typedpython.md §4.3). 선택지 [I, 이번에 재조사 안 함]:
+ a. C 벡터 확장 (`__attribute__((vector_size))`, clang/GCC 공통, MSVC 불가). 소스 생성이 단순하고 NEON/AVX 로 내려감. 폭이 고정이라 SVE 류는 못 씀.
+ b. Highway: 런타임 디스패치와 가변 폭 지원. C++ 템플릿이라 C 생성기와 결합하려면 C++ 로 emit. 라이선스 Apache-2.0/BSD-3 [I, 기억 기반].
+ c. LLVM 직접 emit: 가장 강력하나 의존성 최대. iOS 앱에 LLVM 을 싣는 것은 비현실적이고 AOT 빌드 머신에서만 쓸 수 있음. 빌드 타임 도구로서는 가능.
+ d. 모조 방식: `SIMD[dtype,N]` 을 1급 타입으로 두고 LLVM 에 내림 [I 일반 지식].
+- 권고(추정): a 를 기본, 핵심 BLAS 류는 손으로 쓴 NEON (torchnative 가 이미 후보로 둠), 성능이 필요한 곳에만 b.
+
+## 7. 비교표
+
+범례: O 가능(검증), o 가능 추정, X 불가, ? 미확인.
+
+| 항목 | TileLang | wgpu+naga | CubeCL | MLIR(IREE/Triton/Mojo) | 자체 IR->SPIR-V |
+|---|---|---|---|---|---|
+| iOS AOT | ? (export 있으나 iOS 형태 미확인) | o (PASSTHROUGH 로 MSL/SPIR-V 제출 가능; 런타임은 Rust 크레이트) | X 기본(JIT). 소스 추출 개조 필요 [I] | IREE o (AOT 전제). Triton X (JIT). Mojo ? | O 설계 목표 |
+| Android Vulkan | X (SPIR-V 미구현) | O (wgpu Vulkan 백엔드) | O (SPIR-V 컴파일러, JIT) | IREE O, 그 외 ? | o (직접 emit) |
+| Metal | O 소스 생성(macOS, iOS ?) | O (naga MSL) | O 네이티브 cubecl-metal 신규/불안정 | IREE o, Triton 외부 포크, Mojo nightly | o (SPIRV-Cross 또는 MSL 직접) |
+| WebGPU | 실험 | O (WGSL) | O (WGSL, SPIR-V 대비 느림) | IREE ? | o (naga) |
+| CUDA | O (주력) | X (wgpu 에 CUDA 없음) | O | Triton/Mojo O | PTX 별도 작업 |
+| 행렬 유닛 | O (CUDA/HIP/Metal) | 실험(협력 행렬, native 전용, WebGPU 표준 미확정) | O (SPIR-V/CUDA 경로, WGSL 경로 약함) | O (Triton/Mojo) | 직접 구현 필요 |
+| 타일 추상화 | O | X | 부분(cubek 라이브러리 수준) | Triton O | X (뒤로 미룸) |
+| 순수 파이썬 CPU 참조 실행 | X [I] | 해당 없음 | 해당 없음 | Triton 은 interpreter 모드 있음 [I 기억] | O (§2.1 설계) |
+| 의존성 무게 | 큼(TVM) [I] | 중(Rust) | 중(Rust) | 큼 | 작음(우리 코드) |
+| 라이선스 | MIT (TVM Apache-2.0 [I]) | MIT/Apache-2.0 [I] | MIT/Apache-2.0 [V] | IREE Apache-2.0 [I], Mojo 폐쇄적 부분 있음 [I] | 해당 없음 |
+| 성숙도 | 활발, 릴리스 0.1.x | 활발, 협력 행렬 신기능 | Alpha, 변화 빠름 | IREE 성숙, Mojo Apple 초기 | 0 |
+
+## 8. 각 선택지가 **못 하는** 것
+
+- TileLang: Android Vulkan 불가, 파이썬 인터프리터 참조 실행 불가(추정), TVM 빌드 체인을 우리 Gradle/uv 배포에 넣는 부담, iOS AOT 형태 미확인.
+- wgpu/naga: WebGPU 표준에 협력 행렬 아직 없음, WGSL 경로는 int8 dot / 협력 행렬 / 큰 공유 메모리에서 native 에 뒤짐(Burn 사례), Metal 4 tensor ops 접근 불가, CUDA 불가. 런타임이 Rust 라 torchnative 의 버퍼와 별도 소유.
+- CubeCL: JIT 전제로 iOS 불가(개조 전), Rust 가 소스 언어라 파이썬 프런트엔드 입구가 없음, Alpha, Metal 네이티브 경로 불안정.
+- MLIR: 무거운 빌드, 커널 단위 DSL 아님(IREE), Triton 은 JIT, Mojo 는 우리가 소유할 수 없는 폐쇄 도구.
+- 자체 IR: 타일 수준 최적화(파이프라인, 레이아웃)를 전부 직접 만들어야 하고, 벤더 드라이버 버그와 기기별 튜닝은 어떤 선택지도 대신해 주지 않는다. Apple Neural Accelerator 는 SPIR-V 를 거치면 못 쓴다.
+
+## 9. 권고 (불확실성 명시)
+
+1. **프런트엔드와 IR 은 자체 소유를 유지한다.** 이유: §2.1(참조 CPU 실행 = 파이썬 그대로)을 만족하는 후보는 이것뿐이고 [I], 등급 1 의 게이트와 공유된다. 외부 시스템은 모두 자체 DSL 이 입구라서 §2.1 과 충돌한다 [V 각 DSL 의 입구 확인, 충돌 판단은 I].
+2. **백엔드는 한 개로 고정하지 말고 emitter 를 교체 가능한 계층으로 둔다.** 1단계는 SIMT 로 SPIR-V(Vulkan, Android)와 MSL(Apple) 두 개를 직접 emit. SPIRV-Cross 경유보다 MSL 직접 emit 을 권한다(Metal 4 tensor ops, simdgroup_matrix 에 닿으려면 필요) [I].
+3. **런타임은 먼저 wgpu 를 쓰지 않는다.** torchnative 의 mps/vulkan 디바이스와 버퍼를 공유해야 하는 커스텀 op 라는 위치 때문에, 자체 얇은 Vulkan/Metal 디스패처가 맞을 가능성이 높다 [I]. 웹 타깃에서만 wgpu/WebGPU 를 쓰고, 이때 WGSL 의 성능 한계를 감수한다. 협력 행렬이 필요한 웹 경로는 표준 확정 이후.
+4. **CubeCL 은 "라이브러리 수준 참조" 로 쓰고 의존하지 않는다.** cubek matmul 의 타일 알고리즘(double buffering, line size)과 벤치 결과는 우리 커널 설계의 비교 기준으로 가치가 크다 [V]. 의존하려면 AOT 개조와 파이썬 IR 직렬화가 먼저다 [I].
+5. **TileLang 은 데스크톱 CUDA/Metal(macOS) 에서 비교 기준(baseline)으로 쓴다.** 모바일 경로가 없으므로 주 백엔드로는 부적합 [V Vulkan 부재 / I 판단].
+6. 타일 추상화는 SIMT 가 돌아간 뒤. 처음부터 Triton 급을 목표로 하면 §5 의 항목 5 가 프로젝트를 잡아먹는다 [I].
+7. CPU SIMD 는 C 벡터 확장으로 시작 [I].
+
+불확실성: (a) TVM/TileLang 의 실제 비용을 측정하지 않았음, (b) wgpu 협력 행렬의 릴리스별 현황과 성능, (c) Adreno/Mali 협력 행렬 보급률, (d) 직접 emit 한 SPIR-V 가 기기별 드라이버에서 얼마나 안 깨지는지, (e) SPIR-V -> MSL 에서 협력 행렬이 내려가는지(SPIRV-Cross 미확인).
+
+## 10. 결정을 위해 필요한 측정
+
+1. TileLang 을 `pip`/`uv` 로 설치한 크기, 소스 빌드 시간, Python 3.13/3.14t 지원 여부, 소스 export 로 얻는 MSL/CUDA 가 iOS 앱에 링크 가능한지(실제로 export 후 Xcode 에서 컴파일).
+2. 동일 커널(GEMM 1024^3 f16, softmax, 융합 어텐션) 을 (i) 손 작성 MSL (ii) 우리 SPIR-V->SPIRV-Cross MSL (iii) naga MSL (iv) CubeCL Metal 로 M-시리즈/A-시리즈에서 GFLOPS 비교. Apple 은 simdgroup_matrix 와 Metal 4 matmul2d 둘 다.
+3. Android: Adreno 7xx/8xx, Mali G7xx 에서 VK_KHR_cooperative_matrix 노출 여부와 SIMT 폴백 대비 속도(실기 3종 이상).
+4. wgpu PASSTHROUGH + 우리 SPIR-V 와 직접 Vulkan 디스패치의 호출당 오버헤드(소형 커널 반복 10만 회), 그리고 torchnative 텐서 버퍼 공유 가능 여부.
+5. 직접 emit SPIR-V 의 spirv-val, 드라이버 크래시율(가능한 많은 기기/Android 버전, CI 팜).
+6. CPU: C 벡터 확장 vs 손 NEON vs Highway 로 dot/GEMV/q4 matmul 비교(torchnative 의 기존 커널 기준). 측정은 규칙 8 에 따라 단독, 비필터로.
+7. 자체 IR 구현 비용 추정을 위한 프로토타입: SIMT vector-add + reduction + 공유 메모리 GEMM 을 SPIR-V 와 MSL 로 emit 하는 데 걸리는 기간.
+
+## 11. 이 조사 방법이 찾지 못하는 것
+
+웹 검색 요약과 문서 페이지 요약(작은 모델이 변환한 결과)에 의존했으므로, 실제 성능 수치, 실기 드라이버 버그, 빌드해 보아야 보이는 의존성 문제, 요약기가 누락하거나 잘못 적은 세부(특히 PDF 본문 수치)는 찾지 못한다.
+
+## 12. torchnative 를 커널 컴파일러의 일부로 보기
+
+표기는 위와 같다. 이 절의 **[R]** = verified (torchnative 저장소의 파일, 이번에 직접 읽음. 경로 병기, 실행하지는 않았으므로 문서가 주장하는 측정값을 재현한 것은 아님). **[V]** 는 웹 출처, **[I]** 는 추론.
+
+### 12.0 읽고 나서 바뀌는 사실 (§2, §5 의 전제 수정)
+
+- torchnative 에는 **이미 자체 Vulkan 경로가 있다.** `torchnative/rust/torch_c/src/vulkan.rs` (3801줄), `ash`(loaded, dlopen) 기반, GLSL `.comp` 를 빌드 머신에서 `glslc` 로 컴파일해 `.spv` 를 **저장소에 체크인**하고 `include_bytes!` 로 싣는다. `torch_c/shaders/` 에 add, matmul, bmm, softmax, layer_norm, gelu, embedding, gather 등 f32 셰이더가 있다 [R: torch_c/shaders/compile.sh 와 목록].
+- 셰이더는 shape 마다 재생성하지 않는다. 모양은 push constant 로 넘기고 셰이더 하나가 모든 크기를 처리한다 [R: docs/devices/VULKAN.md §3].
+- `ash` vs `wgpu` 를 **기기(에뮬레이터)에서 둘 다 돌려 비교**했다. 둘 다 통과, ash 는 크레이트 4개 719 KB / 클린 빌드 8.5 s, wgpu 는 60 크레이트 6.77 MB / 24 s. 권고는 ash. 단 "Apple GPU 를 어떻게 채우나" 가 걸려 있어, candle 의 metal 을 켜면 ash, 커널을 직접 소유하면 wgpu 가 맞다고 명시하고 결정은 조율 세션에 남겼다 [R: docs/devices/VULKAN.md §4.1, §5.1-5.3]. 이 조사의 질문(커널을 우리가 소유)은 바로 그 두 번째 칸이다.
+- 단 wgpu 측정은 `Limits::downlevel_defaults()` 로 돌렸고 이는 기기 한계가 아니라 wgpu 하한, 버퍼는 최대 4 MB 였다 [R: VULKAN.md 약 381행]. 큰 텐서·협력 행렬·f16 에서의 비교는 아직 없다.
+- Apple 쪽은 `mps` 디바이스가 candle Metal 위에 있고, candle Metal 에는 조용한 CPU 폴백이 없으며 이 크레이트의 커널 중 54개 op 이 호스트 되읽기라 `NotImplementedError` 로 거절한다 [R: docs/devices/MPS.md]. `Cargo.toml` 은 Apple 에서 `accelerate` 만 켜고 `metal` 은 기본값으로 끈다는 주석이 있다 [R: torch_c/Cargo.toml, DESIGN.md 1150-1160].
+- 즉 §5 의 "SPIRV-Cross->MSL, naga->WGSL" 같은 **번역 계층 전략**은 torchnative 입장에서는 이미 한 번 검토되어 "가장 가벼운 것 = 직접 ash + 체크인된 SPIR-V" 로 기운 상태다. 그 방향과 정합하도록 §9 권고 3("wgpu 를 먼저 쓰지 않는다")은 근거가 강화된다.
+
+### 12.1 (a) 빌드 타임 그래프 캡처 -> 융합 구간 -> 커널 컴파일러
+
+**상태 [R]**: `torch_c/src/capture.rs` + `docs/graph/CAPTURE.md`. `_aten_dispatch` 단일 문(door) 끝 한 줄에서 op 을 기록한다. 기록 = (op 이름 `aten..`, 피연산자 shape/dtype/device, 결과), 가드 = 입력의 **정확한** shape/dtype/device, 재생 = eager 와 대조. FX/ExportedProgram 과 1:1 대응이라 ExecuTorch 로 갈 수 있는 모양이라는 판단이 문서에 있다 (단 "부족한 네 가지" 가 명시됨, 이번에는 열람하지 않음).
+**거절하는 것 [R: CAPTURE.md §4]**: 텐서 값 기반 제어 흐름(`_local_scalar_dense`), 인플레이스/별칭, **동적 shape**, 난수, 비텐서 결과, 컨테이너. 상수(가중치)는 참조로 잡아 가드하지 않음. 배치 차원은 자유롭지 않음.
+**torch.compile 은 불가 [R]**: `docs/graph/COMPILE.md` 는 abi3 에서 PEP 523 프레임 평가 훅이 막힌다고 진단하고, 대안으로 `torch.export` 경로를 본다. `docs/graph/EXPORT6.md` 는 실제 transformers 40개 아키텍처 중 export 성공이 **0/40** (벽 8개 중 상당수 해결, 그래도 헤드라인은 0/40) 이라고 적는다.
+
+이 역할이 필요로 하는 것과 막는 것:
+
+1. **융합 패턴 선정기.** 기록에서 직선 구간을 찾아 "gated MLP", "RMSNorm+residual", "SDPA", "dequant+matmul" 같은 패턴을 추출. `NPUFUSE.md` 가 Intel NPU 에서 gated MLP 를 OpenVINO 그래프 하나로 낮춘 선례다 (단 NPU 에서는 아직 실행 안 됨, 문서가 스스로 명시) [R]. 이것은 새 컴파일러가 아니라 **패턴 매처**로 시작하는 게 현실적이다 [I].
+2. **동적 shape (LLM 디코드).** 막힘의 핵심. 디코드는 KV 길이가 매 스텝 변하고, 현재 캡처 가드는 정확한 shape 를 요구한다 [R]. 외부 사례: ExecuTorch 는 LLM 에서 sequence 차원을 dynamic 으로 표시하며, 정적 shape 만 되는 백엔드(예: QNN)에는 prefill(최대 길이로 패딩)과 단일 토큰 decode 모델을 따로 export 한다 [V docs.pytorch.org/executorch/1.0/llm/export-custom-llm.html, 검색요약]. 즉 업계도 "shape 별 그래프 2~몇 개 + 패딩" 으로 해결한다. torchnative 도 같은 길(prefill 그래프, decode 그래프를 각각 캡처, KV 는 최대 길이 사전 할당 + 길이는 스칼라 입력)을 택하는 것이 가능해 보인다 [I]. AOTInductor 가 동적 shape 를 어떻게 다루는지는 이번에 원문으로 확인하지 못했다 (ExecuTorch 의 Metal/CUDA 가 AOTInductor 를 활용 중이라는 검색요약만 [V]).
+3. **커널 쪽 계약.** 융합 구간을 TypedPython `@kernel` 로 쓴 커널에 매핑하려면, 커널이 **shape 를 데이터(push constant/uniform)로 받는** 형태여야 한다. torchnative Vulkan 셰이더가 이미 그렇게 한다 [R]. TypedPython 커널 시그니처에 "shape/stride 를 런타임 인자로, tile 크기는 컴파일 타임 상수" 를 요구사항으로 넣는 것이 자연스럽다 [I].
+4. **자동 코드 생성은 이 단계에서 불필요.** 융합 영역을 즉석에서 Inductor 식으로 생성하려면 pointwise/reduction 스케줄러가 필요한데 규모가 크다 [I]. 1단계는 "사람이 `@kernel` 로 쓴 융합 커널 + 캡처 기록에서 패턴 매칭으로 치환" 이면 된다. 이것은 DESIGN.md §8 의 `use_kernel_forward_from_hub` 레이어 치환과 같은 자리다 [R].
+
+못 하는 것: torch.compile 의 자동 융합 대체, 데이터 의존 제어 흐름(MoE 라우팅, 조기 종료), 인플레이스 KV 갱신(변이는 거절됨, 단 KV 캐시 갱신이 정확히 그 경로 [R: DESIGN.md §4 "주의"]). **KV 캐시 인플레이스가 캡처 거절과 정면 충돌**한다 점이 가장 큰 구체적 장애다 [I: 두 문서의 결합에서 도출].
+
+### 12.2 (b) torchnative 디바이스 층을 커널 런타임으로 공유
+
+**상태 [R]**: Vulkan(ash) 디바이스 존재(f32 위주 셰이더, 1훈련 스텝까지 도는 단계 VULKAN10), Metal 은 candle 경유 mps, CPU 는 candle + 자체 커널. 각 디바이스에서 Metal/Vulkan 의 저장소(storage)는 torchnative 가 소유한다.
+
+장점 [I]: TypedPython 커널이 별도 런타임을 갖지 않고 같은 버퍼·큐·동기화를 쓰면 (1) 호스트 복사 없음, (2) 커널 소유 문제(수명, 풀링)가 한 곳, (3) 앞서 §0 이 제기한 "Taichi 식 고립" 을 피한다.
+필요한 것:
+- torchnative `vulkan.rs` 에 **외부 SPIR-V 를 받는 "임의 커널 디스패치" 진입점**: 파이프라인 캐시, 디스크립터 레이아웃 선언(버퍼 개수, push constant 크기), 워크그룹 크기. 현재 프로브는 디스패치마다 파이프라인·풀을 만들고 부수며 이는 "프로브라서 그럼" 이라고 문서가 명시, 커널이 몇 개를 넘으면 얇은 내부 계층이 필요하다고 스스로 적었다 [R: VULKAN.md §5.2].
+- Metal 쪽: 현재 candle-metal 이 MTLDevice/큐를 소유한다. 커스텀 MSL 을 끼우려면 candle 의 command buffer 에 인코딩하거나 같은 MTLBuffer 를 받아 별도 인코딩해야 한다. candle-metal-kernels 가 `rust/vendor/` 에 벤더링되어 있어 [R] 수정은 가능하나 버퍼 공유 방식은 코드를 읽지 않아 모른다 [I].
+- 행렬 유닛 접근: 현재 셰이더에 cooperative matrix 사용 흔적은 확인하지 못함(.comp 이름만 열람, 내용 미열람) [R 한계].
+막힘: Vulkan 셰이더가 f32 뿐이면 AI 커널 목적(f16/bf16/int4 matmul)에 부족. 이는 TypedPython 커널의 첫 번째 실제 수요처다 [I].
+
+**wgpu 선택지와의 상호작용 [R + I]**: torchnative 는 wgpu 를 이미 기기에서 통과시켜 둔 프로브(`vulkan_probe` 의 `--features wgpu-route`)를 보존하고 있다 [R]. 그래서 두 런타임이 공존하면 버퍼가 둘로 갈라진다. 권고: 네이티브(Android Vulkan, Apple Metal)는 torchnative 디바이스 층(ash/candle-metal), **웹 타깃만 wgpu(WebGPU)** 로 분리하고, 두 경로는 같은 IR 에서 SPIR-V/MSL/WGSL 을 각각 emit 하는 emitter 로만 만난다. 웹에서 torchnative 를 쓰는지는 `wasm_probe` 가 있다는 것만 확인 [R].
+
+### 12.3 (c) torchnative CPU 경로를 수치 오라클로
+
+**상태 [R]**: `torch_c/src/flash.rs` 는 ATen CPU flash-attention 을 bf16/fp16 에서 **bit-identical** (226136개 원소 중 차이 0) 로 재현한 "참조 구현" 이고, f32/f64 는 upstream 이 BLAS 를 쓰므로 비트 일치 불가(차이 4.17e-07 수준)라고 문서가 구분한다. `vulkan_probe/src/check.rs` 는 GPU 와 CPU 참조를 같은 하니스로 비교하는 패턴이다.
+역할 적합성:
+- 수치 검증의 좋은 오라클이 된다. 단 **무엇과 일치해야 하는가를 먼저 정해야 한다**: TypedPython 의 순수 파이썬 참조 실행(§2.1) vs torchnative CPU(candle) vs 상류 PyTorch. 세 개의 오라클이 서로 다른 reduction 순서를 쓰므로 f32 에서는 허용오차 기반(ULP 한도 선언) 비교가 필요하다 [I, flash.rs 의 문서가 이 현상을 직접 보여줌 R].
+- 기기에서 CPU 오라클을 쓰는 것(디바이스 내 자기 대조)은 이미 프로브가 한다. 같은 방식으로 `@kernel` 하나당 (입력 시드, CPU 참조, GPU 결과) 를 출력하는 테스트 형식을 재사용할 수 있다 [I].
+- 한계: 오라클이 candle 이라면 candle 의 의미론 차이(복사 지향, view/in-place 임피던스 [R]) 가 섞인다. 커널 단위 테스트는 raw 버퍼 입력이라 영향이 작다 [I].
+
+### 12.4 (d) kernels 번들 리졸버를 착지 슬롯으로
+
+**상태 [R: DESIGN.md §8]**: HF `kernels` 계약(백엔드 변형 선언, transformers 무수정 교체)을 채택하고, 해석 시점을 빌드 타임으로 옮겨 Gradle 플러그인이 변형을 골라 AOT 컴파일·번들, 런타임 리졸버가 번들을 조회. 타깃 표에서 Android GPU 는 `kernels` 에 `vulkan` 백엔드가 없어 **구멍**이고, 권고는 "1) Android 는 CPU 만으로 시작 + 2) 상류에 vulkan 백엔드 제안 병행".
+TypedPython 입장 [I]:
+- `@kernel` 하나가 `metal` / `vulkan`(비표준) / `cpu` / `cuda` 변형 번들을 산출하는 **`kernel-builder` 대체 빌더**가 되는 구도. 이것이 §5.4 의 "`torch.library` 커스텀 op 등록" 보다 구체적인 착지 형태다. 두 개를 동시에 만족시키려면: op 등록은 torchnative 의 ATen 디스패처(`aten..`), 변형 선택은 리졸버로 하는 이중 구조가 필요할 수 있다. 어느 쪽이 정본인지는 결정 필요.
+- 업스트림 `vulkan` 백엔드 슬롯 제안과 TypedPython 의 Vulkan emitter 는 한 묶음으로 움직이는 게 맞다. 이것이 DESIGN.md §8 의 선택지 2 를 실체화한다.
+- 막힘: 빌드 타임 AOT 컴파일을 Gradle 플러그인이 호출해야 하므로 TypedPython 컴파일러(파이썬 프로세스) 호출 비용과 증분 컴파일(SPEC N-10)의 연계가 필요 [I].
+
+### 12.5 종합 (이 절의 권고, 모두 [I])
+
+1. 가장 얇은 수직 슬라이스: **`@kernel` 1개 (f16 GEMV 또는 RMSNorm) -> SPIR-V 직접 emit -> torchnative `vulkan.rs` 에 임의 SPIR-V 디스패치 진입점 추가 -> 기기에서 CPU 오라클과 대조.** 이미 있는 프로브·체크인 SPIR-V 흐름과 같은 모양이라 새 위험이 가장 적다.
+2. wgpu 는 **웹 전용**으로 한정. torchnative 의 기존 측정(719 KB vs 6.77 MB)이 모바일 네이티브에서의 기각 근거다 [R].
+3. 그래프 캡처(a)는 커널 컴파일러의 선행 조건이 아니다. 먼저 레이어 치환(`use_kernel_forward_from_hub` 식)으로 가고, 캡처 기반 융합은 KV 인플레이스와 동적 shape 문제가 풀린 뒤로 미룬다. torch.export 0/40 이 그 이유다 [R].
+4. 결정이 필요한 것: (i) Apple 은 candle-metal 에 끼울지, TypedPython 이 MSL 을 emit 하고 자체 Metal 디스패처를 둘지, (ii) 정본 등록 경로(ATen 디스패처 vs kernels 리졸버), (iii) Vulkan f16/cooperative matrix 를 torchnative 디바이스 층의 범위로 볼지.
+
+### 12.6 추가 측정 항목 (§10 에 더해)
+
+8. torchnative Vulkan: 디스패치 1회당 오버헤드 (파이프라인 캐시 전/후), 동일 matmul 의 f32 GFLOPS (Adreno/Mali 실기), f16 셰이더 가능 여부.
+9. candle-metal 에 외부 MSL 커널을 같은 command buffer 로 끼울 수 있는지(코드 열람 + 프로토타입).
+10. 캡처: Llama 급 1개 모델의 decode 스텝을 "KV 최대 길이 고정 + 길이 스칼라" 로 쓰면 캡처 가드를 통과하는지 (인플레이스 KV 갱신 때문에 현재는 거절될 것으로 예상, 미측정).
+11. 세 오라클 (파이썬 참조, candle CPU, 상류 PyTorch) 의 f32 softmax/matmul 편차 크기.
+
+이 절의 조사 방법이 찾지 못하는 것: 문서의 측정값을 재현하지 않았고(문서가 주장하는 숫자를 그대로 인용), `vulkan.rs` 3801줄과 candle-metal 연동 코드는 본문을 읽지 않았으며, 문서가 "해결됨" 이라 적은 항목이 현재 코드에서도 참인지는 확인하지 못한다.
diff --git a/docs/investigations/typedpython-kernel-ir.md b/docs/investigations/typedpython-kernel-ir.md
new file mode 100644
index 000000000..76d438456
--- /dev/null
+++ b/docs/investigations/typedpython-kernel-ir.md
@@ -0,0 +1,128 @@
+# TypedPython 커널 컴파일러 IR 선택 (2026-10-04)
+
+표기: **[V]** = verified (직접 실행한 명령 또는 fetch 한 URL), **[R]** = 로컬 저장소 파일을 직접 읽음, **[I]** = inference.
+결론의 반영: `docs/design/typedpython.md` §5.4 (2026-10-04 결정). 조사 당시 작업 위치는 git 에 넣지 않은 `.tmp/research/` 였다(스크립트와 venv 는 커밋하지 않았다). venv 두 개(`venv` = py3.13 + xdsl, `venv314t` = py3.14 free-threaded + MLIR 바인딩 wheel). 스크립트 `t_spirv.py`, `t_bin.py`, `t_ptx.py`.
+
+## 0. 전제 (읽은 문서)
+
+- [R] typedpython.md §2.1: 유효한 파이썬만, 커널도 CPython 에서 그대로 실행(참조 구현). §5.1: iOS 때문에 AOT 가 하드 제약. §5.3 현재 경로: 자체 소형 IR -> SPIR-V -> (Vulkan | SPIRV-Cross->MSL | naga->WGSL), PTX 별도. 착지점 torchnative.
+- [R] torchnative `vulkan.rs` (3801줄): `ash` 로 로더를 dlopen, GLSL 을 `glslc` 로 컴파일한 `.spv` 를 저장소에 체크인하고 `include_bytes!` 로 싣는다(71행). `create_shader_module`(692행) -> `create_compute_pipelines`(750행), 형상은 push constant(866행) 으로 전달, 디스패치는 `dispatch_kernel` / `dispatch_kernel_words`(788, 801행). 즉 **임의 SPIR-V 워드열을 받는 진입점이 이미 `dispatch_kernel_words` 모양으로 존재**하고, 첫 슬라이스의 도착 지점은 "`.spv` 를 컴파일러가 만든 것으로 바꾸는 것" 이다 (이전 조사 §12 의 "임의 SPIR-V 진입점이 필요" 는 이 파일의 현재 상태로 보면 부분적으로 이미 해결됐을 가능성, 단 파이프라인 캐시 상태는 읽지 않음) [R + I].
+- 이전 조사(kernel-backend.md)의 "자체 프런트엔드 + 자체 IR" 권고는 MLIR 을 내부 인프라로 평가하지 않았다. 이 문서가 그 공백을 채운다.
+
+프런트엔드(valid Python `@kernel` 서브셋 파싱, 등급 1 게이트와 공유)는 어느 선택지에서도 우리 몫이다. 선택지가 갈리는 곳은 **프런트엔드 뒤: IR, 변환(tiling/layout/pipelining), 코드 방출(emitter)** 이다.
+
+## 1. 직접 확인한 사실 (이번 조사에서 실행한 것)
+
+### 1.1 MLIR Python 바인딩 wheel
+
+- [V] PyPI 의 `mlir-python-bindings` 는 **이름 선점용 빈 placeholder** (0.0.1, "not the real distribution"). `mlir` 도 2023 년 빈 패키지. 즉 `uv add mlir-python-bindings` 는 진짜를 주지 않는다 (`curl pypi.org/pypi//json`).
+- [V] 실제 wheel 은 GitHub release `llvm/eudsl` 태그 `mlir-python-bindings` 에 있다 (GitHub API 로 목록 조회: 658 assets, 최신 `mlir_python_bindings-20261004+758aa4a92-*`, 거의 매일 갱신). 설치 가이드: `pip install mlir-python-bindings -f https://llvm.github.io/eudsl` (eudsl README 요약). 이전 저장소 `makslevental/mlir-wheels` 는 2025-11-29 archive, eudsl 로 이전됨 [V WebFetch].
+- [V] 플랫폼/파이썬 매트릭스 (asset 이름 집계): cp310, 311, 312, 313, 314, **cp314t(free-threaded)** 각각 macOS arm64(`macosx_13_0_arm64`), Linux x86_64/aarch64(manylinux_2_34), Windows amd64. **cp315 / cp315t wheel 은 없음** (`cp315` 문자열 asset 0개). 크기 wheel 당 58 ~ 76 MB, 설치 후 `mlir/` 디렉터리 253 MB (macOS cp314t).
+- [V] 실제로 설치해 실행: `uv venv venv314t --python 3.14t; uv pip install --python venv314t/bin/python ` 성공. `sys.version` = "3.14.7 free-threading build", `import mlir` 후 `sys._is_gil_enabled()` = **False** (GIL 이 다시 켜지지 않음). 등록된 dialect: spirv, gpu, nvvm, vector, linalg, transform, amdgpu, x86, nvgpu, emitc, llvm, scf, memref, tensor, tosa 등 (`pkgutil.iter_modules(mlir.dialects.__path__)`).
+- [V] 공식 문서(mlir.llvm.org/docs/Bindings/Python/): free-threading 은 3.13+ 에서 "독립된 Context 라면 멀티스레드 안전". 안전하지 않다고 명시한 것: IR printing 과 PassManager 동시 사용, location error emit, module dump, transform interpreter, **GPU dialect 관련 작업**. 우리는 컴파일을 빌드 타임에 단일 프로세스/스레드로 돌리면 되므로 영향이 작다 [I].
+- [V] 동작 증명 1, **SPIR-V 바이너리**: `gpu.module @k [#spirv.target_env<...>] { spirv.module Logical GLSL450 requires #spirv.vce {...} }` 에 `gpu-module-to-binary` 를 돌리면 `gpu.binary` 에 `\03\02#\07` (SPIR-V magic 0x07230203) 로 시작하는 바이너리 blob 이 나온다 (`t_bin.py`). 즉 **파이썬 안에서 SPIR-V 직렬화까지 도달**. 단 `vce_triple` 없으면 "module must have 'vce_triple' attribute to be serializeable" 로 실패.
+- [V] 동작 증명 2, **gpu -> spirv 하향**: `gpu.func` (global_id, memref.load, arith.addf, memref.store) 에 `convert-gpu-to-spirv` 를 돌리면 `spirv.func` 가 나온다 (`t_spirv.py`; StorageBuffer 구조체 래핑, GlobalInvocationId builtin 포함). 이어 `gpu-module-to-binary` 를 같은 모듈에 바로 돌리면 "the module has no target attributes" 로 실패: 하향 결과의 `spirv.module` 이 `gpu.module` 의 형제로 남고 target 속성이 없기 때문. **하향 산출물을 `gpu.module [target]` 안에 넣는 접합 코드는 우리가 써야 한다** (실행해 보지 않음, [I] 난이도 낮음). 이 end-to-end(f32 add -> .spv 바이트) 는 **검증하지 못했다**.
+- [V] 동작 증명 3, **PTX**: `gpu.module @k [#nvvm.target]` 에 `gpu.module(convert-gpu-to-nvvm,canonicalize),gpu-module-to-binary{format=isa}` 를 돌리면 `.version 7.0 .target sm_80 ... .visible .entry f(` 로 시작하는 PTX 텍스트가 나온다 (`t_ptx.py`). wheel 에 NVPTX 백엔드가 들어 있다는 뜻.
+- [V] `spirv.KHR.CooperativeMatrixLoad/Store/MulAdd/Length` op 이 파이썬 dialect 모듈(`mlir.dialects._spirv_ops_gen`)에 있음. `gpu-lower-to-nvvm-pipeline`, `convert-vector-to-spirv`, `convert-arith-to-spirv`, `convert-nvgpu-to-nvvm` 패스가 파싱됨.
+- [V] 확인 못 한 패스 이름: `spirv-lower-abi-attrs`, `spirv-update-vce`, `convert-gpu-to-nvvm` (Context 없이 파싱 시도했거나 이름이 다를 수 있음; `convert-gpu-to-nvvm` 은 위 PTX 실험에서 `gpu.module(...)` 안에서는 동작). 결론 가능한 것은 "일부 패스가 파이썬에서 호출 가능" 까지.
+
+### 1.2 xdsl
+
+- [V] `uv pip install xdsl` -> xdsl 0.72.0 (PyPI 2026-10-02 릴리스). 순수 파이썬, 의존성 `immutabledict`, `ordered-set`, `typing-extensions` 셋뿐. Requires-Python >=3.10, classifier 3.14 까지. **py3.14t venv 에서도 설치/임포트되고 `sys._is_gil_enabled()` = False** [V]. 3.15t 는 시험하지 못함 (순수 파이썬이라 [I] 가능성 높음).
+- [V] 라이선스: PyPI METADATA 는 "Apache License v2.0 with LLVM Exceptions". GitHub API 의 SPDX 는 NOASSERTION (표기 불일치, 사소함). GitHub star 595, 최근 push 2026-10-03, PyPI 릴리스 126 개, 최근 8 개가 2026-05-29 ~ 2026-10-02 사이 (약 2~4 주 간격) [V].
+- [V] dialect 목록 (`pkgutil.iter_modules(xdsl.dialects.__path__)`): acc, affine, arith, arm, arm_neon, bufferization, builtin, cf, comb, complex, csl, dlti, emitc, func, **gpu**, linalg, llvm, math, memref, memref_stream, ml_program, omp, pdl, ptr, riscv*, scf, snitch*, stencil, tensor, tosa, **transform**, ub, **vector**, wasm, x86*, 등. **`spirv` dialect 없음**: `grep -ril "spirv\|spir-v" site-packages/xdsl` 결과 0 건 [V].
+- [V] 백엔드 (`xdsl.backend`): `llvm` (xdsl -> LLVM IR 텍스트 변환, 약 950줄), `wgsl/wgsl_printer.py` (252줄), `riscv`, `x86`, `csl`, **`mps` 는 스텁** (`print_to_mps` 가 `NotImplementedError("MPS backend not yet implemented")`, AIR 비트코드를 목표로 한다고 docstring). `xdsl-opt` 의 대상 목록: csl, llvm, mps, riscv-asm, wgsl, x86-asm. SPIR-V, MSL, PTX 방출기는 없음.
+- [V] gpu dialect(796줄)는 launch/func/module/global_id/barrier/subgroup_id 등 기본 op 위주이며, `subgroup_mma` 계열 op 이름은 상위 60 개 심볼 출력에서 확인되지 않음 [V 한계: 전체 grep 하지 않음]. 협력 행렬 관련 dialect 는 `grep` 상 없음.
+
+## 2. 선택지 비교
+
+### 옵션 1. 완전 자체 IR + 자체 방출기
+
+- 대상별 경로 [I]:
+ - SPIR-V: 우리 IR -> SPIR-V 워드를 직접 방출(binary writer 수백 줄 + 구조화 제어 흐름(OpLoopMerge/OpSelectionMerge), 메모리 모델, Capability/Extension 선언, 협력 행렬 `OpCooperativeMatrix*KHR`). `spirv-val` 로 검증 가능.
+ - MSL: 같은 IR 에서 텍스트 방출. 어느 옵션이든 MSL 은 우리가 쓴다(아래 참조), 자체 IR 에서는 **가장 자연스럽다**.
+ - WGSL: 텍스트 방출. SPIR-V 로 가는 것보다 단순.
+ - PTX: 텍스트 방출(작은 부분집합) 또는 LLVM 에 의존. 자체 PTX 방출은 레지스터 할당/주소 공간이 일.
+ - CPU SIMD: C(벡터 확장)로 방출, 이는 §4.3 에서 이미 정한 `typed IR -> C` 와 합칠 수 있다 [R typedpython.md §4.3].
+- 타일 변환: **전부 직접.** 레이아웃 추론, 소프트웨어 파이프라인, swizzle, autotune 대체물. 이것이 가장 큰 일이다 (이전 조사 §5.5).
+- 도구 무게: 최소. 순수 파이썬 코드, 3.15t 문제 없음 [I].
+- 위험: 수년 걸리는 변환 인프라를 혼자 쌓는다. 반면 MSL/SPIR-V 직접 제어 가능, 디버깅이 쉽다.
+- 첫 슬라이스 (f16 elementwise 또는 softmax -> SPIR-V -> `vulkan.rs` 디스패치 -> CPU 참조와 비교): 파이썬 AST -> 타입 IR(어차피 필요), SPIR-V 방출 약 800 ~ 1500 줄, 호스트 하니스. 추정 2 ~ 4 주(1인) [I, 측정 아님]. f16 은 `Float16` + `StorageBuffer16BitAccess` capability 필요(기기 의존).
+- 타일 matmul + cooperative matrix: IR 에 tile 타입과 `mma` op, `OpCooperativeMatrix*` 방출, 공유메모리 타일링, 정밀한 레이아웃. 추정 2 ~ 4 개월 [I]. Adreno/Mali 에서는 기본값 아님(선택 경로) [V 이전 조사, llama.cpp PR 요약].
+
+### 옵션 2. MLIR (upstream, 파이썬 바인딩, C++ 코어)
+
+- 획득 [V §1.1]: PyPI 에 없음. `llvm/eudsl` release 의 nightly wheel(매일, cp314t 포함, macOS arm64 / Linux x86_64+aarch64 / Windows x86_64). 커밋 해시가 버전에 붙는 **nightly 스냅샷**이라 안정 릴리스 번호가 없고 고정하려면 특정 날짜 wheel URL 을 lockfile 에 박아야 한다 (uv 의 `--find-links` 또는 URL 의존성). 직접 빌드는 LLVM 전체 빌드(`MLIR_ENABLE_BINDINGS_PYTHON=ON`, nanobind) 이며 시간/디스크가 크다 [I, 빌드해 보지 않음].
+- 버전 churn: 매일 갱신되는 upstream main 추종. Python 바인딩 API, dialect op 이름, 패스 옵션이 자주 바뀐다 (예: 검색에서 확인된 SPIR-V KHR CooperativeMatrix Load/Store Stride 옵션화 PR #214194, 협력 행렬 operand 수 수정 PR #118014) [V 검색요약]. 완화: 특정 날짜 wheel 로 고정하고 분기별로 올리는 정책 [I].
+- 3.15t: **wheel 없음** (cp314t 까지) [V]. 기반이 nanobind 라 소스 빌드로 3.15t 가능성은 있으나 미확인 [I]. 사용자 목표가 3.15t 이므로, 컴파일러(빌드 타임)를 3.14t 또는 GIL 파이썬 별도 인터프리터에서 돌리고 앱 런타임과 분리하는 것이 현실적 [I]. 공식 문서가 free-threading 에서 GPU dialect/PassManager 동시 사용을 안전하지 않다고 명시한 점도 단일 스레드 컴파일로 우회 [V 문서].
+- 대상별 경로:
+ - SPIR-V: gpu/vector/arith/memref/scf -> `convert-gpu-to-spirv` -> `spirv` dialect -> `gpu-module-to-binary` 로 직렬화 [V 부분: 각 단계 개별 실행. 연결은 접합 코드 필요]. 협력 행렬: `gpu.subgroup_mma_*` 가 `spirv.KHR.CooperativeMatrixMulAdd` 로 내려가는 변환이 있다는 포럼 글 [V 검색요약], `spirv.KHR.CooperativeMatrix*` op 은 설치본에 존재 [V]. 실제로 f16 협력 행렬 matmul 이 변환을 통과해 Adreno/Mali 에서 도는지는 **검증 못함**.
+ - MSL: **upstream 에 MSL 타깃 없음**. 우리가 확인한 설치본에서 dialect 목록(spirv, gpu, nvvm, amdgpu, emitc 등)에 metal/msl 이 없다 [V 목록 확인]. 외부 사례만 있음: Triton 외부 백엔드(triton-ext PR #126, bledden/triton-msl)가 TTGIR 에서 MSL 을 방출하는 패스를 가진다 [V 검색요약]. 가능한 길 둘: (a) `emitc` dialect 로 C++ 형 텍스트를 뽑는 구조를 흉내 내 **MSL 프린터를 우리가 작성**, (b) SPIR-V -> SPIRV-Cross -> MSL (협력 행렬은 simdgroup_matrix 8x8 float 로만 제한되고 최근 initial 지원 단계 [V 검색요약, SPIRV-Cross PR #2645]; Metal 4 cooperative tensor / matmul2d 는 도달 불가 [V 이전 조사]). 따라서 Apple 은 어느 쪽이든 MSL 방출기를 직접 쓴다 [I].
+ - WGSL: upstream 에 WGSL 타깃 없음 [V 목록]. SPIR-V -> naga 또는 직접 방출.
+ - PTX: **되는 것을 확인** [V §1.1, `t_ptx.py`]. 가장 성숙한 경로.
+ - CPU SIMD: `vector` + `llvm` dialect -> LLVM IR -> ExecutionEngine/오브젝트 [V: `mlir.execution_engine` 임포트됨. 실제 NEON 오브젝트 방출은 시험 안 함]. 또는 `emitc` 로 C. iOS 앱엔 LLVM 을 싣지 않고 빌드 타임에 오브젝트/라이브러리만 [I].
+- 타일/레이아웃/파이프라인 변환: **여기가 MLIR 의 최대 이점.** `linalg`(structured ops) + `transform` dialect(`structured.tile_using_for`, vectorize 등), `vector.contract` 패턴, `gpu` dialect 의 mma/예약 [V 검색요약 + 설치본에 `_structured_transform_ops_gen`, `_gpu_transform_ops_gen`, `_nvgpu_transform_ops_gen` 존재 [V]]. 소프트웨어 파이프라이닝(cp.async 급) 은 nvgpu 쪽에 일부, Vulkan 쪽은 IREE 가 가진 별도 코드젠(IREE 의 SPIR-V 파이프라인은 upstream 이 아님 [V iree.dev 요약])이라 upstream 만으로 Vulkan 에서 Triton/IREE 급 matmul 성능이 나온다는 보장은 없다 [I].
+- 프런트엔드 접합: 파이썬 AST -> MLIR 를 `ir.Module`/`OpBuilder` 로 구성. "valid Python 그대로 실행" 요건은 프런트엔드가 AST 를 읽는 방식이므로 충돌하지 않는다 [I].
+- 도구 무게: wheel 66 ~ 76 MB / 설치 253 MB, 빌드 타임 전용이므로 앱 크기와 무관. 단 uv/pypackpack 설치 흐름에 PyPI 가 아닌 외부 index(find-links) 가 들어가고 날짜 고정이 필요하다 [V + I].
+- 위험: (1) nightly 스냅샷 의존과 API churn, (2) 3.15t wheel 부재, (3) eudsl 이 자체를 "alpha" 로 표기 [V], (4) MSL/WGSL 은 어차피 직접 방출, (5) 디버깅이 C++ 패스 안에서 일어나 에러 메시지가 불친절 [I], (6) wheel 이 단일 메인테이너 프로젝트(makslevental -> llvm/eudsl) 의 유지에 달림 [I].
+- 첫 슬라이스: 접합 코드(gpu.module 에 spirv.module + target_env/vce 부착) + 프런트엔드 -> MLIR 빌더 + 형상/버퍼 ABI 정리(`spirv.interface_var_abi`, push constant 는 MLIR 에서 `spirv.StorageClass PushConstant` 구성 필요 [I]). 추정 2 ~ 4 주 [I]; 환경/버전 고정과 f16/ABI 문제 때문에 옵션 1 과 비슷하거나 약간 더 걸릴 수 있다. 이득은 슬라이스 이후.
+- 타일 matmul + cooperative matrix: 변환 파이프라인을 upstream 패스로 조립. Vulkan KHR 협력 행렬 경로는 존재하나 성능 튜닝 정도는 모름. 추정 1 ~ 3 개월, 하지만 위험이 옵션 1 보다 "변환이 안 맞을 때 고칠 수 없음" 쪽에 쌓인다 [I].
+
+### 옵션 3. xdsl (순수 파이썬 MLIR 호환)
+
+- [V §1.2] 설치 쉬움(PyPI, 의존 3개, 3.10 ~ 3.14t), 활발(2~4 주 릴리스), 라이선스 Apache-2.0 with LLVM Exception. 3.15t 는 미시험이나 순수 파이썬이라 [I] 문제 가능성 낮음.
+- MLIR 텍스트 호환: `xdsl-opt` 로 MLIR 텍스트를 읽고 쓴다(프린터/파서). 따라서 **xdsl <-> MLIR(mlir-opt, 위 wheel) 상호운용을 텍스트로 연결**할 수 있다 [I, 두 도구를 실제로 연결해 보지 않음]. 즉 옵션 3 은 옵션 2 와 배타적이지 않다: 프런트엔드/고수준 변환은 xdsl, SPIR-V 직렬화나 NVPTX 는 MLIR wheel 에 텍스트로 넘기는 혼합 구성이 가능 [I].
+- 대상별 경로:
+ - SPIR-V: **방출기 없음, spirv dialect 도 없음** [V]. 텍스트를 MLIR 에 넘겨야 하거나 SPIR-V 방출을 직접 써야 한다.
+ - MSL: 없음. `mps` 백엔드는 AIR 비트코드를 목표로 한 NotImplemented 스텁 [V].
+ - WGSL: 있음(252줄) 단 범위는 작음(소스에서 `gpu.FuncOp`, `memref.StoreOp` 처리 등 기본 연산) [V 부분: 코드 헤드만 확인, 지원 범위 전체는 미확인].
+ - PTX: 없음. LLVM IR 텍스트 변환(`xdsl.backend.llvm`)은 있어 `llvm` 의 NVPTX 로 이어 붙일 수 있으나 llvmlite 등 별도 필요 [I].
+ - CPU SIMD: `vector`, `x86`, `arm_neon`, `riscv`, `llvm` 백엔드 등 있음. 실사용 성숙도는 미확인.
+- 변환: `transform` dialect(1042줄) 와 `vector`(1470줄), `linalg` 가 있으나 upstream MLIR 의 `structured.*` 변환 패스 수준인지 확인 못 함. 협력 행렬/타일링 패스 존재는 확인 못 함 [V 한계]. 패스를 파이썬으로 쓰므로 새 패스 작성/수정은 쉬움(`pattern_rewriter`) [I].
+- 성숙도: 연구/컴파일러 교육 중심 프로젝트(스텐실, CSL, RISC-V/Snitch 등) 이며 GPU 코드젠 프로덕션 사례는 확인 못함 [I, 근거: dialect 구성].
+- 위험: 필요한 후단(SPIR-V/MSL/PTX 방출, 협력 행렬 dialect)이 거의 비어 있어, 옵션 1 에서 "IR 클래스 + 패스 인프라 + 프린터" 를 xdsl 로 빌려 오는 정도의 이득. IR 인프라(SSA, 파서, verifier, 패턴 리라이터, 정확성 검사)를 공짜로 얻는 점이 장점 [I].
+- 첫 슬라이스: 옵션 1 과 같은 SPIR-V 방출기를 어차피 써야 하므로 추가 이득은 IR 인프라 재사용뿐. xdsl 학습 비용 + 방출기 직접 작성 -> 2 ~ 4 주 [I]. Cooperative matrix: spirv dialect 를 xdsl 에 추가(IRDL 로 정의 가능 [I]) + 직렬화기 작성 필요. 옵션 1 대비 이득 작음.
+
+### 기타 (간단히)
+
+- Triton 의 MLIR dialect(TTIR/TTGIR) 재사용: 서브모듈/LLVM 포크에 묶여 있고 JIT 전제, Apple MSL 은 외부 포크(triton-ext)가 TTGIR -> MSL 을 했다 [V 검색요약]. 우리 파이썬-서브셋 프런트엔드와 TTGIR 의 레이아웃 인코딩을 쓰려면 Triton 빌드 전체가 따라온다 [I]. 비추천.
+- IREE: SPIR-V 코드젠(Mali Valhall, Adreno 640+ 대상 triple) 과 Vulkan 런타임을 가진 AOT 컴파일러 [V iree.dev 요약]. 입력은 모델 단위(linalg/StableHLO)이고 산출물은 `.vmfb`(flatbuffer) 이며 SPIR-V 만 라이브러리로 뽑아 쓰는 공식 경로는 확인하지 못했다 [V 부재는 검색 한계]. 타일링/매트릭스 파이프라인이 가장 성숙한 오픈 구현이므로 **알고리즘 참조 대상**, 의존 대상은 아님 [I].
+- Mojo: 닫힘, MAX 가 Apple GPU 초기 [V 이전 조사].
+- SPIRV-Cross(MSL): 협력 행렬 초기 지원, simdgroup_matrix 는 8x8 float 계열만 [V 검색요약]. 즉 SPIR-V 경유 Apple 은 f16 matmul 에서 불리할 수 있고 Metal 4 tensor ops 는 불가 [V 이전 조사 + I].
+
+## 3. 요약 표
+
+| 항목 | 1. 자체 IR | 2. MLIR wheel | 3. xdsl |
+|---|---|---|---|
+| SPIR-V 방출 | 직접 작성 | **있음** (gpu/spirv dialect + `gpu-module-to-binary`, 접합 필요) [V] | 없음 [V] |
+| MSL | 직접 (자연스러움) | upstream 없음, 직접 [V] | 없음, mps 는 AIR 스텁 [V] |
+| WGSL | 직접 | upstream 없음 | 있음(소형) [V] |
+| PTX | 직접(작게) | **있음, 실행 확인** [V] | 없음 (llvm 텍스트 경유 [I]) |
+| CPU SIMD | C 벡터 확장 | vector+llvm / emitc [I] | arm_neon/x86/llvm 있음 [V 존재] |
+| 타일/레이아웃/파이프라인 | 전부 직접 | linalg+transform+vector+gpu 가 큼 [V 존재, 성능은 미확인] | transform/vector 있음, 수준 미확인 |
+| 설치 | 없음 | wheel 66~76MB, PyPI 아님, nightly find-links [V] | `uv add xdsl` [V] |
+| 3.14t | 해당 | **wheel 있음, GIL 유지 off** [V] | **됨** [V] |
+| 3.15t | 문제 없음 [I] | **wheel 없음** [V] | 가능성 높음 [I] |
+| 버전 churn | 없음 | 높음 (매일) [V] | 중 (2~4 주 릴리스, 0.x) [V] |
+| 첫 슬라이스 | 2~4 주 [I] | 2~4 주 [I] | 2~4 주 [I] |
+| 타일 matmul + coop mat | 2~4 개월 [I] | 1~3 개월 (고장 시 수정 불가 위험) [I] | 2~4 개월 [I] |
+
+## 4. 권고 (불확실성 명시)
+
+1. **IR 의 정본은 우리가 소유한다(옵션 1 의 IR), 단 MLIR 호환 형태로 설계한다.** 이유: (a) §2.1 요건과 프런트엔드는 우리 몫이며 어떤 외부 IR 도 대신하지 못한다 [I], (b) MSL, WGSL 은 MLIR 을 써도 직접 써야 하므로 MLIR 이 줄여 주는 방출기는 SPIR-V 와 PTX 둘뿐이다 [V], (c) 3.15t wheel 이 없고 nightly 고정이라 도구 체인의 기초로 삼기에는 변동이 크다 [V]. 이 판단은 "방출기가 일의 대부분" 이라는 가정에 의존한다 [I].
+2. **MLIR wheel 은 "SPIR-V 와 PTX 직렬화기/검증기, 그리고 비교 오라클" 로 선택적 사용을 우선 시험한다.** 우리 IR 을 MLIR 텍스트로 내리는 얇은 방출기(수백 줄)만 쓰면, `convert-gpu-to-spirv` + `gpu-module-to-binary` 로 SPIR-V 를, `gpu-module-to-binary{format=isa}` 로 PTX 를 얻는다 [V 각 단계 개별]. 막히면 직접 SPIR-V 방출로 후퇴할 수 있어 위험이 낮다. 3.15t 와는 별도 프로세스(3.14t 또는 GIL 인터프리터) 로 분리 실행 [I].
+3. **타일 변환(tiling, 협력 행렬, pipelining) 에서 MLIR 의 transform/linalg/vector 를 쓸지는 슬라이스 2 이후 별도 시험으로 결정.** 지금은 약속하지 않는다. 이 영역이 MLIR 의 진짜 이점이지만 Vulkan 에서의 성능은 IREE 쪽 코드젠에 있다 [I, V 검색요약].
+4. **xdsl 은 채택하지 않되 IR/패스 인프라 설계의 참고, 그리고 MLIR 텍스트 상호운용 후보로 남긴다.** 후단이 비어 있어 SPIR-V/MSL/PTX 에 이득이 없다 [V].
+5. **Apple 은 MSL 직접 방출.** 이전 조사와 동일 결론이고 이번에도 어느 옵션도 대신하지 않는다 [V + I].
+
+미결/불확실: (a) `gpu.func` 하향 결과를 `gpu-module-to-binary` 로 연결하는 접합이 실제로 쉬운지 (실행 안 함), (b) f16/협력 행렬 SPIR-V 가 실기(Adreno, Mali)에서 도는지, (c) wheel 이 NEON/x86 CPU 오브젝트를 만들 수 있는지(LLVM 타깃 구성 미확인; NVPTX 는 확인), (d) 3.15t 에서의 MLIR 소스 빌드 가능성, (e) eudsl wheel 의 장기 유지, (f) 시간 추정은 모두 측정 아닌 추정.
+
+## 5. 다음 시험 (구체적 첫 슬라이스)
+
+1. `t_spirv.py` 를 확장: 하향된 `spirv.module` 을 `gpu.module [target_env]` 안에 옮기고 `requires` 를 붙여 직렬화, `spirv-val` 로 검증 (`spirv-val` 은 이 환경에 설치하지 않았음).
+2. 그 `.spv` 를 `torch_c/shaders/` 의 체크인 모양대로 넣고 `dispatch_kernel_words` 경로로 실행, CPU 참조(torchnative flash.rs 식 허용 오차 비교)와 대조. 이 단계는 torchnative 쪽 빌드가 필요하므로 이번 조사에서는 하지 않았다.
+3. 같은 커널을 직접 방출한 SPIR-V 와 크기/명령어/기기 호환성 비교(옵션 1 대비 MLIR 산출물의 위험 평가).
+
+## 6. 이 조사 방법이 찾지 못하는 것
+
+wheel 설치와 개별 패스 실행으로 "존재와 단일 단계의 동작" 만 확인했으므로, 연결된 전체 파이프라인의 정확성, 실기 GPU 드라이버 동작, SPIR-V 산출물의 성능, 장기 API 안정성, 그리고 xdsl/MLIR 변환 패스의 실제 품질은 찾지 못한다 (시간 추정도 측정이 아니다).
diff --git a/docs/investigations/typedpython-ownership-memory.md b/docs/investigations/typedpython-ownership-memory.md
new file mode 100644
index 000000000..ab4b9f940
--- /dev/null
+++ b/docs/investigations/typedpython-ownership-memory.md
@@ -0,0 +1,292 @@
+# TypedPython: ownership inference 와 메모리 (Rust 대비) 조사
+
+결론의 반영: `docs/design/typedpython.md` §4.3.5, 결함 #193 (2026-10-05). 메인테이너 결정: D1 예, D2 예(조건부), D3 예(조건부), D4 아니오, D5 결함 수정, D6 예(앱 배포 빌드만), D7 예, D8 지금은 아니오. 측정에 쓴 스크립트와 원자료(`.tmp/research/`)는 커밋하지 않았다.
+
+날짜 2026-10-05. 태그: [V] 가져온 URL 또는 실행한 명령(근거 표기), [R] 로컬 파일을 읽음, [I] 추론.
+측정 환경: Apple Silicon macOS (Darwin 25.5.0), CPython 3.14.7 (GIL), 3.14.7t, 3.15.0rc1 (GIL), 3.15.0rc1t.
+설치는 `uv python install` 로 .tmp/research/pyinst 에 했다. 모든 스크립트와 원자료는 .tmp/research/ 아래에 있다
+(measure.py, measure.out, rssdelta.py, rssdelta.out, rss.out, cext/, rs/).
+
+## 0. 요약
+
+1. 소유권 추론이 줄이는 것은 **시간**(refcount 연산, 할당)이고, 장기 상주 데이터의 **크기**는 거의 줄이지 못한다.
+ 장기 메모리는 객체 표현(박싱된 float 32 B, free-threaded 에서는 헤더 32 B)이 정한다 (B.1 표).
+2. N-11 클래스를 non-GC 로 만들면 GIL 빌드에서 객체당 16 B(48 → 32 B)를 얻는다 [V cext/t.out]. 그러나
+ **free-threaded 3.15t 에서는 0 B** 다 (GC 비트가 ob_gc_bits 로 헤더에 들어가 있어서, 48 == 48) [V cext/t.out].
+ 우리 대상은 3.15t 뿐이므로 non-GC 의 메모리 이득은 사실상 없고, 얻는 것은 GC 추적·순회 시간뿐이다.
+3. 큰 이득은 **float/int 슬롯을 객체 안에 unbox** 하는 것이다 (두 float 필드 객체 157 B → 약 48 B, 3.3x, 3.15t 기준).
+ 그러나 이는 `a.x is a.x` 를 거짓으로 만들고 [V], `PyType_FromSpec` 으로 컴파일러가 타입을 직접 만들어야 한다.
+ 둘 다 사용자 결정이 필요하다.
+4. **free-threaded 에서는 N-12 의 "borrowed 읽기" 근거가 무너진다** (A.2 (4)). 현재 문서는 "GIL 을 놓지 않으므로
+ 미지 코드 호출이 없으면 슬롯이 안 바뀐다" 에 기대는데, 3.15t 에서는 다른 스레드가 동시에 슬롯을 바꾼다. [I]
+5. 설치 부작용: `uv python install` 이 `~/.local/bin/python3.14t`, `python3.15t` 심볼릭 링크 두 개를 저장소 밖에
+ 만들었다 (규칙 2 위반, 의도하지 않음). 사용자 승인으로 2026-10-05 에 지웠다. 다음 조사는 `uv python install` 대신 저장소 안 venv 만 쓴다.
+
+## Part A. 소유권 추론 ("GC 를 스마트 포인터로")
+
+### A.1 선행 사례 조사
+
+| 시스템 | 메커니즘 | 언어에 요구하는 것 | 보고된 수치 | 근거 |
+|---|---|---|---|---|
+| Koka, Perceus | 정확한(precise) RC: 마지막 사용 직후 drop, 소유 참조를 이동하고 나머지만 dup. drop specialization, reuse analysis(해제될 셀을 같은 크기의 새 할당에 재사용), FBIP | 순수 함수형. 불변 데이터, 즉시 평가라서 순환이 없고 순환이 생길 수 있는 곳(ref)이 정적으로 알려져 있음 (논문 2.7.4). 타입과 효과가 추적됨 | 할당 집약 벤치에서 OCaml, Haskell, Swift, Java 와 비교해 "outstanding"; 트리 삽입(rbtree)에서 순수 함수형 Koka 가 in-place C++ std::map 의 10% 이내. no-opt 대비 reuse 로 메모리 8% 적음(rbtree 계열) | [V] https://www.microsoft.com/en-us/research/wp-content/uploads/2020/11/perceus-tr-v1.pdf (pdftotext 로 읽음: perceus.txt 줄 63 부터, 841, 1141) |
+| Lean 4 | 소유(owned)/빌림(borrowed) 파라미터, 빌림 추론 휴리스틱, reset/reuse(공유되지 않았으면 제자리 재사용), 값에 single-threaded/multi-threaded/persistent 태그 | 순수 함수형, 즉시 평가 | 기준 대비 상대 시간 (ablation, lean.txt 표 6): `-borrow` 를 끄면 binarytrees 약 1.14x, deriv 1.16x 느림; `-reuse` 를 끄면 const_fold 1.64x, rbmap 3.23x; `-ST`(원자 RC 강제) 는 binarytrees 1.22x ~ rbmap 1.71x. 단 표 숫자가 PDF 추출에서 일부 깨져 소수 둘째 자리는 신뢰하지 않는다 | [V] https://arxiv.org/abs/1908.05647 (PDF https://arxiv.org/pdf/1908.05647 를 읽음) |
+| Lobster | 할당마다 단일 소유자(보통 처음 대입된 변수, 필드, 벡터 원소)를 고르고 이후 사용을 가능한 한 borrow 로 만든다. 다른 곳에서 소유하려 할 때만 증가를 삽입. 런타임 RC 는 폴백으로 남음 | 정적 타입. 프로그래머 개입은 대체로 불필요 | "런타임 refcount 연산의 약 95% 제거" | [V] https://aardappel.github.io/lobster/memory_management.html |
+| Nim ARC/ORC | 소멸자와 move 기반. 정적 제어 흐름 분석으로 "복사원이 이후 쓰이지 않으면 move", cursor 추론으로 복사 대신 별칭. ORC 는 ARC 위에 순환 수집기(trial deletion). 순환이 불가능한 타입은 수집기가 간선으로 보지 않음, `{.acyclic.}` 로 프로그래머가 보증 가능, 거짓 보증은 누수만 일으킴 | 정적 타입, 값 의미론 | 수치 없음 (문서와 블로그에 벤치마크가 없다) | [V] https://nim-lang.org/docs/destructors.html (move, cursor), 순환 부분은 검색 결과 요약: https://nim-lang.org/docs/mm.html (검색 결과 목록에서 확인, 해당 페이지를 직접 내려받지는 않음. 2차 확인 필요) |
+| Swift ARC, OSSA | SIL 에서 값에 ownership 종류(owned / guaranteed / none / unowned)를 부여해 retain/release 쌍을 최적화기가 안전하게 제거. 변수 수명 전략 3종(strict, lexical, optimized) | 값 타입 위주, 클래스는 RC. 순환은 weak/unowned 를 프로그래머가 써야 함 | 문서에 수치 없음. Lean 논문 표에서 Swift 5.1.1 은 binarytrees 에서 Lean 의 약 5.4x 시간, 59% 가 GC(RC) 시간으로 표기됨 | [V] https://github.com/swiftlang/swift/blob/main/docs/SIL/Ownership.md (ownership 종류), 수치는 [V] lean.txt 의 비교 표 |
+| Mojo | 인자 규약: 기본은 불변 참조(read), `mut` 가변 참조, `var` 소유권 이전(`^` 로 명시 이동), `out`/`deinit` 특수. ASAP 소멸: 마지막 사용 직후 소멸(스코프 끝이 아님), origin 이 참조의 수명을 추적 | 소유권을 시그니처에 **직접 적는다** (추론이 아니라 선언) | 수치 없음 | [V] https://mojolang.org/docs/manual/values/ownership , https://mojolang.org/docs/manual/values/lifetimes |
+
+읽은 결과 공통점 [I]: 이득이 큰 시스템(Koka, Lean, Lobster)은 **불변 데이터 또는 단일 소유자 가정** 위에 있다.
+파이썬 객체는 변경 가능하고 별칭이 자유로워서 (1) borrowed 로 증명할 수 있는 범위가 좁고, (2) 순환을 정적으로 배제할 수 없다.
+Mojo 처럼 소유권을 선언하게 하는 길은 "새 문법 없음" 에 막힌다.
+
+### A.2 우리 설계에 사상
+
+#### (1) 컴파일된 함수 사이의 owned/borrowed 파라미터와 반환 추론
+
+- **진입 규약.** 인터프리터에서 들어오는 인자는 vectorcall 규약상 호출자가 소유한다. 즉 컴파일된 함수 입구에서는
+ 모든 객체 파라미터가 borrowed 다. 그러므로 "owned 로 받는다" 는 컴파일된 함수끼리의 내부 ABI 에서만 의미가 있다. [I]
+- **추론 규칙(Lean 식).** 파라미터는 borrowed 로 시작하고, 함수 안에서 (a) 슬롯·전역·컨테이너에 저장되거나 (b) 반환되거나
+ (c) owned 파라미터 자리로 넘겨질 때만 owned 로 승격한다. 승격 지점에서만 INCREF 를 한 번 낸다. 반환은 항상 owned (새 참조)로
+ 둔다. borrowed 반환은 수명 추적 없이는 증명할 수 없어 하지 않는다 (Lean 도 반환은 owned). [I]
+- **현재 설계와의 관계.** 4.3.4 나 절의 "빌린 읽기" 는 바로 이 규칙의 특수 경우다 (`check(node.left)`: 부모가 소유되고 callee 가
+ 슬롯을 쓰지 않으면 무연산). IR 의 N-9 소유 규칙(OBJ 참조는 하나의 소유 규칙)과 순수성 분석이 이미 필요한 정보를 갖고 있다. [R]
+- **기대 효과.** Lobster 95%, Lean ablation 1.14 ~ 1.16x 는 **함수형 언어에서 RC 가 지배 비용인 경우**다. 우리는 refcount 쌍이
+ 3 ~ 4 ns (7절 R-1 측정)인 반면 객체 할당과 해제는 이보다 크다 (바이너리 트리의 1.7x 격차가 할당 때문이라는 #125 가설이
+ 있고 아직 프로파일 전이다 [R 설계 문서 4.3.4]). 따라서 refcount 생략은 가상 객체·영역 할당보다 **2차적**이라고 본다. [I]
+ 이 순서 판단은 측정으로 확인하지 못했다.
+- **abi3t 의 영향.** 3.15 헤더에서 abi3t 는 `Py_INCREF`/`Py_DECREF`/`Py_REFCNT` 를 함수 호출로 만든다 [V src/refcount.h.main 줄
+ 101, 159, 260, 330 의 주석]. 그래서 쌍 하나를 빼면 호출 둘을 아낀다. 단 limited API(0x030c) 로 GIL 3.14 에서 측정한 쌍 비용은
+ 4.12 ns 로 인라인 3.96 ns 와 큰 차이가 없었다 [V cext/rc.out]. abi3t + 3.15t 조합은 컴파일하지 않았다 (측정 안 함).
+- **관측성.** refcount 는 `sys.getrefcount` 와 미지 코드 호출에서만 보인다. 미지 호출 앞에서 쌍을 복원하면 관측 불가. [R 4.3.4 나]
+ 따라서 이 추론 자체는 **사용자 결정 없이** 동일성 규칙 안에서 구현 가능하다 (단 A.2 (4)의 free-threaded 조건 아래에서).
+
+#### (2) N-11 클래스가 비순환임을 증명하고 `Py_TPFLAGS_HAVE_GC` 없이 만들기
+
+사실 확인(전부 실행):
+
+| 항목 | GIL 3.14.7 | 3.14.7t | 3.15.0rc1t | 근거 |
+|---|---|---|---|---|
+| 파이썬 `class S: __slots__=("x","y")` 인스턴스 `gc.is_tracked` | True | True | True | [V cext/t.out] |
+| `__slots__ = ()` 빈 클래스 인스턴스 `gc.is_tracked` | True | True | True | [V cext/t.out, S0] |
+| `PyType_FromSpec` 으로 만든 non-GC 타입 인스턴스 `gc.is_tracked` | False | False | False | [V cext/t.out, NonGcObj] |
+| 그 인스턴스 `gc.get_objects()` 에 나타남 | 아니오 | 아니오 | 아니오 | [V] |
+| `sys.getsizeof` : 파이썬 클래스(슬롯 2) / non-GC 타입 | 48 / 32 | 48 / 48 | 48 / 48 | [V] |
+| 슬롯 2개 non-GC 타입에서 자기참조 1000개를 `gc.collect()` 후 dealloc 횟수 | 0 (누수) | 0 | 0 | [V]; GC 타입은 1000 |
+
+- **파이썬 `class` 문으로는 non-GC 가 불가능하다.** `type_new_alloc` 이 모든 힙 타입에 `Py_TPFLAGS_HAVE_GC` 를 건다
+ [R src/typeobject.c.314 줄 4183 ~ 4199, 주석 "All heap types need GC"]. 실제로 빈 슬롯 클래스도 추적된다 (위 표). [V]
+- **따라서 컴파일러가 타입을 `PyType_FromSpec`(또는 `PyType_FromMetaclass`)로 직접 만들어야 한다.** 스펙이 주는 플래그가 그대로 쓰여
+ `HAVE_GC` 를 뺄 수 있음을 실험으로 확인했다 [V cext/ngc.c, t.out]. 문서도 GC 플래그가 켜지는 조건을
+ "순환에 참여할 수 있는 참조를 소유하거나 traverse/clear 를 구현할 때" 로 설명한다 [V https://docs.python.org/3.14/c-api/typeobj.html].
+- **뒤따르는 관측 차이.** 컴파일러가 만든 타입은 클래스 사전에 `__new__` 가 더 있고 `__slots__` 속성이 없다 (`hasattr(T,"__slots__")`
+ False) [V]. `__flags__`, `__basicsize__`, `sys.getsizeof`(GIL 에서 16 B 작음), `gc.is_tracked`, `gc.get_objects` 가 파이썬 클래스와
+ 다르다. `__slots__`·`__init__`·`__module__`·`__qualname__`·`__doc__` 를 복사해 대부분은 맞출 수 있지만 `gc` 관련 네 항목은 **정의상**
+ 달라진다. 컴파일된 모듈이 소스를 먼저 실행해 만든 원래 클래스와 이 타입을 어떻게 바꿔치기할지 (전역 이름 교체, deopt 용 인터프리터
+ 본문이 쓰는 클래스) 도 설계 문제다. [I]
+- **비순환 증명의 한계(핵심).**
+ - 주석의 타입은 런타임에 강제되지 않는다. 슬롯이 `PyObject*` 이면 인터프리터 쪽 코드가 `n.next = n` 처럼 무엇이든 넣을 수 있다.
+ non-GC 타입 안의 순환은 영원히 수집되지 않고 (위 표 마지막 행), **다른 객체의 `__del__`/weakref 콜백도 영원히 안 돈다**. 이는 GC 시점
+ (결정 (b))이 아니라 **수집 자체가 없는** 것이라 동일성 규칙 밖으로 나가는지 별도 판단이 필요하다. [I]
+ - `Node(left: Node, right: Node)` 처럼 타입이 자기 자신을 닫는 재귀 클래스는 타입 그래프에서 이미 순환 가능하므로 증명 대상이 아니다
+ (binary-trees 의 Node 는 안 된다). 비순환으로 증명되는 것은 필드 타입이 float/int/bool/str/None 이거나, 다른 비순환 N-11 클래스의
+ DAG 인 클래스뿐이다. [I]
+ - 그 경우도 `PyObject*` 슬롯에는 런타임 타입 위반을 막는 setter 가 없다. 안전하려면 슬롯이 **참조를 못 담는** 표현(unbox 된 double/i64)이거나
+ 저장 시 정확한 타입 검사를 해야 한다. 검사하면 CPython 이라면 성공했을 잘못된 타입 대입이 예외가 된다 (결정 필요, B.5 D3).
+ - 서브클래스: `BASETYPE` 를 켜고 파이썬에서 상속하면 서브타입은 GC 가 되지만 기반 타입의 `tp_traverse` 가 없어 기반 슬롯의 참조를 순회하지 못한다.
+ 그래서 non-GC 타입은 final 이어야 하고 (`class Sub(Node)` 가 CPython 과 달리 `TypeError`), 이것도 관측 차이다. [I, 실험 안 함]
+- **절약 추정.** GIL: 객체당 16 B (슬롯 2개 객체 48 → 32 B, -33%) [V]. 3.15t: 0 B [V]. 시간: GC 할당 카운터·추적·untrack 이 사라진다 (측정 안 함).
+
+#### (3) refcount 1 일 때 제자리 재사용(Perceus)과 관측성
+
+- **가능한 곳.** 컴파일된 함수가 **직접 소유한** (자기가 만들었거나 owned 로 돌려받은) N-11 객체가 죽는 지점 바로 뒤에 같은 클래스 객체를
+ 만들 때 (루프에서 `n = Node(...)` 를 반복 대입하는 형태). 인자로 들어온 객체는 borrowed 이므로 `Py_REFCNT == 1` 이어도 재사용하면 안 된다
+ (호출자의 지역 변수가 그 하나를 쥐고 있다). [I]
+- **CPython 3.14 의 우연한 해석 차이.** 인터프리터의 stackref borrow(LOAD_FAST_BORROW) 때문에 인터프리터 쪽 refcount 는 의미상 참조 수보다 작을 수 있다.
+ 우리는 공개 API 의 `Py_REFCNT` 만 쓰므로 "내가 소유한 참조가 유일" 만 쓰면 된다. [I, 이 부분 문서 확인 안 함: whatsnew 3.14 에는 해당 서술이 없었다 [V]]
+- **free-threaded 의 유일성 검사.** 내부 판정은 `owned by current thread && ob_ref_local == 1 && ob_ref_shared == 0` [R src/pycore_object.h.314 줄 184 ~ 196].
+ 공개 형태 `PyUnstable_Object_IsUniquelyReferenced` 는 비 stable ABI 헤더(cpython/object.h)에 있다 [R src/object.h.314 줄 493]. abi3t 에서는 쓸 수 없고,
+ 쓰려면 "내가 소유한 유일 참조" 를 컴파일러 증명으로 보장하고 `Py_REFCNT(o) == 1` 을 보조 확인으로 쓴다. [I]
+- **관측 지점(전부 [I]).**
+ 1. `id()`: 같은 주소가 다시 나오지만 CPython 도 해제 직후 할당에서 같은 주소를 자주 재사용하므로 유효한 실행이다.
+ 2. `gc.get_objects()` 순서와 세대 소속: 재사용 객체는 옛 위치와 세대에 남는다 (새 할당은 젊은 세대 끝). GC 시점 결정 (b) 와 같은 범주로 볼 수 있으나 `get_objects(generation=0)` 은 직접 관측 가능.
+ 3. `tracemalloc.get_object_traceback`: 재사용 객체는 이전 할당 위치를 보고한다.
+ 4. `__del__`/weakref 클래스: 이미 N-12 대상에서 제외됨.
+ 5. 자식 refcount: 슬롯 덮어쓰기에서 자식 decref 는 원래 해제와 같은 시점에 한다.
+- **절약.** 할당·해제 호출과 0 채우기 생략. 메모리 크기에는 영향 없음. Lean ablation 에서 reuse 가 const_fold 1.64x, rbmap 3.23x [V] 이지만 이는 불변 함수형 트리 재구성 패턴이고, 파이썬의 변경 가능 객체에는 같은 비율이 나온다는 근거가 없다. [I]
+
+#### (4) free-threaded 3.14t/3.15t
+
+- **헤더 배치** [V src/object.h.top314 줄 152 ~ 163]:
+
+ uintptr_t ob_tid; // 소유 스레드 id (GC 와 trashcan 이 연결 리스트·gc_refs 로도 재사용)
+ uint16_t ob_flags;
+ PyMutex ob_mutex; // 객체별 락
+ uint8_t ob_gc_bits; // GC 상태 (PyGC_Head 를 대체)
+ uint32_t ob_ref_local; // 소유 스레드의 비원자 카운트
+ Py_ssize_t ob_ref_shared;// 다른 스레드의 원자 카운트 (+ 상태 비트)
+ PyTypeObject *ob_type;
+
+ GIL 빌드의 16 B 헤더(+ GC 객체는 앞에 16 B GC 헤더)에 비해 free-threaded 는 32 B 헤더이고 GC 헤더가 없다. 측정에서 float 24 → 40 B, `object()` 16 → 32 B
+ [V measure.out]. 그래서 GC 객체는 크기가 같고 (48 == 48), 비 GC 객체(int, float, non-GC 타입)는 free-threaded 에서 커진다.
+- **biased refcount.** 소유 스레드는 비원자 `ob_ref_local`, 다른 스레드는 원자 `ob_ref_shared` [V PEP 703 https://peps.python.org/pep-0703/].
+ 측정(INCREF+DECREF 한 쌍, 3.15t): 생성한 스레드 소유 객체 3.19 ns, **다른 스레드가 만들고 끝난 객체 14.24 ns**, 불멸(None) 0.63 ns.
+ GIL 3.14: 3.96 ns, 불멸 0.35 ns [V cext/rc.out, 로드 평균 약 2, N=5천만, 3회 중 최소]. 즉 스레드 사이를 오가는 객체에서는 쌍 하나가 4.5배 비싸다.
+- **deferred refcount.** 최상위 함수, 코드 객체, 모듈, 메서드처럼 여러 스레드가 자주 접근하는 객체는 스택 push/pop 에서 RC 를 건너뛰고 GC 때 참 카운트를 계산한다
+ [V PEP 703]. N-11 인스턴스에 적용 가능한지는 확인하지 못했다.
+- **immortalization.** `ob_ref_local == UINT32_MAX` 이면 INCREF/DECREF 는 무연산 [V PEP 703, src/refcount.h.314 줄 76, 110]. 할당기는 mimalloc [V PEP 703].
+- **단일 스레드 오버헤드.** 3.14 whatsnew: free-threaded 의 단일 스레드 페널티 약 5 ~ 10% [V https://docs.python.org/3.14/whatsnew/3.14.html]. PEP 703 은 6%(Skylake) ~ 5%(Zen 3).
+- **소유권 추론이 free-threaded 에서 아끼는 것.**
+ - 공유 객체(스레드를 건너는 객체)를 borrowed 파라미터로 받으면 원자 연산 쌍(14 ns) 전체를 없앤다. 가장 큰 이득이 나는 자리다. [I, 측정 14.24 vs 3.19 에 근거]
+ - 스레드 로컬 객체는 쌍이 3.2 ns 라 이득이 GIL 빌드와 비슷하다.
+ - 불멸 객체(None, 작은 정수, 인턴 문자열)는 이미 0.6 ns.
+- **중요한 위험.** 현재 설계의 borrowed 읽기 근거는 "컴파일된 코드는 GIL 을 놓지 않으므로 미지 코드 호출이 없으면 슬롯이 안 바뀐다" [R 4.3.4 나]. free-threaded
+ 에서는 GIL 이 없으므로 다른 스레드가 `node.left = None` 을 **동시에** 실행해 borrowed 로 쥔 자식을 해제할 수 있다 (use-after-free). 따라서 3.15t 에서
+ (a) 호출자가 쥐고 있는 파라미터 자체는 여전히 borrowed 가능, (b) 공유될 수 있는 객체의 **필드 읽기**는 실제 참조 증가(소유 스레드면 3.2 ns)나 임계구역이 필요하고,
+ (c) borrowed 필드 읽기는 "탈출하지 않은 스레드 로컬 객체"(가상 객체, 영역 객체, 이 호출에서 만들고 아직 저장하지 않은 객체)에만 허용된다. [I]
+ 같은 이유로 N-12 가상 객체와 영역은 free-threaded 에서도 안전하지만(스레드 로컬이라) 현재 문서의 "다른 스레드는 GIL 때문에 그 사이를 보지 못한다" 문장은 3.15t 에서 거짓이다.
+ 문서 4.3.4 나 의 마지막 문단("free-threaded 빌드는 범위 밖이다")과 이번 제약("3.15t 전용")이 충돌하므로 갱신이 필요하다.
+
+## Part B. 메모리: CPython 대 Rust
+
+### B.1 측정표
+
+모든 수치는 요소(또는 객체) 하나당 바이트. "요청" 은 tracemalloc 이 본 파이썬 할당기 요청 바이트에서 리스트 슬롯 8 B 를 뺀 값, "RSS" 는 100만 개(dict 는 10만 개)를 만든 뒤
+maxrss 증가를 개수로 나눈 값으로 **리스트 슬롯 8 B 포함** 이다 (측정 방식이 달라 같은 줄이어도 다른 열은 직접 비교하지 않는다). 소스: measure.out, rssdelta.out.
+
+| 항목 | getsizeof 3.14 GIL / 3.14t | 요청 3.14 / 3.14t / 3.15t | RSS 3.14 / 3.14t / 3.15t | Rust |
+|---|---|---|---|---|
+| int (고유, 30비트 초과) | 28 / 44 | 32 / 48 / 48 | 42.4 / 64.2 / 64.8 (리스트 `[int]` 원소당) | i64 = 8 |
+| float | 24 / 40 | 24 / 40 / 40 | 42.4 / 64.2 / 64.7 (리스트 `[float]` 원소당) | f64 = 8 |
+| `object()` | 16 / 32 | 16 / 32 / 32 | | `()` 0, `Box` 힙 최소 16 (할당기 단위, 측정 안 함) |
+| `__slots__` 2 float 필드 클래스 (필드 값 float 제외) | 48 / 48 | 48 / 48 / 48 | float 2개 포함 122.6 / 157.5 / 157.3 | struct{f64,f64} = 16 (Vec 안에 인라인), `Vec>` 원소당 24 [V rs/m] |
+| 일반 클래스 2 필드 (필드 값 제외, 인라인 values) | 48 (+dict 288 가상) / 48 | 88 / 88 / 88 | float 2개 포함 170.9 / 204.2 / 203.9 | 16 |
+| `list` 100만 int, 원소당 | getsizeof 8.4 (포인터만) | 40.4 / 56.4 / 56.4 (슬롯 포함) | | `Vec` 8 [V rs/m] |
+| dict 10만 `str -> int`, 항목당 | getsizeof 38.4 (테이블만) | 120.5 / 152.5 / 152.5 | 171.2 / 170.4 / 171.2 | `HashMap` 55.3 요청 바이트 [V rs/m] |
+| 유휴 인터프리터 `python -c pass` maxrss | | | 16.3 MiB (16656 KiB) / 17.3 MiB (17744) / 3.15: 16.6 MiB (16976) / 3.15t: 18.0 MiB (18400) | 빈 `main` 1.4 MiB (1440 KiB) |
+| 같은 명령의 peak memory footprint | | | 6.3 MiB / 7.8 MiB / 6.4 MiB / 8.2 MiB | 측정 안 함 |
+
+- **GC 헤더 포함 여부.** getsizeof 는 GIL 빌드에서 GC 헤더 16 B 를 더해 보고한다: 슬롯 클래스 48 = 헤더 16 + GC 헤더 16 + 슬롯 16, 같은 레이아웃의 non-GC 타입은 32 [V cext/t.out].
+ free-threaded 는 둘 다 48.
+- **Rust 쪽 수치 출처.** 위 Rust 값은 규칙 계산이 아니라 `rs/m.rs` 를 `rustc -O` 로 컴파일해 전역 할당기로 요청 바이트를 센 값이다 [V `rs/m` 실행].
+ `HashMap` 의 capacity 는 114688 로 출력되었다. hashbrown 은 버킷당 제어 바이트 1 B 를 쓰고 적재율이 7/8 이므로 131072 버킷 x (String 24 + i64 8 + 1) 약 4.3 MB 이고,
+ 키 문자열 9 B 가 따로 힙에 있어 항목당 약 55 B 가 나온다 [V README 줄 35 "only 1 byte of overhead per entry instead of 8" https://github.com/rust-lang/hashbrown; 계산은 [I]].
+ 실제 RSS 는 힙 할당기 단위(16 B)로 올림되어 이보다 크다 (측정 안 함).
+- **배율(위 표에서 계산).** float 리스트 원소: GIL 약 5x (40/8), 3.15t 약 7 ~ 8x (56 ~ 64/8). 두 float 필드 객체: RSS 기준 GIL 7.7x (122.6/16), 3.15t 9.8x (157.3/16);
+ Rust 가 박싱해도(24) 5 ~ 6.6x. dict 항목: GIL 2.2x (120.5/55.3), 3.15t 2.8x (152.5/55.3). 유휴: 약 12x (16.3/1.4 MiB).
+- **측정의 한계.** RSS 증가는 `ru_maxrss` 기반이라 단조 증가 값이다. 측정은 한 번에 하나씩 했고 로드 평균은 약 2 였다. 분산(반복)은 유휴 RSS 3회만 기록했다(거의 동일).
+ `-I -S -OO` 실행에서 3.14 가 기준보다 큰 18016 KiB 였던 것은 최적화 레벨 2 의 pyc 가 없어 소스를 컴파일했기 때문일 것으로 [I] 보며 확인하지 않았다.
+
+### B.2 현재 설계가 줄이는 것과 줄이지 못하는 것
+
+| 현재 설계 요소 | 줄이는 것 | 줄이지 못하는 것 |
+|---|---|---|
+| 가상 객체 + 영역 (N-12) | 컴파일 구간 안에서만 사는 임시 객체의 할당과 해제 전체 (객체 하나당 48 B 와 할당 비용) | 컨테이너·전역·필드에 저장되는 장기 객체, 인터프리터로 넘어가는 객체 (실체화되면 위 표 그대로) |
+| unbox 된 i64/f64 지역 변수 | 지역 스칼라의 박싱 (float 한 개 32 B, 3.15t 40 ~ 48 B) | 슬롯, 리스트, dict 에 저장되는 값은 저장 시점에 박싱 |
+| `list[float]`/`list[int]` 네이티브 배열 | 컴파일 구간 안의 복사본 | 인터프리터가 쥔 `list` 자체 (원소당 40 ~ 56 B) |
+| N-11 고정 레이아웃 | dict 288 B 가상 크기와 인라인 values 40 B 를 슬롯 16 B 로 | 슬롯 값은 여전히 `PyObject*` 라서 float 필드마다 박싱 32 ~ 48 B 가 따로 든다 |
+| 소유권/refcount 생략 | 시간 | 크기 |
+| 인터프리터 기본 비용 | 없음 | 16 ~ 18 MiB 유휴 RSS (Rust 1.4 MiB) |
+
+### B.3 대안 표 (장기 메모리를 줄이면서 동일성 유지)
+
+절약 추정은 모두 위 표의 숫자에서 계산한 값이며 구현해 보지 않았다.
+
+| 번호 | 대안 | 절약 추정 (객체당, 3.15t / GIL) | 관측 위험 (동일성 규칙 대비) | 필요한 사용자 결정 |
+|---|---|---|---|---|
+| O1 | N-11 슬롯의 float/i64 unbox (엄격형). `Py_T_DOUBLE` 멤버 | 두 float 필드 객체 157 B 에서 48 B (3.3x) / 122.6 에서 40 B (3.1x) | **관측됨**: `a.x is a.x` False, `id(a.x)` 매 읽기마다 달라짐 [V cext/t.out DblObj]; `a.x = 1` 은 int 가 float 으로 바뀜(`type(a.x)`), 문자열 대입은 `TypeError`; i64 슬롯은 큰 정수·bool 을 못 담음; 타입이 컴파일러 생성 (아래 O5) | D1, D2, D3 |
+| O2 | 같은 슬롯을 NaN-boxing 으로 (double 또는 `PyObject*` 태그). 비 float 값은 포인터로 저장 | O1 과 같은 크기 (슬롯은 8 B). GC 타입 유지 시 GIL 은 40 에서 56 B | `is`/`id` 만 관측됨 (float 읽기마다 새 객체). 타입 보존·임의 대입 가능. 단 `tp_traverse` 가 태그를 구분해야 하고 PyPy 의 방식과 같다 [I]. 3.15t 에서 매 읽기마다 새 float 할당(40 B 단명, 민감 경로에서 느려질 수 있음, 측정 안 함) | D1, D2 |
+| O3 | N-11 클래스를 non-GC 로 (O1 과 결합할 때만 건전) | GIL 16 B (33%) / 3.15t 0 B [V] | `gc.is_tracked`, `gc.get_objects`, `sys.getsizeof`(GIL) 가 정의상 달라짐 [V]; final 이어야 함 [I]; `PyObject*` 슬롯과 결합하면 순환 누수로 건전성 상실 | D2, D3, D4 |
+| O4 | 장기 데이터를 소스에서 `array.array('d')`/numpy 같은 압축 컨테이너로 쓰도록 안내 (엔진 변경 없음) | float 리스트 원소 56 ~ 64 B 에서 8 B (7 ~ 8x) | 엔진은 건드리지 않으므로 없음. 사용자 코드가 타입을 바꾸는 선택 (원소 `is` 동일성도 같이 바뀜) | 정책 결정 (검사기에서 `list[float]` 경고 여부) |
+| O5 | `PyType_FromSpec` 로 컴파일러가 N-11 타입을 직접 생성 (O1 ~ O3 의 전제) | 자체로는 0 | `__dict__` 항목, `__flags__`, `__basicsize__`, `__slots__` 속성, 서브클래싱, pickle/copy/dataclass 연동, 인터프리터 폴백 때 쓰는 클래스와의 일치 | D2 |
+| O6 | 인터프리터 기반 비용: `-S` (site 생략), frozen stdlib 유지(기본 on), 지연 import | `-S`: 16656 에서 15632 KiB (-1.0 MiB), 모듈 33 개에서 21 개 [V]; `-X frozen_modules=off` 는 21120 KiB (+4.4 MiB) 라서 frozen 을 반드시 켜 둘 것 [V] | 없음 (site 가 하는 일을 앱이 필요로 하지 않을 때). `sys.path` 에 site-packages 가 없어짐 | 임베딩 시 `-S` 허용 여부 |
+| O7 | `PYTHONMALLOC=malloc` | 0 (유휴 +0.5 MiB, float/객체 RSS 동일) [V] | 없음 | 불필요 (효과 없음) |
+| O8 | free-threaded 의 mimalloc 설정, 3.15t 대신 GIL 빌드 | 3.15t 의 float 40 B / 객체 헤더 32 B 의 추가 비용은 빌드 선택으로만 줄어듦 (GIL 은 float 24, int 28) | 빌드 선택은 제약 "3.15t 전용" 과 충돌 | 제약 변경 여부 (권고하지 않음) |
+| O9 | 인터프리터 풋프린트: 유휴 16 ~ 18 MiB 는 libpython 매핑과 초기 모듈이 대부분 | 줄일 수 있는 것은 O6 정도 (-1 MiB), 나머지는 못 줄임 | | 없음 |
+
+### B.4 권고
+
+1. **소유권 추론(A.2 (1))은 구현해도 된다.** 관측 가능한 변화가 없고 사용자 결정이 필요 없다. 단, 3.15t 용 조건(A.2 (4)의 borrowed 필드 읽기 제한)을 설계에 먼저 반영한다. 크기에는 도움이 안 되고 시간에만 도움이 된다고 기대치를 낮춘다.
+2. **non-GC 클래스(O3)는 단독으로는 하지 않는다.** 3.15t 에서 메모리 이득이 0 이고 (표 3.15t 열), 건전성이 슬롯 표현에 달려 있다. O1 과 한 묶음일 때만 의미가 있다.
+3. **장기 메모리 이득의 본체는 O1/O2 (슬롯 unbox)와 O4 (압축 컨테이너)** 다. O4 는 결정이 거의 필요 없으므로 먼저 문서화·검사기 경고로 시작하고, O1/O2 는 `is`/`id` 결정 후 진행한다.
+4. **O1 보다 O2 를 먼저 검토한다.** O2 는 관측 차이를 `is`/`id` 하나로 좁히고 O1 의 타입 강제 문제를 없앤다. 대가는 읽기마다 float 할당과 `tp_traverse` 복잡도다.
+5. 가상 객체와 영역(N-12)이 장기 데이터에 안 닿는다는 점을 SPEC N-12 에 한 줄로 못박아 기대 관리를 한다.
+
+### B.5 필요한 사용자 결정
+
+- **D1** float/int 슬롯 값의 객체 동일성(`a.x is a.x`, `id(a.x)`)을 동일성 보증 범위에서 뺄 것인가. 근거 문장: 데이터 모델 3.1 은 불변 타입에서 "새 값을 계산하는 연산"이 기존 객체를 돌려줄 수 있다고만 허용한다 [V https://docs.python.org/3/reference/datamodel.html]. 속성 읽기는 "새 값 계산" 이 아니므로 읽을 때마다 새 float 를 주는 것은 엄밀히는 허용 밖이다. [I] GC 시점 (b) 결정과 같은 방식의 명시적 예외가 필요하다.
+- **D2** 컴파일러가 N-11 타입을 `PyType_FromSpec` 로 직접 만들고 클래스 문 결과를 대체해도 되는가 (`__flags__`/`__basicsize__`/`gc` 관측 차이 허용 범위).
+- **D3** 슬롯 타입 강제(`o.x = "s"` 가 `TypeError`) 허용 여부. O1/O3 에는 필요하고 O2 에는 불필요하다.
+- **D4** non-GC 타입을 final 로 만들어 서브클래싱을 막아도 되는가 (O3 한정).
+- **D5** 다른 스레드 동시 접근 가능성이 있는 3.15t 에서 N-12 4.3.4 나 의 borrowed 읽기 근거를 어떻게 바꿀 것인가 (스레드 로컬 증명만 허용 / 실제 incref 유지). 설계 문서 수정 승인.
+
+## 7. 부록: 직접 측정한 보조 수치
+
+- R-1 INCREF+DECREF 쌍(ns) [V cext/rc.out, cext/rc.c, N=5천만, 최소값]:
+
+ | 빌드 | 소유 스레드 객체 | 다른 스레드가 만든 객체 | 불멸(None) |
+ |---|---|---|---|
+ | 3.14.7 GIL (인라인) | 3.96 | 3.95 | 0.35 |
+ | 3.14.7 GIL (limited API 0x030c, 함수 호출) | 4.12 | 4.14 | 2.22 |
+ | 3.14.7t | 3.21 | 14.20 | 0.63 |
+ | 3.15.0rc1t | 3.19 | 14.24 | 0.63 |
+- R-2 인터프리터 3.14 `-X frozen_modules=off`: 21120 KiB, `-S`: 15632 KiB, `-S -E`: 15632 KiB; 3.14t `-S -E`: 16880 KiB; 3.15t `-S -E`: 17424 KiB [V rss.out].
+- 3.14.0 ~ 3.14.4 의 증분 GC 는 3.14.5 에서 되돌려졌다고 whatsnew 가 말한다 [V https://docs.python.org/3.14/whatsnew/3.14.html]. 이 연구는 3.14.7 과 3.15.0rc1 에서 했다.
+
+## 8. 이 방법이 찾지 못하는 것 (Part C 포함)
+
+단일 머신 마이크로벤치와 합성 클래스(float 2개)로 구조 크기만 쟀으므로, 실제 앱의 객체 구성·할당기 단편화·멀티스레드 경합 아래의 실효 절약과 (O2 의 읽기당 새 float 할당 같은) 시간 비용, 3.15t + abi3t 로 컴파일한 확장의 실제 refcount 호출 비용은 찾지 못한다.
+
+## Part C. 인터프리터는 갱신 가능한 코드에만, 그 밖에는 최소 풋프린트
+
+원자료: rssC.out, impcost.py, release.py, rel2.py, lazy.py/eager.py. 측정은 모두 같은 머신, 한 번에 하나씩, 3.14.7 / 3.14.7t / 3.15.0rc1 / 3.15.0rc1t.
+주의: 새 설치 직후 첫 실행은 stdlib pyc 를 쓰느라 RSS 가 +8 ~ 9 MiB 크게 나온다 (3.14t 에서 -S -I +imports 가 38144 KiB 로 나왔다가 재측정에서 29280 KiB). 아래 표는 pyc 가 만들어진 뒤 정상 상태의 값이다.
+
+### C.1 유휴와 소규모 앱의 RSS (maxrss KiB / peak memory footprint KiB) [V rssC.out]
+
+| 빌드 | `-c pass` | `-S -I -c pass` | `-S -I` + json,re,dataclasses,typing,asyncio | site 포함 + 같은 import | `-S -I -OO` + 같은 import |
+|---|---|---|---|---|---|
+| 3.14 GIL | 16640 / 6433 | 15632 / 5697 | 26160 / 14785 | 26624 / 15249 | 25808 / 14449 |
+| 3.14t | 17744 / 7953 | 16880 / 7329 | 29392 / 18577 | 29472 / 18641 | 28576 / 17745 |
+| 3.15 GIL | 16864 / 6449 | 15904 / 5793 | 27248 / 15649 | 27280 / 15649 | 26736 / 15137 |
+| 3.15t | 18416 / 8353 | 17424 / 7617 | 30384 / 19201 | 30720 / 19505 | 29712 / 18529 |
+
+- import 5개의 증가분: GIL 3.14 +10.5 MiB, 3.14t +12.5 MiB, 모듈 21개에서 147개(126개 추가). json 하나만: +2.3 MiB (GIL), re+typing+dataclasses: +5.1 MiB. [V]
+- Rust 빈 `main` 은 1.4 MiB (Part B). `-S -I` 유휴는 그것의 약 11 ~ 12배다. [V]
+
+### C.2 바닥(floor)을 이루는 것
+
+| 구성 | 크기 | 근거 |
+|---|---|---|
+| 파이썬 힙(pymalloc)에 살아 있는 객체 | `-S -I` 유휴에 1,022,416 B, 블록 12,370 개, 1 MiB 아레나 2개(사용 가능 블록 261,808 B). GC 추적 객체 4,484 개, 대부분 `wrapper_descriptor` 1094, `method_descriptor` 797, `builtin_function_or_method` 694, `function` 672, `dict` 617 (내장 타입과 모듈의 메서드 표) | [V `sys._debugmallocstats`, `gc.get_objects`] |
+| 물리 풋프린트(private/dirty) | GIL 5.7 MiB, 3.14t 7.3 MiB (`-S -I`). RSS 와의 차이 약 10 MiB 는 libpython(18.8 MB 파일)과 실행 파일·공유 캐시의 clean 파일 매핑 페이지다. 앱마다 중복되지 않는 공유 가능 페이지라서 "앱이 더하는 비용" 은 풋프린트 쪽이 맞다 | [V rssC.out, vmmap Physical footprint 8.6M(subprocess 를 import 한 측정이라 위 표보다 큼)]. 차이의 원인 서술은 [I] |
+| 힙 밖 C 할당 | vmmap 상 MALLOC_SMALL+TINY 이 GIL 에서 약 2.7 MiB dirty (인터프리터 상태, 인턴 테이블 등으로 추정), 3.14t 에서는 0.6 MiB 이고 대신 풋프린트 합이 커짐 (mimalloc 이 VM 영역을 직접 씀) | [V vmmap -summary], 용도 추정은 [I] |
+| free-threaded 추가분 | 유휴 +1.1 MiB RSS, +1.6 MiB 풋프린트. 객체 헤더 32 B 와 float 40 B 등 (Part B), 스레드별 mimalloc 힙 | [V], 스레드별 힙의 몫은 분리 측정하지 못함 [I] |
+| import 한 모듈의 사전·코드 객체 | 5개 import 후 tracemalloc 으로 6.0 MiB (GIL) / 7.1 MiB (FT) 가 파이썬 할당기에 남음. 이 중 코드 객체 4,013 개의 `getsizeof` 합 1.67 MiB, 그 중 line table + exception table 0.69 MiB (코드 객체 크기의 41%). 나머지는 모듈 사전, 함수·클래스 객체, 상수, 확장 모듈(`_json`, `_asyncio` 등)의 C 상태 | [V impcost.py]. 나머지 분해는 [I] |
+
+결론 [I]: 유휴 바닥(약 5.7 MiB 풋프린트)은 CPython 런타임이 정하고 앱이 줄일 수 있는 부분이 거의 없다. 앱이 영향을 줄 수 있는 쪽은 **무엇을 import 하느냐**(최대 +10 ~ 12 MiB)다.
+
+### C.3 항목별 평가
+
+| 항목 | 절약 추정 | 동일성 위험 | 노력 | 검증 상태 |
+|---|---|---|---|---|
+| 임베딩 초기화 최소화: `PyConfig_InitIsolatedConfig` + `site_import=0` + 명시적 `module_search_paths` | `-S`: RSS -1.0 MiB (16640 → 15632 KiB), 풋프린트 -0.7 MiB, 모듈 33 → 21 [V]. 격리 설정 자체는 환경 변수, argv, 사용자 site 를 무시 [V https://docs.python.org/3.14/c-api/init_config.html] | `sys.path` 에 site-packages 가 없다. `quit`/`exit`/`help` 내장이 없다. `sys.flags` 가 달라진다. 의존 패키지 경로를 우리가 구성해야 한다 (이미 Gradle 플러그인이 PYTHONHOME 을 스테이징하므로 자연스러움) | 낮음 | 절약 [V 측정], 위험 [I]. 같은 페이지에서 가져온 "최소 구성 예제" 코드는 `PyConfig` 와 `PyInitConfig_SetInt` 를 섞어 쓰는 것으로 읽혀 요약기의 합성일 수 있어 사용하지 않았다 |
+| frozen stdlib / 최소 stdlib | 기본값 `frozen_modules` 켬이 이미 이득: 끄면 +4.4 MiB (16656 → 21120 KiB, 앞 측정). 추가 이득은 "안 쓰는 모듈 제외" 인데 RSS 는 import 한 만큼만 늘므로 디스크는 줄어도 RSS 는 import 안 한 모듈에서는 안 줄어든다 | import 하지 않을 모듈을 삭제하면 `ImportError` 가 달라진다. 닫힌 세계 증명이 있어야 함 | 중간 (이미 아티팩트 워커가 의존 그래프를 안다) | 켬/끔 차이 [V], 삭제의 RSS 이득이 거의 없다는 것은 [I] |
+| PEP 810 명시적 lazy import | 3.15t 에서 다섯 모듈을 쓰지 않을 때 30272 → 17536 KiB (-12.7 MiB, -42%), 3.15 GIL 27328 → 16000 KiB. json 만 실제로 쓰면 20528 KiB [V lazy.py 등]. 3.15 에 실제로 들어갔다 [V PEP 810 상태 Final, Python-Version 3.15, 결정일 2025-11-03 https://peps.python.org/pep-0810/ ; whatsnew 3.15 가 `lazy import json` 와 `-X lazy_imports`, `PYTHON_LAZY_IMPORTS`, `sys.set_lazy_imports()` 를 서술 https://docs.python.org/3.15/whatsnew/3.15.html] | 사용자가 `lazy` 를 쓴 곳은 사용자 선택이라 위험 없음. 컴파일러가 임의로 lazy 로 바꾸면 import 부작용의 순서와 `ImportError` 발생 시점이 달라져 관측됨. 전역 모드(`-X lazy_imports`)는 같은 이유로 권하지 않음 | 낮음 (사용자 코드), 높음 (자동 변환) | 절약·상태 [V], 위험 [I]. 3.14 에는 없다 (우리는 3.15t 전용이라 무관) |
+| docstring 제거(-OO), 줄 표 제거 | `-OO`: RSS -0.35 MiB (GIL), -0.8 MiB (3.14t) [V]. `-X no_debug_ranges` 를 더해도 RSS 변화가 관측되지 않았다 (26144 vs 25808 KiB, 3.14t 28848 vs 28576, 잡음 범위) [V]. line+exception tables 합 0.69 MiB 가 상한 | `-OO` 는 docstring 뿐 아니라 `assert` 문도 제거한다 (의미 변경, 관측됨). `__doc__` 이 None 이 되어 `dataclasses`, `argparse`, 일부 라이브러리가 달라진다. 줄 표를 지우면 traceback 의 줄 번호가 사라진다 (관측됨) | 낮음 | 절약 [V 소규모], 위험 [I]. 권고하지 않음 (이득이 작고 위험이 큼) |
+| `gc.freeze` | 단일 프로세스 RSS 이득 없음. 목적은 `fork()` 후 copy-on-write 공유와 GC 순회 감소다 [V https://docs.python.org/3.14/library/gc.html]. free-threaded GC 는 메모리가 10% 늘지 않으면 수집을 건너뛴다 [V 같은 페이지] | `gc.get_freeze_count()`, `gc.get_objects()` 에 영향. 앱이 안 쓰면 없음 | 낮음 | [V 문서], 이득 없음 판단은 [I] |
+| 해제한 메모리를 OS 에 반환 | **GIL (pymalloc)**: 100만 객체 해제 후 풋프린트 125.8M → 20.7M 로 돌아옴 (빈 아레나 해제) [V rel2.py]. 단 1% 만 흩어 남기면 아레나가 묶여 RSS 144 MiB 가 그대로 [V release.py]. **free-threaded (mimalloc)**: 같은 시험에서 158.7M → 158.9M, 즉 **반환되지 않음**. `MIMALLOC_PURGE_DELAY=0` 을 줘도 같음 (환경 변수를 이 빌드가 읽는지는 확인 못함) [V rel2.py, release.py]. libpython3.14t 는 `mi_collect` 류 심볼을 내보내지 않는다 [V nm -gU]. CPython 은 mimalloc 힙 수집을 QSBR 판정용 내부 경로에서만 부른다 [R src/obmalloc.c 줄 224] | 없음 (할당기 동작은 파이썬 의미가 아님) | FT: 높음. 직접 빌드한 CPython 에서 `mi_collect(true)` 를 노출하거나 `purge_delay` 를 설정해야 한다 (PBS 배포 바이너리로는 불가) | 측정 [V], 원인과 해법은 [I] |
+| 컴파일 모듈이 deopt 용으로 들고 있는 소스·바이트코드의 지연 로드 | 코드 객체 평균 약 425 B (1.67 MiB / 4013), 그 41% 가 위치·예외 표 [V]. 컴파일 함수 1000 개면 약 0.4 MiB 로 바닥 대비 작다 [I]. 모듈 본문은 import 때 한 번 실행되고 그 코드 객체는 보관되지 않으므로 줄일 수 있는 것은 함수 코드 객체뿐이다 [I] | deopt 첫 호출이 느려진다. `__typedpython_interpreted__` 의 함수를 지연 생성 객체로 바꾸면 그것을 검사하는 코드가 다르게 본다 (드묾). FT 에서 지연 로드는 동기화가 필요하다 | 중간 | 절약 [I], 크기 통계 [V] |
+| FastTrack: 갱신된 모듈은 인터프리터로, 건드리지 않은 모듈은 컴파일 상태로 | 모듈 단위는 N-10 증분 단위와 같아 구조적으로 맞는다 [R 설계 문서 4.3.2]. 그러나 컴파일 모듈이 소스를 먼저 실행해 네임스페이스를 만든다는 현재 설계 [R 4.3 "모듈 모양"] 때문에 컴파일된 모듈도 인터프리터와 소스(또는 pyc)를 import 때 쓴다. 인터프리터 자체는 컴파일 확장이 C API 와 파이썬 힙 위에 산다는 제약상 제거할 수 없다. 즉 바닥 5.7 MiB 풋프린트는 이 아이디어로 줄지 않는다 [I]. 줄일 수 있는 것은 "건드리지 않은 모듈의 인터프리터용 소스·코드 객체를 아예 안 싣는 것" 인데, 그러려면 컴파일러가 클래스·함수 정의를 C 초기화 코드로 내보내야 한다 | 클래스 생성 의미(메타클래스, `__init_subclass__`, 데코레이터 순서)를 C 로 재현해야 하므로 위험이 크다. 갱신 모듈과 컴파일 모듈 사이의 가정은 N-11 의 `tp_version_tag` 와 전역 바인딩 확인이 이미 일반 경로로 떨어뜨린다 [R 4.3.3] | 높음 | [I] 전부. 현재 코드가 실제로 그렇게 동작하는지는 확인하지 않았다 |
+
+### C.4 권고 (Part C)
+
+1. **먼저 할 것(위험 없음, 낮은 노력)**: 격리 구성과 `site_import=0` (약 1 MiB), `frozen_modules` 유지, 사용자 코드에서 `lazy import` 안내와 검사기 경고 (큰 라이브러리 import 가 12 MiB 까지 좌우).
+2. **하지 않을 것**: `-OO` (assert 제거), 줄 표 제거, 컴파일러 자동 lazy 변환, `gc.freeze` (이득이 작거나 의미가 바뀐다).
+3. **3.15t 에서 가장 큰 구조적 문제는 메모리 반환**이다. 피크 후 해제한 메모리가 돌아오지 않는다 (158.9M 유지). 장시간 실행 앱에는 피크 = 상주다. CPython 을 직접 빌드해 mimalloc 수집을 노출할 수 있는지가 열린 질문이다.
+4. "인터프리터는 갱신 코드에만" 은 현재 제약(컴파일 확장이 CPython 힙 위)에서는 **풋프린트 바닥을 줄이지 못한다**. 얻는 것은 모듈 단위의 코드 객체와 소스 지연 로드 정도(수 MiB 이하)다.
+
+### C.5 이 절의 사용자 결정
+
+- **D6** 임베딩에서 `site` 를 끄고 `module_search_paths` 를 우리가 구성해도 되는가 (`quit`/`exit`/`help` 내장 부재 허용).
+- **D7** 3.15t 용 CPython 을 직접 빌드(또는 패치)해 mimalloc 반환을 제어해도 되는가 (PBS 바이너리 의존 탈피).
+- **D8** FastTrack 에서 컴파일 모듈의 인터프리터용 정의를 제거하는 설계(클래스·함수를 C 초기화로 내보냄)를 진행할 가치가 있는지.
diff --git a/docs/investigations/typedpython-vs-cython-numba-lpython.md b/docs/investigations/typedpython-vs-cython-numba-lpython.md
new file mode 100644
index 000000000..5851dc555
--- /dev/null
+++ b/docs/investigations/typedpython-vs-cython-numba-lpython.md
@@ -0,0 +1,159 @@
+# TypedPython against Cython, numba and LPython (design-level comparison)
+
+Requested by the user (2026-10-04, via the leader): compare the current TypedPython design with
+Cython, numba and LPython **logically first**; measurements come later (the harness is on
+`feat/bench-cython-numba`, not yet measured). Issues: #41, #125.
+
+Evidence tags: **[doc]** stated in the tool's own documentation (links at the end), **[src]** read in
+this repository's code, **[measured]** a number from our own runs (2026-10-03 quiet window, develop
+e888e7f5, macOS arm64), **[inferred]** reasoning, not verified.
+
+## Conclusion
+
+**Where TypedPython wins**
+
+1. **Deployment.** It is ahead-of-time C that becomes a CPython extension, built with the target's own C compiler. So it runs on iOS, on Android, and next to a GraalVM native image. [src] [doc]
+ - numba compiles at run time through LLVM, and its AOT path (`numba.pycc`) is deprecated with no replacement yet [doc]. It needs LLVM on the device, which rules out iOS. [inferred]
+ - LPython AOT produces standalone binaries, and its `@lpython` JIT needs a C compiler at run time [doc]. Neither fits an app that embeds CPython. [inferred]
+ - Cython has the same deployment model as TypedPython. [doc]
+2. **CPython identity by default.** The others give up semantics either by default or once you type the code.
+ - What TypedPython keeps:
+ - int overflow promotes to a big int, via deopt [src];
+ - bounds checks stay unless proven [src];
+ - exceptions are CPython's own [src];
+ - signals and GIL hand-off work since #141 phase 1 [src].
+ - numba: integers are fixed width and wrap; bounds checks are off by default; globals are frozen at compile time. [doc]
+ - Cython: typed C integers wrap unless `overflowcheck=True` (off by default). [doc]
+ - LPython: requires its own fixed-width types (`i32`, `f64`). [doc]
+3. **No new syntax, gradual by function.**
+ - TypedPython takes types from ordinary annotations plus Pyrefly's inference. A function it cannot prove stays interpreted, with a reason. [src]
+ - Cython needs `cdef` or `cython.*` types to leave the object path. [doc]
+ - LPython needs its own type names. [doc]
+ - numba needs no annotations, but rejects code outside its subset with a `TypingError`. [doc]
+4. **Kotlin interop inside compiled code.** No other tool has a counterpart. [src]
+
+**Where TypedPython loses, and the design item that would close each gap**
+
+| Gap | Who wins it | Closing design item | Identity cost |
+|---|---|---|---|
+| Per-call overhead on small leaf functions (spectral-norm's `eval_a`): `tp_enter_call`/`tp_leave_call` on every call, probably no inlining | numba and Cython inline small functions [inferred] | Depth precheck plus a fast entry for callees outside every cycle, with a fallback to the per-call path (#41 plan), then let the C compiler inline | none |
+| Checked i64 arithmetic in hot integer code | numba, Cython typed, LPython (unchecked) | Interval proofs that drop provably safe checks (for example `i + j` with both below `len(list)`), and a shift for a floor division by a power of two of a proven non-negative value | none |
+| Lists are copied in and written back, O(n) per call | numba (numpy arrays) and Cython (memoryviews) are zero-copy [doc] | Accept buffer-protocol arrays (`array.array`, numpy, memoryview) as zero-copy `ArrayParam`. Since no user code runs while the array is live (the existing contract), direct access is observably the same | none |
+| Strings and dicts (wordfreq is 1.1x [measured]) | numba `typed.Dict` [doc], Cython C-level code [inferred] | An exact-type `dict[str, int]` and `str` fast path that calls the same C API (`PyDict_GetItem`/`SetItem` on exact `dict` and exact `str` keys, so no user `__hash__` or `__eq__` runs) | none |
+| Object allocation (binary-trees is 1.7x [measured]) | Cython `@cython.freelist` [doc]; numba keeps objects out of CPython's GC [doc] | N-12 virtual objects (#143) | **GC timing**, a user decision |
+| Float reductions are not vectorised | numba `fastmath=True`, Cython `-ffast-math` (both opt-in) [doc] | None that keeps identity: reassociating float sums changes results. Integer reductions and element-wise loops still vectorise. An opt-in `fastmath` would break identity, so it is a user decision | **yes**, if offered |
+| Parallel loops | numba `parallel=True`/`prange`, Cython `prange` with OpenMP [doc] | After free-threaded support: parallel loops whose result is order-independent (element-wise), or reductions in a fixed order | none if restricted that way |
+
+Defaults are on the same footing: numba's `fastmath`, `parallel` and `nogil` are all off by default
+[doc], and Cython's default directives keep Python semantics (`boundscheck=True`, `wraparound=True`,
+`cdivision=False`) [doc]. Most of the "they are faster" headline numbers come from opting out of
+semantics.
+
+## 1. Where type information comes from, and what happens when it is missing
+
+| | TypedPython | Cython | numba | LPython |
+|---|---|---|---|---|
+| Source | Ordinary annotations plus Pyrefly's static inference [src] | `cdef`/`cython.*` declarations; in pure mode, `float` becomes a C double while `int` stays a Python object [doc] | Specialised at run time from argument types, with inference over the bytecode [doc] | Mandatory annotations in its own types (`i32`, `f64`, `list[i32]`) [doc] |
+| When it cannot type something | The function stays interpreted, with a diagnostic; a guard failure at run time deopts that one call [src] | Compiles anyway on the generic object path (slow, but correct) [doc] | `TypingError`: the function does not compile [doc] | Compile error [doc] |
+| Granularity | Per function, and per call when deopting [src] | Per variable | Per function, per argument signature | Per program |
+
+## 2. Code generation path and when compilation happens
+
+| | Path | When | Mobile / GraalVM-embedded CPython |
+|---|---|---|---|
+| TypedPython | Typed IR, then C, then the target's C compiler, giving a CPython extension module [src] | AOT, incremental per module [src] | Yes: NDK, Xcode, desktop clang [src] |
+| Cython | C, then a C compiler, giving an extension module [doc] | AOT | Yes (Kivy and BeeWare ship Cython extensions) [inferred] |
+| numba | LLVM IR, JIT through llvmlite [doc] | At the first call per signature; `cache=True` stores it on disk [doc]; `numba.pycc` AOT is deprecated [doc] | No on iOS (JIT, LLVM on the device) [inferred] |
+| LPython | ASR, then LLVM, C, C++, WASM or x86 [doc] | AOT standalone binaries, or the `@lpython` JIT (C backend only at present) [doc] | Not as part of an embedded CPython [inferred] |
+
+## 3. Object model
+
+- **TypedPython** [src]
+ - Unboxing: scalars (`int` as i64, `float` as double, `bool`) are unboxed.
+ - Arrays: `list[float]` and `list[int]` parameters become native arrays through copy-in and write-back, and local arrays are native.
+ - Classes: `__slots__` classes (N-11) are real objects with direct slot access.
+ - Everything else is an opaque object.
+ - Refcounting: every object operation does a real incref or decref.
+ - Not covered: no dict or str fast path.
+- **Cython**
+ - Unboxing: C types where declared. [doc]
+ - Classes: `cdef class` objects are real CPython objects with C fields. [doc]
+ - Arrays: memoryviews give zero-copy access to buffers. [doc]
+ - Refcounting: Cython's own code generation does it. [doc]
+ - Lists and dicts go through the C API. [inferred]
+- **numba**
+ - Its data lives outside CPython: arrays are managed by NRT, and `typed.List` and `typed.Dict` are numba's own types. [doc]
+ - Classes: `jitclass` is experimental. [doc]
+ - Lists: reflected Python lists are pending deprecation. [doc]
+ - Interop: Python objects appear only at the boundary or in `objmode`. [doc]
+- **LPython**
+ - Its own value types, with lists and dicts of fixed element types. [doc]
+ - Interop: CPython objects are reached through `@pythoncall`. [doc]
+
+## 4. CPython identity
+
+| | TypedPython | Cython (typed) | numba | LPython |
+|---|---|---|---|---|
+| Integer overflow | Promotes to a big int (deopt) [src] | Wraps; `overflowcheck` is off by default [doc] | Fixed width, wraps [doc] | Fixed width (`i32`/`i64`) [doc] |
+| Bounds | Checked unless proven [src] | `boundscheck=True` by default [doc] | Off by default [doc] | Not documented [doc gap] |
+| Division by zero | CPython's `ZeroDivisionError` [src] | `cdivision=False` by default [doc] | `error_model='python'` by default [doc] | Not documented |
+| Globals | Read live, or through an entry snapshot plus a guard (closed functions) [src] | Live | **Frozen at compile time** [doc] | Not applicable |
+| Signals, other threads | Polled since #141 phase 1; deferred while a snapshot is live [src] | Not polled unless the code calls `PyErr_CheckSignals` [inferred] | Not polled; `nogil=True` releases the GIL [doc] | Not applicable |
+| Trace and profile | No events (#142) [src] | `linetrace`/`profile` directives, off by default [doc] | None [inferred] | Not applicable |
+| GC timing | Unchanged for real objects; N-12 would change it [src] | Unchanged [inferred] | Its data is not GC-tracked [inferred] | Not applicable |
+| Exceptions | CPython's own; arrays write back partial updates [src] | CPython's own | Limited (constant arguments); memory allocated in a function that raises leaks [doc] | Not documented |
+
+## 5. Loops and arrays
+
+- Bounds-check elimination:
+ - TypedPython uses interval proofs that the verifier checks again (`proven` ops). [src]
+ - Cython and numba drop all bounds checks when told to (`boundscheck=False` or the default). [doc]
+- Vectorisation:
+ - All four leave it to the backend (clang or LLVM). [inferred]
+ - TypedPython builds with `-ffp-contract=off -fno-fast-math` for bit-identical floats, so float reductions do not vectorise, and neither do they under numba's or Cython's defaults. [src] [inferred]
+- Parallelism:
+ - numba and Cython have `prange`. [doc]
+ - TypedPython has none yet; it needs the free-threaded build first (AGENTS.md §12.8).
+
+## 6. Call boundary cost
+
+- **Python → compiled code**
+ - TypedPython: a vectorcall wrapper checks argument types exactly and copies arrays in. The copy is O(n) per call, which is a real cost for many small calls on large lists. [src]
+ - Cython: a C call, plus unboxing, plus buffer acquisition for memoryviews. [inferred]
+ - numba: a dispatcher that typechecks the arguments against the compiled signatures. [doc] [inferred]
+- **compiled code → Python**
+ - TypedPython and Cython: a C API vectorcall. [src] [inferred]
+ - numba: only through `objmode` blocks. [doc]
+ - LPython: through `@pythoncall`. [doc]
+- **compiled → compiled**
+ - TypedPython: a direct C call, but with depth counting on every call. That is the spectral-norm finding. [src]
+ - Cython: a direct C call. [inferred]
+ - numba: calls between jitted functions inline through LLVM. [inferred]
+
+## 7. The three numeric benchmarks: theoretical ceilings
+
+The measured numbers are interleaved medians from the 2026-10-03 quiet window, speedup against CPython 3.13 with compiled times. [measured]
+
+| benchmark | TypedPython now | Rust | Node | Ceiling for numba, Cython typed and LPython [inferred] | What TypedPython needs to reach it |
+|---|---|---|---|---|---|
+| nbody | 31.3x (0.129 s) | 0.059 s | 0.117 s | About C speed, near Rust. Short arrays and float math, no reductions across bodies | Find the remaining 2x to Rust first. Candidates: bounds checks not removed on `pos[i]`-style indexing, no inlining across helper calls, and the call per step. Needs the #125-style profile; not verified |
+| spectral-norm | 6.3x (0.518 s) | 0.027 s | 0.068 s | About C speed: `eval_a` inlined, integer arithmetic unchecked, the floor division a shift | (1) the depth precheck and fast entry; (2) inlining of `eval_a`; (3) the interval proof `i + j < 2n`, plus a shift for `// 2` once the value is proven non-negative. The `(i+j)*(i+j+1)` product cannot be proven safe without a bound on `n`, so its check stays, but a predicted branch costs little |
+| fannkuch-redux | 23.5x (0.250 s) | 0.249 s | 0.261 s | About C speed | Already at Rust parity [measured]; nothing needed |
+
+## LPython: development state (checked 2026-10-04)
+
+- The README says "currently in alpha stage and under heavy development". [doc]
+- Latest release: v0.22.0 (2024-07-08). Last push to `main`: 2025-12-11. 580 open issues. [src: GitHub API]
+- It compiles "a subset of Python" with mandatory LPython type annotations. [doc]
+- Backends: LLVM, C, C++, WASM, Julia, x86. [doc]
+- The `@lpython` JIT supports only the C backend at present. [doc]
+- It reaches CPython libraries through `@pythoncall`. [doc]
+
+Assessment [inferred]: the project is active but slow, a long way from a stable 1.0. It is not built to run inside an embedded CPython, so it is not a candidate backend for this repository. The design doc's earlier note (§4.3.3 "upstream activity moved to LFortran") still matches the release history: no release since 2024-07.
+
+## Sources
+
+- numba: [JIT options](https://numba.readthedocs.io/en/stable/reference/jit-compilation.html), [deviations from Python semantics](https://numba.readthedocs.io/en/stable/reference/pysemantics.html), [deprecation notices](https://numba.readthedocs.io/en/stable/reference/deprecation.html)
+- Cython: [compiler directives](https://cython.readthedocs.io/en/latest/src/userguide/source_files_and_compilation.html)
+- LPython: [repository README](https://github.com/lcompilers/lpython), [2023 announcement](https://lpython.org/blog/2023/07/lpython-novel-fast-retargetable-python-compiler/), GitHub API for release and push dates
+- TypedPython: `python-multiplatform-ksp/src/main/python/typedpython/` (`ir.py`, `cgen.py`, `runtime/tp_runtime.h`, `cbuild.py`), `docs/design/typedpython.md` §4.3
diff --git a/docs/investigations/typedpython-vs-jax.md b/docs/investigations/typedpython-vs-jax.md
new file mode 100644
index 000000000..6254d5f2b
--- /dev/null
+++ b/docs/investigations/typedpython-vs-jax.md
@@ -0,0 +1,121 @@
+# TypedPython 과 JAX 비교 (조사일 2026-10-04)
+
+결론의 반영: `docs/design/typedpython.md` §5.10 (2026-10-04). 메인테이너 결정: §5 의 권고 2 는 "torch 가 JAX 만큼 성능을 내는 부분만 torch 에 맡기고 나머지는 목록으로 따로 판단" 으로 바뀌었고, 권고 4 는 "numpy 의존 없음, 순수 파이썬 참조" 로 정해졌다.
+
+태그: [V] 가져온 URL 또는 실행한 명령, [R] 읽은 로컬 파일, [I] 추론. 웹 도구가 페이지를 요약해서 돌려주므로
+[V] 항목은 원문 인용이 아니라 요약을 본 것이다. 기기에서 직접 재지 않았다.
+
+## 1. 비교표
+
+| 항목 | JAX (0.11.2, 2026-09-17 [V]) | TypedPython (설계안 [R]) |
+|---|---|---|
+| 프로그래밍 모델 | `jit` 가 tracer 로 함수를 jaxpr 로 변환. 순수 함수, 불변 배열 [V] | 일반 파이썬 의미를 보존. 컴파일은 IR 에서 증명, 실패하면 인터프리터에 남김 [R §4.3.1] |
+| 파이썬 제어 흐름 | trace 시점에 평가. 값 의존 분기는 에러, `static_argnums` 는 재컴파일 [V] | 컴파일 구간에서도 CPython 과 같은 결과가 계약 [R §4.3.4] |
+| 부작용 | `print`, list append 는 trace 때만 실행되고 사라짐 [V] | 보존 (같은 결과 원칙) [R] |
+| 커널 | Pallas. GPU 는 Mosaic GPU (Hopper 이상), Triton 은 0.11.0 에서 deprecated, TPU 는 Mosaic [V] | `@kernel`, 타일 우선 + SIMT 폴백, Vulkan/Metal/WebGPU/CUDA 출력기 [R §5] |
+| 커널의 CPU 실행 | `interpret=True`. 커널을 JAX 함수로 낮춰 grid 위 `scan` 을 `jit` 한 것 [V] | 커널이 수정 없이 CPython 에서 실행 [R §5.2] (설계, 미구현) |
+| 모바일 GPU 커널 | Pallas 변경 기록에 Vulkan/Metal 없음 [V] | 1차 목표 (Vulkan, Metal) [R] |
+| AOT | `jax.export` 로 StableHLO 직렬화. 내장 플랫폼 TPU, CPU, CUDA, ROCm [V] | 빌드 타임 AOT 가 전제 (iOS) [R §5.1] |
+| 실행 런타임 | XLA/PJRT 또는 StableHLO 소비자 (IREE, Google AI Edge) [V] | torchnative 장치 계층 (ash/Vulkan, candle-metal) [R §5.6] |
+| 전체 프로그램 융합 | XLA 가 jit 단위로 융합 [I, 일반 지식. 이번에 1차 출처를 확인하지 못함] | 없음. 그래프 캡처로 미룸 (§5.9) [R] |
+| autodiff / vmap / sharding | 핵심 기능 [I, 일반 지식. 확인 못 함] | 없음 (설계에 언급 없음 [R]) |
+| 정적 타입 | 배열 shape/dtype 은 `jaxtyping` 런타임 검사. 정적 체커는 `Array` 로만 취급 [V] | 빌드 기본 게이트, `Any` 유출 0 (Pyrefly) [R §4.2] |
+| 자유 스레드 CPython | 3.14t, 3.15t 지원 표기, mac 포함 wheel [V 검색 요약] | 3.14t 데스크톱 opt-in, 컴파일 모듈은 GIL 재활성 [R INTENT, §4.3.4] |
+| iOS/Android 휠 | 변경 기록에 언급 없음 [V]. jaxlib 휠은 Linux, macOS ARM64, Windows [V 검색 요약] | 임베드 대상 [R] |
+| HF transformers | v5 에서 Flax/TF 클래스 제거, PyTorch 단일 백엔드 [V] | torch op 로 착지해 transformers 무수정 사용 [R §5.6] |
+
+## 2. 우리 설계가 이기는 지점 (근거가 버티는 것만)
+
+1. **모바일 GPU 가 1급 대상이다.** JAX 의 GPU 커널 경로는 Mosaic GPU 가 Hopper 이상 NVIDIA 로 한정되고, Triton
+ 은 deprecated 다 [V]. Pallas 변경 기록에 Vulkan/Metal 이 없다 [V]. 공식 Apple GPU 플러그인은 "experimental" 이고
+ macOS 15.6 에서 `arange` 도 실패하는 이슈가 열려 있다 (jax-metal 0.1.1) [V]. 서드파티 metaljax 가 있으나
+ 비공식 beta 이고 mlx-lm/llama.cpp 에 1.1~1.9배 뒤진다고 스스로 적는다 [V]. Android 에 대해서는 이번 조사에서
+ 근거를 찾지 못했다 [I: JAX 공식 경로로는 없다고 보이나 증명은 아니다].
+2. **iOS/Android 에서 실행되는 런타임이 있다는 점.** JAX 모델의 모바일 배포는 jax2tf 를 거쳐 TF SavedModel,
+ LiteRT 로 가는 2단계 경로가 문서화돼 있고 jax2tf 는 experimental 이다 [V]. 임의의 Pallas 커널이 그 경로로
+ 가는지는 확인하지 못했다. 그리고 `jax.export` 문서는 안정성이 보장되는 custom call 이 일부뿐이고 나머지는
+ 안전 검사를 꺼야 직렬화된다고 한다 [V]. Pallas 커널은 custom call 로 내려가므로 [I] 이식 가능한 아티팩트가
+ 되기 어렵다고 추정한다.
+3. **정적 타입 게이트는 JAX 에 대응물이 없다.** jaxtyping 은 의도적으로 정적 shape 검사를 하지 않고, 정적
+ 체커에는 `Array` 로 보인다. 런타임(trace 시점) 검사다 [V]. 우리의 "사용자 코드에 `Any` 없음" 보장은 다른
+ 종류의 보장이다 [I]. 다만 이것이 JAX 대비 우위인 것은 "빌드 실패" 라는 시점뿐이며, shape 오류를 정적으로
+ 잡는다는 뜻이 아니다 (우리도 못 한다).
+4. **파이썬 의미를 바꾸지 않는다.** JAX 는 `jit` 안에서 값 의존 분기 에러, 부작용 소실, 인덱스 clamp (NumPy 는
+ 에러), list 입력 거부, 기본 32비트 정밀도 등 다른 의미로 동작한다 [V]. TypedPython 의 계약은 "어떤 유효한
+ CPython 실행과 구별 불가" [R §4.3.4] 이므로 기존 파이썬 코드를 의미 변경 없이 점진 컴파일한다는 점이 다르다.
+ 다만 이것은 설계 약속이고, 커널 쪽(타일 연산의 CPU 참조 구현)은 아직 구현이 없다 [R].
+5. **CPU 에서 "진짜 파이썬으로" 디버깅.** Pallas `interpret=True` 는 커널을 JAX 함수로 낮춘 `scan` 을 `jit`
+ 한 것이고, CPU 에서 Pallas 를 돌리는 유일한 방법이다 [V]. 커널 본문은 `Ref` 와 `pl.*` 연산을 쓰므로 JAX 없이는
+ 실행되지 않는다 [I]. `jax.debug.print` 같은 JAX 도구가 쓰인다 [V]. 우리 설계는 JAX 도 필요 없이 CPython 만으로
+ 돈다는 점에서 한 걸음 더 가깝다. 단, 그 "일반 파이썬 실행" 이 느리다는 비용은 둘 다 같다 [I].
+6. **생태계 방향.** transformers v5 가 Flax/TF 를 제거하고 PyTorch 단일 백엔드가 됐다 [V]. torch op 로 착지하는
+ 우리 경로가 HF 생태계와 맞는다. JAX 쪽은 MaxText 등 별도 도구로 호환을 유지한다고 한다 [V].
+
+## 3. JAX 가 이기는 지점과 따라잡으려면
+
+| JAX 의 강점 | 우리 상태 | 격차를 메우려면 |
+|---|---|---|
+| XLA 전체 프로그램 융합 | 없음 (§5.9) [R] | torchnative 그래프 캡처 (torchnative #68). 단기에는 수작업 융합 커널 번들 |
+| `grad`, `vmap`, `jit` 합성 | 설계 범위 밖 [R] | torch autograd 에 맡긴다고 명시. 커널은 forward/backward 를 사용자가 `torch.library` 로 등록 [I] |
+| 샤딩, 다중 기기 (pjit, shard_map) | 없음 | 모바일/온디바이스 목표상 범위 밖으로 선언하는 것이 정직하다 [I] |
+| 성숙도, TPU/Hopper 성능 | 코드 없음 (커널 단계 4부터) [R] | 데스크톱 CUDA 비교는 Triton/TileLang 기준 [R §5.8] |
+| 타일 커널 DSL 의 검증된 파이프라이닝, warp specialization | 설계만 | Mosaic GPU 문서 (파이프라이닝, warp specialization, Blackwell matmul) [V] 를 참조 사례로 삼는다 |
+| `jax.export` 의 호환 창 (후방 6개월, 전방 3주) [V] | 아티팩트 형식 미정 | 커널 번들에 IR 버전과 출력기 버전을 박는다 [I] |
+| 자유 스레드 휠 | 3.14t 만 | JAX 는 3.14t, 3.15t 표기 [V]. 우리 MLIR 보조 휠은 3.15t 없음 [R §5.4] |
+
+## 4. 우위가 불분명하거나 입증되지 않은 것
+
+- **"런타임 코드 생성 없음" 은 JAX 대비 우위가 아닐 수 있다.** 이유는 `jax.export` 의 StableHLO 를 IREE 가 AOT
+ 컴파일해 모바일에 싣는 길이 이론상 있기 때문이다 [V: IREE 와 Google AI Edge 가 StableHLO 소비자]. 실제로 JAX
+ 모델이 IREE 로 iOS/Android GPU 에서 도는 사례와 성능은 확인하지 못했다. 우리 조사도 IREE 를 "SPIR-V 코드
+ 생성만 떼어 쓸 공식 경로 없음" 으로 기각했으나 [R §5.4], 그것은 커널 컴파일러로서의 평가이고 "모델을 통째로
+ 배포하는 경로" 로서는 JAX+IREE 가 경쟁자다 [I].
+- **성능.** 둘 다 모바일에서 측정이 없다. llama.cpp/MLX 근접 목표는 안 [R §5.8]. metaljax 가 1.1~1.9배 뒤진다고
+ 밝힌 수치가 있어 [V], 우리가 "JAX 보다 빠르다" 고 주장할 근거는 지금 없다.
+- **"Pythonic 하다".** JAX 도 NumPy API 이고 학습 곡선이 낮다. 의미 보존의 가치는 기존 코드 이식 시나리오
+ 에서만 나타난다 [I].
+- **타입 게이트의 가치.** JAX 사용자가 이 게이트를 원하는지(특히 `jnp` 중심 코드는 `Array` 로 충분히 추론되는지)
+ 검증하지 않았다 [I]. Pyrefly 가 JAX 를 쓰는 대규모 코드베이스에서 쓰인다는 서술 [R §4.1] 은 JAX 코드가
+ 게이트를 통과한다는 뜻이 아니다.
+- **Mosaic GPU 와의 직접 대결.** Hopper/Blackwell 한정이라 겹치는 기기가 없다. 우리 데스크톱 CUDA(PTX) 경로가
+ JAX 보다 낫다는 주장은 근거가 없다 [I].
+- **Pallas 의 Vulkan/Metal 부재** 는 "변경 기록에 없다" 까지만 확인했다. 실험 브랜치나 제3자 포크는 못 봤다 [V 한계].
+
+## 5. 설계 변경 권고
+
+1. **포지셔닝 문장을 바꾼다.** "JAX 보다 낫다" 가 아니라 "JAX 가 가지 않는 곳(모바일 Vulkan/Metal, 앱에 임베드된
+ CPython, 의미 보존)" 으로 쓴다. 근거가 이 범위에서만 서기 때문이다 (§2.1~2.4).
+2. **범위 밖 선언을 문서에 넣는다.** autodiff, vmap, 샤딩은 torch 에 위임하고 만들지 않는다고 §5 에 한 단락 적는다.
+ 사용자가 JAX 와 같은 것을 기대하지 않게 한다.
+3. **`interpret=True` 대응 성질을 시험으로 박는다.** "커널이 JAX/torch 없이 CPython 만으로 도는가" 를 첫 조각 시험에
+ 넣는다. 이것이 Pallas 대비 구체적 차별점이므로 구현 전에 반증 가능해야 한다 (AGENTS.md §14).
+4. **타일 연산의 CPU 참조 구현 의존성을 정한다.** numpy 를 쓰면 "의존 없는 CPython" 우위가 약해진다. 순수 파이썬
+ 참조를 쓸지, 배열 라이브러리를 허용할지 결정이 필요하다 [I].
+5. **IREE 경로를 "모델 단위 대안" 으로 §5.4 에 한 줄 비교로 추가**한다. 기각 사유를 커널 컴파일러 관점과 모델 배포
+ 관점으로 나눠 적는다.
+6. **JAX 사용자 이주 경로 검토(선택).** 필요하면 StableHLO 입력을 받는 것은 INTENT §1 범위를 넘으므로 제안하지
+ 않는다. 메인테이너가 정할 일이다.
+7. **융합 격차를 "알려진 약점" 으로 표에 유지**하고 torchnative #68 완료 전에는 모델 전체 속도를 비교 대상으로
+ 삼지 않는다 (§5.9 와 같다).
+
+## 6. 이 방법이 찾지 못하는 것
+
+웹 요약 도구가 돌려준 문장에 의존했고(원문 대조 없음), 기기 실측, 비공개 Google 로드맵, 공식 문서에 없는 제3자
+Vulkan/Metal Pallas 포크, 그리고 XLA 융합/autodiff/vmap 의 1차 출처 확인(이번엔 일반 지식으로만 적음)은 찾지
+못한다.
+
+## 출처 (가져온 것)
+
+- https://docs.jax.dev/en/latest/changelog.html (0.11.2, Python 3.12 이상, 3.14t/3.15t, 모바일 언급 없음)
+- https://docs.jax.dev/en/latest/pallas/CHANGELOG.html (Triton deprecated, Vulkan/Metal 없음)
+- https://docs.jax.dev/en/latest/pallas/quickstart.html (Mosaic GPU, Hopper 이상, Triton 비권장, Ref)
+- https://docs.jax.dev/en/latest/pallas/design/design.html (scan 기반 emulation)
+- https://docs.jax.dev/en/latest/export/export.html , https://docs.jax.dev/en/latest/jax.export.html
+- https://docs.jax.dev/en/latest/jit-compilation.html , .../notebooks/Common_Gotchas_in_JAX.html
+- https://docs.jax.dev/en/latest/installation.html (Apple GPU experimental)
+- https://github.com/jax-ml/jax/issues/34109 (jax-metal 실패)
+- https://pypi.org/project/metaljax/ (비공식, 1.1~1.9배)
+- https://docs.kidger.site/jaxtyping/faq/
+- https://github.com/openxla/stablehlo/blob/main/README.md (IREE, Google AI Edge)
+- https://developers.google.com/edge/litert/conversion/jax/overview (jax2tf 경로, 검색 결과 요약)
+- 웹 검색 요약: transformers v5 Flax 제거, jaxlib 3.14t 휠, pallas interpret 파라미터
diff --git a/docs/locale/README_ko.md b/docs/locale/README_ko.md
index dff48d279..2627bfaa4 100644
--- a/docs/locale/README_ko.md
+++ b/docs/locale/README_ko.md
@@ -4,9 +4,9 @@
# 🐍 python-multiplatform
-**Kotlin Multiplatform 안의 진짜 CPython — Kotlin 이 Python 을, Python 이 Kotlin 을, 모든 타깃에서 부른다.**
+**Kotlin Multiplatform 안의 진짜 CPython, Kotlin 이 Python 을, Python 이 Kotlin 을, 모든 타깃에서 부른다.**
-[](../../LICENSE)
+[](../../LICENSE)
[](https://kotlinlang.org/docs/multiplatform.html)
[](https://www.python.org/)
[-lightgrey.svg)](#-플랫폼)
@@ -21,31 +21,31 @@
## 💡 왜 필요한가
-라이브러리는 Python 에 있고, 닿는 범위는 Kotlin Multiplatform 에 있다 — 데스크톱, Android, iOS, 웹.
+라이브러리는 Python 에 있고, 닿는 범위는 Kotlin Multiplatform 에 있다, 데스크톱, Android, iOS, 웹.
python-multiplatform 은 **진짜 CPython 인터프리터**를 Kotlin Multiplatform 앱 안에 넣고, 그 위에
양방향 다리를 놓는다.
- Kotlin 은 Python 위에 **타입이 있는, Kotlin 다운 객체 모델**을 얻는다. `PyList` 는 곧
`MutableList` 이고, `PyDict` 는 곧 `MutableMap` 이며, Python 오류는 곧 `Throwable` 이다.
- Python 은 **Kotlin 클래스를 평범한 Python 으로** 쓴다. `Greeter('Kotlin').greet(2)`,
- `suspend fun` 에 대한 `await`, 메서드로 보이는 Kotlin 확장 함수 — 모두 빌드 타임에 생성한 테이블을
+ `suspend fun` 에 대한 `await`, 메서드로 보이는 Kotlin 확장 함수, 모두 빌드 타임에 생성한 테이블을
거치므로, 리플렉션이 없는 곳(Kotlin/Native, GraalVM 네이티브 이미지)에서도 동작한다.
재구현이 아니라 CPython 그 자체이므로 C 확장도 그대로 동작한다.
## ✨ 기능
-- 🔌 **다운콜** — CPython Stable ABI(약 330개 함수)를 `expect` 하나로 선언하고 플랫폼마다 `actual` 을
+- 🔌 **다운콜**: CPython Stable ABI(약 330개 함수)를 `expect` 하나로 선언하고 플랫폼마다 `actual` 을
둔다. 데스크톱은 Panama `invokeExact`, Android 는 `RegisterNatives` JNI, Native 는 cinterop.
-- 🧩 **객체 모델** — `PyObject`, `PyType`, `PyException`, 기본 타입, Kotlin 컬렉션 인터페이스를 구현하는
+- 🧩 **객체 모델**: `PyObject`, `PyType`, `PyException`, 기본 타입, Kotlin 컬렉션 인터페이스를 구현하는
`PyList` / `PyDict` / `PySet` / `PyTuple`, 모듈, 호출 가능 객체, 변환 계층.
-- 🚀 **업콜** — KSP 프로세서가 함수 테이블을 생성한다. Python 이 Kotlin 클래스를 생성하고, 메서드를
+- 🚀 **업콜**: KSP 프로세서가 함수 테이블을 생성한다. Python 이 Kotlin 클래스를 생성하고, 메서드를
부르고, 프로퍼티를 읽고 쓰고, `suspend` 함수를 취소까지 포함해 `await` 한다.
-- 📦 **빌드된 라이브러리 바인딩** — Gradle 플러그인이 jar(Compose 포함)를 훑어 바인딩과 `.pyi` 스텁을
+- 📦 **빌드된 라이브러리 바인딩**: Gradle 플러그인이 jar(Compose 포함)를 훑어 바인딩과 `.pyi` 스텁을
생성한다. 기본 인자 생략과 값 클래스를 지원한다.
-- ♻️ **수동 메모리 관리 없음** — 래퍼는 수거될 때 Python 참조를 놓는다. 경계를 가로지르는 참조 순환은
+- ♻️ **수동 메모리 관리 없음**: 래퍼는 수거될 때 Python 참조를 놓는다. 경계를 가로지르는 참조 순환은
Python 의 순환 GC 가 수거한다.
-- 🧪 **측정됨** — 모든 경계 메커니즘에 오버헤드 테스트가 붙어 있고, 비용 표는 실제 실행에서 렌더링된다
+- 🧪 **측정됨**: 모든 경계 메커니즘에 오버헤드 테스트가 붙어 있고, 비용 표는 실제 실행에서 렌더링된다
([비용 표](../investigations/cost-table.md)).
## 🚀 빠른 시작
@@ -117,7 +117,7 @@ async def main():
### 플랫폼별 준비
-데스크톱 — PYTHONHOME 에 stdlib 가 있도록 Gradle 플러그인을 적용한다
+데스크톱, PYTHONHOME 에 stdlib 가 있도록 Gradle 플러그인을 적용한다
```kotlin
plugins {
@@ -136,7 +136,7 @@ plugins {
-Android — PythonBootstrap.initialize 를 호출한다
+Android, PythonBootstrap.initialize 를 호출한다
```kotlin
import python.multiplatform.env.PythonBootstrap
@@ -170,7 +170,7 @@ flowchart LR
T --> K
```
-플랫폼마다 진입점은 하나이고, 그 뒤에서는 **모든 플랫폼이 같은 생성 테이블을 쓴다** — 플랫폼별
+플랫폼마다 진입점은 하나이고, 그 뒤에서는 **모든 플랫폼이 같은 생성 테이블을 쓴다**, 플랫폼별
Python → Kotlin 바인딩 코드가 없다. 자세한 내용: [설계 문서](../design/).
## 🌍 플랫폼
@@ -186,7 +186,7 @@ Python → Kotlin 바인딩 코드가 없다. 자세한 내용: [설계 문서](
## 📊 상태
-정직한 요약이다 — 남은 작업은 [로드맵](../roadmap/ROADMAP.md) 에 있다.
+정직한 요약이다, 남은 작업은 [로드맵](../roadmap/ROADMAP.md) 에 있다.
| 영역 | 상태 |
|---|---|
@@ -211,10 +211,10 @@ Python → Kotlin 바인딩 코드가 없다. 자세한 내용: [설계 문서](
## 📖 문서
-- 🌐 **[가이드](https://thisisthepy.github.io/python-multiplatform/)** (English / 한국어) — 소스는 [`docs/guide/`](../guide/)
-- 🏗 [설계 문서](../design/) — 구조, 다운콜, 업콜, 수명, 스레딩, 노출 정책
-- 📱 [플랫폼 문서](../platforms/) — Android FFM, wasm, CPython 취득, GraalVM 검증
-- 🔬 [조사 기록](../investigations/) — 측정과 감사
+- 🌐 **[가이드](https://thisisthepy.github.io/python-multiplatform/)** (English / 한국어), 소스는 [`docs/guide/`](../guide/)
+- 🏗 [설계 문서](../design/), 구조, 다운콜, 업콜, 수명, 스레딩, 노출 정책
+- 📱 [플랫폼 문서](../platforms/), Android FFM, wasm, CPython 취득, GraalVM 검증
+- 🔬 [조사 기록](../investigations/), 측정과 감사
- 🗺 [로드맵](../roadmap/ROADMAP.md)
- 🇺🇸 [영어 README](../../README.md)
@@ -227,4 +227,4 @@ iOS 타깃 빌드에는 Xcode 가 있는 macOS 가 필요하다.
## 📄 라이선스
-[MIT](../../LICENSE) © 2024 thisisthepy
+[Apache-2.0](../../LICENSE) © 2024 thisisthepy
diff --git a/docs/platforms/desktop-packaged-app.md b/docs/platforms/desktop-packaged-app.md
index ca24c6889..7f803b020 100644
--- a/docs/platforms/desktop-packaged-app.md
+++ b/docs/platforms/desktop-packaged-app.md
@@ -11,8 +11,8 @@ app now gets its prefix, why it was done this way, and how to check it.
## What happens
**Build time** (`python-multiplatform-gradle-plugin`, `PythonBindingsPlugin.configurePackagedPythonHome`).
-Every Compose Desktop `prepare*AppResources` task — the `Sync` that `createDistributable`,
-`packageDmg`/`packageMsi`/`packageDeb` and their `Release` variants take their resources from —
+Every Compose Desktop `prepare*AppResources` task, the `Sync` that `createDistributable`,
+`packageDmg`/`packageMsi`/`packageDeb` and their `Release` variants take their resources from,
depends on `stagePythonHome` and copies part of the staged prefix into `python-multiplatform-home/`:
| Host | Copied (relative to the prefix) |
@@ -30,7 +30,7 @@ In the app image the directory is `$APPDIR/resources/python-multiplatform-home`;
**Run time** (`python-multiplatform`). `PackagedPythonHome.resolve()` (`jvmMain`) takes the first of:
-1. `PYTHONHOME` in the environment — then nothing else happens; CPython reads it itself.
+1. `PYTHONHOME` in the environment, then nothing else happens; CPython reads it itself.
2. The system property `python.multiplatform.home`, used even if the path does not exist, so that a
wrong value is reported by name.
3. `/python-multiplatform-home`, only if that directory exists.
@@ -58,7 +58,7 @@ mechanism for the stdlib was considered and not taken:
abort at `Py_Initialize` rather than a re-extraction. (Not observed here; a reason not to take the
risk for the one tree whose absence is fatal.)
- `libpython` has to be a file for `System.load` anyway. Loading it from the classpath copy means
- extracting 19.4 MB into the working directory and finding it through `java.library.path` — this
+ extracting 19.4 MB into the working directory and finding it through `java.library.path`, this
repo's tests pass `-Djava.library.path=.`, a launched app has no such flag and may have `/` as its
working directory. Loading it from the packaged prefix also pairs it with its own stdlib.
- jpackage builds an image for the host it runs on, so only one platform's prefix is ever packaged;
diff --git a/docs/platforms/free-threaded-mobile.md b/docs/platforms/free-threaded-mobile.md
new file mode 100644
index 000000000..8146231ae
--- /dev/null
+++ b/docs/platforms/free-threaded-mobile.md
@@ -0,0 +1,120 @@
+# Free-threaded CPython for Android and iOS
+
+Status: build recipe written from the CPython 3.15.0 source; **not yet run in CI,
+not yet run on a device or emulator.** Tracking issue: #163.
+
+python-multiplatform gets its parallelism from free-threaded CPython (decision 8 in
+`AGENTS.md`). Desktop uses the python.org 3.14t builds. No prebuilt 3.15t exists for
+Android or iOS, so the workflow `.github/workflows/build-ft-mobile.yml` builds it with
+CPython's own mobile tooling.
+
+## What is built
+
+| Platform | Host | Tooling | Output |
+|---|---|---|---|
+| Android | `aarch64-linux-android` | `Platforms/Android` | `python-3.15.0-aarch64-linux-android.tar.gz` |
+| iOS | `arm64-apple-ios` (device), `arm64-apple-ios-simulator`, `x86_64-apple-ios-simulator` | `Platforms/Apple` | `python-3.15.0-iOS-XCframework.tar.gz` |
+
+The iOS tooling only builds an XCframework from all of its slices, so the simulator
+hosts are built as well as the device. There is no cheaper arm64-only path in the
+script.
+
+## How `--disable-gil` is passed
+
+Both scripts accept extra `configure` arguments after `--`
+(`Platforms/Android/__main__.py`: `"args", nargs="*", help="Extra arguments to pass to
+`configure`"`; `Platforms/Apple/__main__.py`: `args", nargs="*", help="Extra arguments
+to pass to `configure`"`). The workflow uses the discrete steps so that the flag reaches
+both the build Python and every host:
+
+```sh
+python3 Platforms/Android configure-build -- --disable-gil
+python3 Platforms/Android make-build
+python3 Platforms/Android configure-host aarch64-linux-android -- --disable-gil
+python3 Platforms/Android make-host aarch64-linux-android
+python3 Platforms/Android package aarch64-linux-android
+
+python3 Platforms/Apple configure-build -- --disable-gil
+python3 Platforms/Apple make-build
+python3 Platforms/Apple configure-host iOS -- --disable-gil # per host
+python3 Platforms/Apple make-host iOS
+python3 Platforms/Apple package iOS
+```
+
+`configure.ac` turns the flag into `#define Py_GIL_DISABLED 1` and appends `t` to
+`ABIFLAGS`, so the library, include directory and standard library directory become
+`libpython3.15t`, `include/python3.15t` and `lib/python3.15t`. `--disable-gil` needs
+mimalloc, which is on by default.
+
+The build Python is also built with `--disable-gil`. The cross-build only requires the
+same major.minor version (`--with-build-python`), so a GIL build Python would probably
+work too, but matching them removes a variable.
+
+## Scripts
+
+`.github/scripts/ft-mobile/`:
+
+- `common.sh`: source download, `Py_GIL_DISABLED` check, sha256 recording.
+- `build-android.sh [VERSION]`: unpacks `Python-.tgz`, installs the NDK the
+ tooling pins (`ndk_version=` in `Platforms/Android/android-env.sh`, 27.3.13750724 in
+ 3.15.0), runs the steps above, then verifies the package.
+- `build-ios.sh [VERSION]`: same for iOS, with the two edits below.
+
+Local use is possible on a Linux or macOS machine with the prerequisites, but the heavy
+build is meant for CI (`AGENTS.md` rule 10). Both scripts honour `FT_WORK` and `FT_OUT`
+(default under `.tmp/`).
+
+### Android notes
+
+- `Platforms/Android package` lists files with `git ls-files`, which does not work in an
+ unpacked tarball. `build-android.sh` runs `git init` and `git add Platforms/Android`
+ in the source tree first.
+- The package contains `prefix/include/python3.15t/` and `prefix/lib/libpython3.15t.so`
+ plus `lib/python3.15t/`, OpenSSL and SQLite libraries, and the `android.py` helper.
+- Dependencies (OpenSSL, SQLite, libffi, xz, bzip2, zstd) come from the
+ `beeware/cpython-android-source-deps` releases, as in upstream.
+
+### iOS notes
+
+- Upstream `Platforms/Apple/__main__.py` computes `version_tag` as `3.15`, but a
+ free-threaded framework installs `lib/python3.15t`. Unpatched, `package` fails when it
+ copies the `.xcprivacy` files into `lib-/python3.15/lib-dynload`. `build-ios.sh`
+ appends `t` to `version_tag`; this also names the symlink `lib/libpython3.15t.dylib`.
+- `testbed/Python.xcframework/build/utils.sh` finds the standard library with
+ `grep -E "^python3\.\d+$"`, which does not match `python3.15t`. `build-ios.sh` changes
+ it to `^python3\.[0-9]+t?$`. Each edit is asserted, so an upstream change fails the
+ build instead of skipping the patch.
+- The framework and its binary are still named `Python` / `Python.framework/Python`;
+ only the `lib` side carries the `t`. The `Py_GIL_DISABLED` define is in
+ `Headers/pyconfig-.h`; `Headers/pyconfig.h` is a wrapper that includes it.
+- Dependencies come from `beeware/cpython-apple-source-deps` releases. The runner needs
+ a full Xcode (`macos-15`); the Xcode version is printed in the log.
+
+## Verification built into the workflow
+
+Each script fails the job unless:
+
+- the build Python reports `Py_GIL_DISABLED == 1`;
+- Android: `include/python3.15t/pyconfig.h` has `#define Py_GIL_DISABLED 1`,
+ `lib/libpython3.15t*.so` exists and is an aarch64 ELF, `lib/python3.15t` exists;
+- iOS: every slice has `Headers/pyconfig-.h` with `#define Py_GIL_DISABLED 1`,
+ `lib/libpython3.15t.dylib`, and a `Python.framework/Python` binary (`lipo -info`
+ is logged); the XCframework has `lib/python3.15t` and exactly two slices.
+
+The sha256 of each archive is written to the job summary and the archives are uploaded
+as workflow artifacts (`python-t-android-aarch64`,
+`python-t-ios-xcframework`).
+
+## Not verified
+
+- The workflow has not run yet. Whether 3.15.0 configures and compiles with
+ `--disable-gil` for these targets is unknown; free-threaded support on Android and iOS
+ is not a documented CPython target.
+- Nothing has been loaded on a device, emulator or simulator. No import of `_ssl`,
+ `_ctypes` or extension modules, no GIL-off test run, no thread-safety check.
+- Whether the prebuilt dependency tarballs and the Xcode on `macos-15` suit 3.15.0 is
+ unconfirmed.
+- Integration with the Gradle plugin's `PYTHONHOME` staging, with the Android
+ `libpython` loading code, and with the iOS framework embedding (which expects
+ `Python.framework` and a non-`t` library name in places) is not done.
+- The x86_64 simulator slice is built only because the packaging step requires it.
diff --git a/docs/platforms/graal-native-image-verification.md b/docs/platforms/graal-native-image-verification.md
index 12ccbb28c..5a6d22f9d 100644
--- a/docs/platforms/graal-native-image-verification.md
+++ b/docs/platforms/graal-native-image-verification.md
@@ -71,18 +71,18 @@ the plugin's KMP support, not a native-image limitation.
- **Date:** 2026-08-17
- **Branch / commit:** `work/graal` off `develop` @ `4687070a` (*Merge branch 'work/syspath' into develop*)
- **Worktree:** `/Volumes/macMini/worktrees/graal`
-- **Machine note:** other agents were building concurrently — load average was **10.39** and physical
+- **Machine note:** other agents were building concurrently, load average was **10.39** and physical
memory was nearly exhausted (129 MB unused of 16 GB) when `nativeCompile` started. It still
succeeded, and the pass/fail results below are unaffected by contention. **The timings, however,
are not clean benchmarks and should not be quoted as such.**
-### Step 1 — JVM control
+### Step 1, JVM control
```
:sample:runNativeImageUpcallDemo EXIT=0 BUILD SUCCESSFUL in 42s
```
-### Step 2 — Native image build
+### Step 2, Native image build
```
:sample:nativeCompile EXIT=0 BUILD SUCCESSFUL in 46s
@@ -96,18 +96,22 @@ the plugin's KMP support, not a native-image limitation.
Finished generating 'upcall-native-demo' in 43.5s.
```
-Artifact: `sample/build/native/nativeCompile/upcall-native-demo` — **26,317,504 bytes (25.1 MB)**,
+The "registered for reflection" line counts everything in the image, the JDK's, Kotlin's and
+every library's shipped metadata included, not what this repository registers. Of it, this
+repository contributes one class, `UpcallTarget` (see the result table; corrected in #244).
+
+Artifact: `sample/build/native/nativeCompile/upcall-native-demo`, **26,317,504 bytes (25.1 MB)**,
`Mach-O 64-bit executable arm64`.
The `28 downcalls and 5 upcalls` the image registered match **exactly** the counts in the
*generated* `reachability-metadata.json`
(`python-multiplatform/build/generated/native-image-metadata/desktop/...`, `foreign.downcalls` = 28,
`foreign.upcalls` = 5). The image consumed the generated metadata rather than a checked-in file that
-could have rotted out of sync — which is the failure mode `generateDesktopReachabilityMetadata`
+could have rotted out of sync, which is the failure mode `generateDesktopReachabilityMetadata`
exists to prevent, since an undeclared descriptor builds clean and dies at the first call with
`MissingForeignRegistrationError`.
-### Step 3 — Native image execution
+### Step 3, Native image execution
```
:sample:runNativeUpcallDemo EXIT=0 BUILD SUCCESSFUL in 3s
@@ -151,7 +155,7 @@ completer thread -> clean
PYTHON: PROXY_OK
```
-### Step 4 — JVM vs native diff
+### Step 4, JVM vs native diff
The two runs are the **same commit and the same entry point**, so they are directly comparable. This
is the control that matters; comparing against a ROADMAP snapshot from an older commit (as an
@@ -171,15 +175,15 @@ Diffing the two 32-line output blocks:
```
**29 of 32 lines are byte-identical.** The only three that differ are raw runtime stub addresses,
-which carry no semantics. Every line that asserts behaviour — the table size, the resolved handles,
+which carry no semantics. Every line that asserts behaviour, the table size, the resolved handles,
`invoke result = 7`, `invoke_args result = 'presses x3 = 21'`, the `-1` for `@PythonInternal`, all
-of the proxy assertions, and both markers — is identical.
+of the proxy assertions, and both markers, is identical.
Incidental observation: under native-image the three Panama upcall stubs land at **contiguous**
addresses 0x40 apart, whereas on the JVM they are scattered. Consistent with stubs being laid out
into one image region at build time rather than allocated on demand.
-### Step 5 — Closed-world proof
+### Step 5, Closed-world proof
Running through Gradle leaves open the objection that a JVM was somewhere in the picture. It was
not. The binary was executed **directly, with `JAVA_HOME` removed from the environment**:
@@ -217,7 +221,7 @@ rather than been produced silently.
| Generated Python proxy layer works | **YES** (`PROXY_OK`, including `await` over a `suspend fun` settled from a Kotlin thread) |
| `@PythonInternal` opt-out still enforced | **YES** (resolves to `-1`) |
| Behaviour differs from JVM | **NO** (29/32 lines identical; the 3 differing lines are runtime addresses) |
-| Reflection registration needed for the upcall path | **NO** — the path uses none, which is the §7 design premise holding |
+| Reflection registration needed for the upcall path | **One class, by hand** (corrected in #244): the generated function table, which is every Kotlin declaration Python calls, needs none, which is the §7 premise holding; the upcall *bootstrap* finds its five FFM entry points by name on one fixed class of this repository (`UpcallStub.kt`, `UpcallTarget::class.java.methods`), so `python.native.ffi.UpcallTarget` is registered with `allPublicMethods` in `python-multiplatform/native-image/reachability-metadata.base.json` |
Nothing is outstanding on the desktop target. The untested surface is other targets: this verifies
**desktop only**, as `sample/build.gradle.kts` wires `nativeCompile` from the `desktop` compilation.
diff --git a/docs/platforms/ios-app-bundle.md b/docs/platforms/ios-app-bundle.md
index daf78fd55..1ddcaf218 100644
--- a/docs/platforms/ios-app-bundle.md
+++ b/docs/platforms/ios-app-bundle.md
@@ -33,14 +33,14 @@ PYTHON_DYLIB_INFO_TEMPLATE=/python-multiplatform/build/python-ios-home/ipho
A universal `ARCHS` (`arm64 x86_64`) is refused with the fix (`ONLY_ACTIVE_ARCH=YES` or `ARCHS=arm64`):
one prefix holds one `lib-dynload`. Upstream's `utils.sh` has the same limit without saying so.
-**Xcode** (`tools/xcode/install-python.sh`, a Run Script phase after Copy Bundle Resources and before
+**Xcode** (`python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh`, a Run Script phase after Copy Bundle Resources and before
Embed Frameworks; `ENABLE_USER_SCRIPT_SANDBOXING = NO`).
-1. Runs Gradle as above. The output is captured, then parsed, then checked to be a directory — a
+1. Runs Gradle as above. The output is captured, then parsed, then checked to be a directory, a
failing Gradle stops the phase and never leaves `rsync` an empty source (issue #59's comment).
2. `rsync -a --delete` the prefix to `/python-multiplatform-home/`.
3. The payload, if configured, to `/python/` (`--delete`, without `__pycache__`):
- `PYTHON_PAYLOAD_DIR=` (a directory whose contents are the payload root — the app's own Python
+ `PYTHON_PAYLOAD_DIR=` (a directory whose contents are the payload root, the app's own Python
sources, or toolchain's `build/pythonStaging/ios/python`), or `PYTHON_PAYLOAD_TASK=`
that prints `PYTHON_PAYLOAD_DIR=` under `-q` (toolchain's `:app:stagePythonBundleIosForXcode`).
Neither set: no payload, and an old `/python/` is removed.
@@ -53,7 +53,7 @@ Embed Frameworks; `ENABLE_USER_SCRIPT_SANDBOXING = NO`).
`Python3.initialize()` calls `applyPackagedPythonHome()` after the `PYTHONHOME` pre-flight check
(L-3) and before `Py_Initialize()`. `IosPythonHome.resolve()` takes the first of:
-1. `PYTHONHOME` in the environment — nothing else happens. The simulator test task and an Xcode
+1. `PYTHONHOME` in the environment, nothing else happens. The simulator test task and an Xcode
scheme variable keep working as before.
2. `/python-multiplatform-home`, only if that directory exists.
@@ -90,23 +90,28 @@ before. `PyConfig.home` is the non-deprecated route, but the library carries no
## Wiring an app
-The sample's project is `iosApp/iosApp.xcodeproj` (product `PythonDemo.app`, bundle id
+The sample's project is `sample/src/iosMain/app.xcodeproj` (target `app`, product `PythonDemo.app`, bundle id
`org.thisisthepy.python.multiplatform.demo`). Its phase:
```sh
set -e
-cd "$SRCROOT/.."
-export PYTHON_PAYLOAD_DIR="$SRCROOT/../sample/python/src/main"
-/bin/bash tools/xcode/install-python.sh
+cd "$SRCROOT/../../.."
+export PYTHON_PAYLOAD_DIR="$SRCROOT/../commonMain/python"
+/bin/bash python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh
```
It replaced two phases: "Install Target Specific Python Standard Library", which rsynced the slice's
-`lib/` — only `libpython3.14.dylib` — into `/lib/`, and "Prepare Python Binary Modules", which
-did the framework wrapping from a template copied in as a resource. `iosApp/iosApp/dylib-Info-template.plist`
-is no longer copied into the app; the file itself is still in the project.
-
-`sample/src/iosMain/app.xcodeproj` is not wired: it still references `../../dist/toolchain/...` and
-a `ComposeApp` framework that this build does not produce, and has never been built here.
+`lib/`, only `libpython3.14.dylib`, into `/lib/`, and "Prepare Python Binary Modules", which
+did the framework wrapping from a template copied in as a resource. The template is no longer copied
+into the app: `install-python.sh` takes the one the Gradle plugin stages (`PYTHON_DYLIB_INFO_TEMPLATE`).
+
+The project used to live in a root `iosApp/` folder. It moved to `sample/src/iosMain/` (the
+app-template layout, #107); the old `python` folder reference to `build/pythonStaging/ios/python`
+and the `../../dist/toolchain/...` references were removed, because `install-python.sh` already
+copies the payload to `/python/` and a second copy would conflict. The target runs two Gradle
+tasks first (`:python-multiplatform:embedAndSignAppleFrameworkForXcode`,
+`:sample:embedAndSignAppleFrameworkForXcode`), then compiles, then installs Python, then embeds
+`Python.xcframework`.
A consumer outside this repository cannot call `:python-multiplatform:stageIosPythonHomeForXcode`:
the iOS archive download lives in this repository's build script, not in the published plugin. That
@@ -138,14 +143,14 @@ EFFECTIVE_PLATFORM_NAME=-iphonesimulator ARCHS="arm64 x86_64" \
# 2. Build the app (flags from the last verified run, ROADMAP history 2026-08-13: actool cannot
# compile the app icon against this machine's simulator runtimes, and -target avoids
# -destination's eligibility check)
-(cd iosApp && xcodebuild -project iosApp.xcodeproj -target iosApp -configuration Debug \
- -sdk iphonesimulator ARCHS=arm64 ONLY_ACTIVE_ARCH=YES SYMROOT="$PWD/build" \
+xcodebuild -project sample/src/iosMain/app.xcodeproj -target app -configuration Debug \
+ -sdk iphonesimulator ARCHS=arm64 ONLY_ACTIVE_ARCH=YES SYMROOT="$PWD/sample/build/xcode-app" \
ASSETCATALOG_COMPILER_APPICON_NAME="" \
- CODE_SIGN_IDENTITY="-" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="" build) \
+ CODE_SIGN_IDENTITY="-" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="" build \
> .tmp/xcodebuild.log 2>&1; echo "EXIT=$?"
grep -E "Installing the Python|Wrapped|error:" .tmp/xcodebuild.log
-APP=iosApp/build/Debug-iphonesimulator/PythonDemo.app
+APP=sample/build/xcode-app/Debug-iphonesimulator/PythonDemo.app
ls "$APP/python-multiplatform-home/lib/python3.14/os.py" "$APP/python/example_py/__init__.py"
ls "$APP/python-multiplatform-home/lib/python3.14/lib-dynload" | grep -c '\.fwork$' # 67 for 3.14
find "$APP/python-multiplatform-home" "$APP/python" -name '*.so' | wc -l # 0
@@ -175,7 +180,7 @@ Fail shapes and what they mean:
| Seen | Meaning |
|---|---|
-| No `DEMO` lines, 0% CPU (`sample ` parked in `open$NOCANCEL`) | the app read a path outside its container — check `env` for a stray `PYTHONHOME` |
+| No `DEMO` lines, 0% CPU (`sample ` parked in `open$NOCANCEL`) | the app read a path outside its container, check `env` for a stray `PYTHONHOME` |
| `Fatal Python error: Failed to import encodings module` | no `python-multiplatform-home/` in the installed app; the phase did not run |
| `DEMO 8 bundle | ModuleNotFoundError: No module named '_json'` | the `.fwork` did not resolve; compare `sys.executable`'s directory with the app directory |
| `DEMO 8 bundle | ModuleNotFoundError: No module named 'example_py'` | `/python/` missing: `PYTHON_PAYLOAD_DIR` not set in the phase |
diff --git a/docs/platforms/python-version-acquisition.md b/docs/platforms/python-version-acquisition.md
index daa234cc2..544c13d3f 100644
--- a/docs/platforms/python-version-acquisition.md
+++ b/docs/platforms/python-version-acquisition.md
@@ -51,18 +51,18 @@ The project currently uses `python-build-standalone` (maintained by Astral). For
## 5. Security and Integrity Verification
* **Desktop:** `python-build-standalone` provides a `SHA256SUMS` file with every release. The build script verifies the downloaded archive against this manifest.
-* **Android (python.org) and iOS (python.org, 3.15+) — Sigstore verified.** This bullet used to say that "full Sigstore verification directly inside Gradle is unreasonable without shelling out to external tools (like `sigstore-python` or the `cosign` CLI)". **That was wrong**, and it is now implemented. `dev.sigstore:sigstore-java` does the whole verification in-process — certificate chain, Rekor inclusion and OIDC identity — with no external binary. python.org publishes a sibling `.sigstore` bundle for both Android tarballs and, from 3.15, the iOS XCframework. (The extension is `.sigstore`; `.sigstore.json` returns 404.) There is still no plain checksum manifest, so the lockfile continues to pin the digest as well.
+* **Android (python.org) and iOS (python.org, 3.15+), Sigstore verified.** This bullet used to say that "full Sigstore verification directly inside Gradle is unreasonable without shelling out to external tools (like `sigstore-python` or the `cosign` CLI)". **That was wrong**, and it is now implemented. `dev.sigstore:sigstore-java` does the whole verification in-process, certificate chain, Rekor inclusion and OIDC identity, with no external binary. python.org publishes a sibling `.sigstore` bundle for both Android tarballs and, from 3.15, the iOS XCframework. (The extension is `.sigstore`; `.sigstore.json` returns 404.) There is still no plain checksum manifest, so the lockfile continues to pin the digest as well.
* **The signer identity is pinned, and has to be version-keyed.** Verifying a bundle *without* pinning an identity proves only that somebody holding a Sigstore certificate signed the bytes, which anyone can arrange. The build pins the Fulcio SAN and OIDC issuer of the actual CPython release manager, per . That must be a map rather than a constant, because it changes per release series: 3.14/3.15 are `hugo@python.org` via `https://github.com/login/oauth`, whereas 3.12/3.13 are `thomas@python.org` via `https://accounts.google.com`. An unrecorded series is a hard build failure, never a silent skip.
-* **Opt-in — `-PverifyPythonSignatures=true`.** Off by default because `sigstore-java` pulls in grpc-netty-shaded, protobuf, bouncycastle and guava, and because fetching the TUF trust root needs network access, which would turn an offline build from working into failing. Gradle resolves the configuration lazily, so a default build downloads none of it (measured: a default `downloadPython_android_*` run mentions Sigstore zero times).
-* **Desktop (`python-build-standalone`) — Sigstore verified via GitHub Attestations.** The release carries 853 assets and the only non-archive among them is `SHA256SUMS`: there is no `.sigstore` file. Provenance is published through GitHub's *attestations* API instead, keyed by artifact **digest** rather than filename, and that endpoint is rate-limited to 60 requests/hour unauthenticated. Because of this rate limit and the bundle size (~212KB each), the build does not check these bundles into the repository. Instead, when `-PverifyPythonSignatures=true` is set, it fetches the DSSE bundle from the API, caches it locally as `.sigstore`, and uses `sigstore-java` to verify it against the pinned `astral-sh/python-build-standalone` workflow identity. Desktop is therefore protected by the lockfile, the release's own `SHA256SUMS`, and Sigstore.
-* **iOS (BeeWare, ≤ 3.14; `3.14-b11` pin `b591f330...ab7b`, taken from GitHub's asset digest and recomputed locally) — nothing exists to verify against.** The `Python-Apple-support` releases publish five `tar.gz` assets and nothing else: no checksums, no signatures, and no GitHub attestations. The lockfile pin is the only honest instrument available here, and no amount of build wiring changes that.
-* **The lockfile is not superseded by any of this.** The two gates prove different things — the lockfile says "these are the exact bytes this repository reviewed and pinned", Sigstore says "these are the bytes the release manager actually signed". The lockfile is also the only check that works offline and the only one covering every source, so it stays unconditional and Sigstore is layered on top of it.
+* **Opt-in, `-PverifyPythonSignatures=true`.** Off by default because `sigstore-java` pulls in grpc-netty-shaded, protobuf, bouncycastle and guava, and because fetching the TUF trust root needs network access, which would turn an offline build from working into failing. Gradle resolves the configuration lazily, so a default build downloads none of it (measured: a default `downloadPython_android_*` run mentions Sigstore zero times).
+* **Desktop (`python-build-standalone`), Sigstore verified via GitHub Attestations.** The release carries 853 assets and the only non-archive among them is `SHA256SUMS`: there is no `.sigstore` file. Provenance is published through GitHub's *attestations* API instead, keyed by artifact **digest** rather than filename, and that endpoint is rate-limited to 60 requests/hour unauthenticated. Because of this rate limit and the bundle size (~212KB each), the build does not check these bundles into the repository. Instead, when `-PverifyPythonSignatures=true` is set, it fetches the DSSE bundle from the API, caches it locally as `.sigstore`, and uses `sigstore-java` to verify it against the pinned `astral-sh/python-build-standalone` workflow identity. Desktop is therefore protected by the lockfile, the release's own `SHA256SUMS`, and Sigstore.
+* **iOS (BeeWare, ≤ 3.14; `3.14-b11` pin `b591f330...ab7b`, taken from GitHub's asset digest and recomputed locally), nothing exists to verify against.** The `Python-Apple-support` releases publish five `tar.gz` assets and nothing else: no checksums, no signatures, and no GitHub attestations. The lockfile pin is the only honest instrument available here, and no amount of build wiring changes that.
+* **The lockfile is not superseded by any of this.** The two gates prove different things, the lockfile says "these are the exact bytes this repository reviewed and pinned", Sigstore says "these are the bytes the release manager actually signed". The lockfile is also the only check that works offline and the only one covering every source, so it stays unconditional and Sigstore is layered on top of it.
* **The verification is known to fail when it should**, which is the only thing that makes it worth having. Two negative controls were run against the real 3.14.7 Android bundle: flipping one base64 character in `messageSignature.signature` gave `KeylessVerificationException: Artifact signature was not valid`, and pointing the identity map at the 3.13 release manager gave `No provided certificate identities matched values in certificate`. Both failed the build with a non-zero exit.
## 6. WebAssembly
No distributor ships a `python.wasm` that exports `wasmExports` and `wasmMemory` to JavaScript, which the
-`wasmJs` target needs. CPython is therefore **built**, not downloaded: `tools/wasm/build-cpython.sh` builds
+`wasmJs` target needs. CPython is therefore **built**, not downloaded: `python-multiplatform/scripts/wasm/build-cpython.sh` builds
CPython 3.14.2 for `wasm32-emscripten` (Emscripten 5.0.3, matched to the `pyemscripten_2026_0` platform of
PEP 783) under the git-ignored `.caches/`, and `python-multiplatform-wasm-runtime` is the published zip of
the result. This is deliberately a different patch release from the native `pythonVersion`; see
diff --git a/docs/platforms/wasm-design.md b/docs/platforms/wasm-design.md
index 414eb1b67..d0d385609 100644
--- a/docs/platforms/wasm-design.md
+++ b/docs/platforms/wasm-design.md
@@ -4,8 +4,8 @@ How CPython runs under Kotlin/Wasm in this library, why it has this shape, and w
get there. The rules a contributor must follow are in
[`python-multiplatform/src/wasmJsMain/README.md`](../../python-multiplatform/src/wasmJsMain/README.md);
the behavioural contract is `docs/SPEC.md` (§0, C-6, M-3, U-5). This document is the design record.
-The chronological experiment log it was distilled from — including the stretch where wasm was
-"parked" — is [`../archive/wasm-design-experiment-log.md`](../archive/wasm-design-experiment-log.md).
+The chronological experiment log it was distilled from, including the stretch where wasm was
+"parked", is [`../archive/wasm-design-experiment-log.md`](../archive/wasm-design-experiment-log.md).
**Status: integrated and experimental.** `wasmJs` is a Kotlin target (`python-multiplatform/build.gradle.kts`),
`src/wasmJsMain` implements the `EmbedAPI` `actual`s, and `src/wasmJsTest` runs the suite on Node
@@ -16,9 +16,9 @@ has a browser wasm target. `wasmWasi` is not a target (when last assessed it was
| Decision | Choice | Why |
|---|---|---|
-| How Kotlin reaches CPython | Raw Stable ABI symbols as `@WasmImport`s against the Emscripten module's own wasm exports — **no JS in the call path** | Preserves the exact object model (`NativePointer`, `PyObject`) of every other target; a high-level Pyodide-style JS API (`pyodide.runPython`, `PyProxy`) would replace the whole object model with `JsAny` wrappers and covers almost none of the ~330 C functions |
-| Memory | **One shared linear memory.** Kotlin imports Emscripten's `Module.wasmMemory` as its `intrinsics.memory` | Needs Kotlin ≥ 2.4.20-Beta2, whose `wasmJs` modules **import** `intrinsics.memory` (`min=0, max=none`). Releases up to 2.4.10 exported an unbounded memory, which Emscripten cannot import — the earlier plan to patch the `.wasm` memory section (and a YouTrack issue) is obsolete ([`../archive/wasm-youtrack-issue.md`](../archive/wasm-youtrack-issue.md)) |
-| Where CPython comes from | **Our own Emscripten build**, `tools/wasm/build-cpython.sh`: CPython **3.14.2**, Emscripten **5.0.3**, matched to the `pyemscripten_2026_0` platform of PEP 783 | No distributor ships a `python.wasm` exporting `wasmExports,wasmMemory`. The version is deliberately not the 3.14.x pinned for native targets (`pythonVersion` in `gradle.properties`); see `docs/design/ecosystem.md` |
+| How Kotlin reaches CPython | Raw Stable ABI symbols as `@WasmImport`s against the Emscripten module's own wasm exports, **no JS in the call path** | Preserves the exact object model (`NativePointer`, `PyObject`) of every other target; a high-level Pyodide-style JS API (`pyodide.runPython`, `PyProxy`) would replace the whole object model with `JsAny` wrappers and covers almost none of the ~330 C functions |
+| Memory | **One shared linear memory.** Kotlin imports Emscripten's `Module.wasmMemory` as its `intrinsics.memory` | Needs Kotlin ≥ 2.4.20-Beta2, whose `wasmJs` modules **import** `intrinsics.memory` (`min=0, max=none`). Releases up to 2.4.10 exported an unbounded memory, which Emscripten cannot import, the earlier plan to patch the `.wasm` memory section (and a YouTrack issue) is obsolete ([`../archive/wasm-youtrack-issue.md`](../archive/wasm-youtrack-issue.md)) |
+| Where CPython comes from | **Our own Emscripten build**, `python-multiplatform/scripts/wasm/build-cpython.sh`: CPython **3.14.2**, Emscripten **5.0.3**, matched to the `pyemscripten_2026_0` platform of PEP 783 | No distributor ships a `python.wasm` exporting `wasmExports,wasmMemory`. The version is deliberately not the 3.14.x pinned for native targets (`pythonVersion` in `gradle.properties`); see `docs/design/ecosystem.md` |
| C shim / composition | **None** | The hops that composition amortised are gone; measured gain ≤ 2.9 ns per operation (below). It also removes a second build pipeline |
| `EXPORTED_FUNCTIONS` list | **Not needed** | `-sMAIN_MODULE` is `LINKABLE`, so the whole Stable ABI is exported (all 310 C symbols `EmbedAPI.kt` declared at the time were found in the build's 8287 exports). The build adds `wasmExports,wasmMemory` to `-sEXPORTED_RUNTIME_METHODS`, which is not ABI-sensitive |
| Upcalls (Python → Kotlin) | `@WasmExport` trampolines placed in CPython's `__indirect_function_table` with `WebAssembly.Table.set`; the table index is the C function pointer. Routing is by data (a handle in `self`/`closure`), through the generated table, as on every platform | A funcref is a funcref: `call_indirect` reaches the Kotlin export directly. Measured 3.1 ns vs 10.9 ns for the `addFunction` + JS-closure route |
@@ -44,7 +44,7 @@ has a browser wasm target. `wasmWasi` is not a target (when last assessed it was
cold paths.
- The generated entry module is patched at build time (`patchKotlinWasmOutputForCPython`): one
substitution for the memory, one for the upcall handoff (`pmpSetKotlinExports(exports)`, before
- `exports._start()`). An application must also declare the three `@WasmExport` trampolines itself —
+ `exports._start()`). An application must also declare the three `@WasmExport` trampolines itself,
`@WasmExport` is honoured only in the compilation that produces the `.wasm` (`ProxyTypeExportNames`;
generating that file from the Gradle plugin is not done).
- **JSPI must not reach the embedding.** `cpython.mjs` deletes `WebAssembly.promising` /
@@ -65,9 +65,9 @@ current figures: run `WasmCrossingOverheadTest`, `WasmUpcallRouteOverheadTest`,
| Bulk read, 1000 `i32`: shared memory `Pointer.loadInt()` vs `Module.HEAP32` | 0.7 vs 7.1 ns per element |
| Composition against the interpreter, reading one global (200 000×): naive / + interned strings / + hoisted module+dict | 259.5 / 185.4 / **65.2** ns; a composed call would save 2.9 ns of that |
| Upcall, 10 M indirect calls issued from C: `table.set` vs `addFunction` + JS closure vs C→C control | **3.1** vs 10.9 vs 0.7 ns |
-| Kotlin `METH_O` function vs C builtin `abs` through `PyMethodDef` | 87 vs 73 ns (14 ns is the JS frame CPython's own trampoline imposes — see below) |
+| Kotlin `METH_O` function vs C builtin `abs` through `PyMethodDef` | 87 vs 73 ns (14 ns is the JS frame CPython's own trampoline imposes, see below) |
| String argument, `"version"` (7 chars) / 54 chars: scratch vs interned | 30.6 / 157.0 ns vs 22.2 / 26.4 ns (`WasmMarshallingOverheadTest`, 200 000 iterations) |
-| `char*` → `String`, 27 / 4000 bytes: ASCII `CharArray`+`concatToString()` vs `ByteArray`+`decodeToString()` | 103 / 5 875 ns vs 405 / **58 271** ns — never `decodeToString()` a large buffer |
+| `char*` → `String`, 27 / 4000 bytes: ASCII `CharArray`+`concatToString()` vs `ByteArray`+`decodeToString()` | 103 / 5 875 ns vs 405 / **58 271** ns, never `decodeToString()` a large buffer |
Corrections that came out of measuring (they changed the design):
@@ -86,22 +86,22 @@ Corrections that came out of measuring (they changed the design):
## The interpreter build and the platform tag
-`tools/wasm/build-cpython.sh` builds CPython 3.14.2 with CPython's own `Tools/wasm/emscripten` driver
+`python-multiplatform/scripts/wasm/build-cpython.sh` builds CPython 3.14.2 with CPython's own `Tools/wasm/emscripten` driver
plus the changes that make it claim `pyemscripten_2026_0` (PEP 783):
| | stock Tier 3 build | this build |
|---|---|---|
-| Unwinding ABI `-fwasm-exceptions -sSUPPORT_LONGJMP=wasm` at compile **and** link | absent | **present — this is the one that gates loading a compiled wheel** (the side module imports the `__cpp_exception` tag) |
+| Unwinding ABI `-fwasm-exceptions -sSUPPORT_LONGJMP=wasm` at compile **and** link | absent | **present, this is the one that gates loading a compiled wheel** (the side module imports the `__cpp_exception` tag) |
| `PYEMSCRIPTEN_PLATFORM_VERSION` | not defined anywhere in CPython 3.14.2 | defined via a patch to `sysconfig` (`_ALWAYS_STR`; otherwise `int('2026_0') == 20260` silently yields the tag `pyemscripten_20260_wasm32`) |
| `wasmExports,wasmMemory,wasmTable` in `-sEXPORTED_RUNTIME_METHODS` | no | yes (not ABI-sensitive) |
-| `lzma`, `zstd`, OpenSSL | missing | **still missing** — ABI-sensitive per Pyodide's flag list but they did not gate the wheel tested; costs `_lzma`, `_zstd`, `_hashlib`, `_ssl` |
+| `lzma`, `zstd`, OpenSSL | missing | **still missing**, ABI-sensitive per Pyodide's flag list but they did not gate the wheel tested; costs `_lzma`, `_zstd`, `_hashlib`, `_ssl` |
Evidence: a real PyPI wheel, `pydantic_core-2.48.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl`, imports
-and runs on this build (`validate_python(42) -> 42`, a `ValidationError` raised and caught — the
+and runs on this build (`validate_python(42) -> 42`, a `ValidationError` raised and caught, the
exception exercises the unwinding ABI), and fails with `tag import requires a WebAssembly.Tag` on the
stock build. This is one wheel and one code path; it does not show that every compiled extension
-loads. `WasmCompiledWheelTest` runs it (`tools/wasm/build-cpython.sh wheels` fetches the pinned wheels);
-`WasmInterpreterAbiTest` checks the claims, with `tools/wasm/build-cpython.sh stock` as the negative
+loads. `WasmCompiledWheelTest` runs it (`python-multiplatform/scripts/wasm/build-cpython.sh wheels` fetches the pinned wheels);
+`WasmInterpreterAbiTest` checks the claims, with `python-multiplatform/scripts/wasm/build-cpython.sh stock` as the negative
control. Emscripten is pinned to 5.0.3 because that is what the platform specifies, and installing it
replaces `~/emsdk/upstream` in place (a global setting).
diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md
index 1e4a2a30b..0cc69cb33 100644
--- a/docs/roadmap/ROADMAP.md
+++ b/docs/roadmap/ROADMAP.md
@@ -2,17 +2,17 @@
**What is left to do in this repository, and nothing else.**
-- **Behaviour status** — what is implemented, partial or planned, with the tests that assert it —
+- **Behaviour status**: what is implemented, partial or planned, with the tests that assert it,
is in [`../SPEC.md`](../SPEC.md). This file does not repeat it; it lists the work that remains and
points at the test or document that proves the part already done.
-- **History** — how each item was found, measured and closed — stays in git. The last full version
+- **History**: how each item was found, measured and closed, stays in git. The last full version
of the old progress log is `4ed98143:docs/roadmap/ROADMAP.md`; the whole trail is
`git log -p -- ROADMAP.md docs/roadmap/ROADMAP.md`.
-- **Section numbers 1–16 are the old ones**, kept because other files cite them ("ROADMAP §9",
+- **Section numbers 1–16 are the old ones**: kept because other files cite them ("ROADMAP §9",
"§15d", …). A closed section keeps its heading and one line saying where its evidence is. The old
sub-sections (§2a…§16f) are mapped at the head of each section.
-- Each remaining item says: what is left, its status (**open** — not started or not working;
- **partial** — part done, the rest named; **unverified** — could not be decided from code and tests),
+- Each remaining item says: what is left, its status (**open**, not started or not working;
+ **partial**, part done, the rest named; **unverified**, could not be decided from code and tests),
and the evidence path. Paths are relative to the repository root; `PM` = `python-multiplatform/src`,
`GP` = `python-multiplatform-gradle-plugin/src`.
- Established against code and tests on 2026-10-03 (`develop` @ `5db4168c`) **without running
@@ -27,7 +27,7 @@
(`PM/commonMain/kotlin/python/multiplatform/ffi/Python3.kt`, `initialize`); background cleaners
release through `withGIL` (`PM/commonTest/.../ref/GCLeakTest.kt`).
-- **1.1 A test that fails when a C API call runs without the GIL — partial.** Every C API call is
+- **1.1 A test that fails when a C API call runs without the GIL, partial.** Every C API call is
supposed to hold the GIL (AGENTS.md §16), and three past regressions were exactly an unguarded call
that only crashed once the GIL was really released. The only direct test,
`PM/commonTest/.../ffi/GilParkingTest.kt`, asserts "does not crash". What is left is a guard that
@@ -49,7 +49,7 @@ are deliberate (`ffiAllocUtf8`/`ffiFreeUtf8`/`ffiReadUtf8`, `echoCriticalNamed`)
`docs/investigations/jni-call-convention-audit.md`. Measured reason no function was promoted to
`@CriticalNative`: promoting costs +16.54 ns on API 34 and +25.73 ns on API 36.
-- **3.1 `JniWiringTest.bothListGetItemConventionsAgree` on a device — unverified.** The
+- **3.1 `JniWiringTest.bothListGetItemConventionsAgree` on a device, unverified.** The
`PyList_GetItem` `@FastNative` twin was compile-verified when added; whether this assertion has
passed on `pmp_api26` and `pmp_api36` is not recorded per test. Undecided registrations (65 at the
last count) stay on ordinary JNI by default, which is the safe direction; no action is needed for
@@ -62,34 +62,34 @@ Closed for the ordinary case: wrappers release on GC on every target
`PM/androidInstrumentedTest/.../ref/AndroidCleanerPathTest.kt`), and cross-boundary cycles with a
Python-side edge are collected (§7, SPEC M-3).
-- **4.1 Cycles that close entirely on the Kotlin side — open.** `HandleTable` holds every entry
+- **4.1 Cycles that close entirely on the Kotlin side, open.** `HandleTable` holds every entry
strongly by construction (`PM/commonMain/kotlin/python/multiplatform/reflection/HandleTable.kt`,
KDoc "This table leaks by construction"), so a cycle with no Python-side edge for `tp_traverse` to
report never collects. The mechanism (weak table entries, the proxy supplying the strength) is
named but not worked through in `docs/design/object-lifetime.md`.
-- **4.2 Wrappers that outlive `Py_Finalize()` — open (known limitation).** Their cleaners see
+- **4.2 Wrappers that outlive `Py_Finalize()`, open (known limitation).** Their cleaners see
`!Python3.isInitialized` and skip the release (`Python3.finalize`), which is safe at shutdown but
leaves stale pointers if the interpreter is ever initialised again. No re-initialisation test
exists; `finalize` is `internal` and documented as "will not be able to re-initialize".
## 5. Composition (조립)
-String-carrying operations: **closed** — interning beats composing on every API level (`getAttr`
+String-carrying operations: **closed**, interning beats composing on every API level (`getAttr`
160 ns on API 36, 167 ns on API 26 interned; `docs/design/marshalling-design.md`).
-- **5.1 Bulk iteration through a composed call — open (optional optimisation).** Composition pays
+- **5.1 Bulk iteration through a composed call, open (optional optimisation).** Composition pays
only where crossings scale with N: `list → LongArray` of 1000 elements measured 11.0x faster
composed on API 36 hardware (50 065.89 → 4 532.55 ns,
`PM/androidInstrumentedTest/kotlin/python/native/ffi/CompositionBenchmark.kt`). The probe
(`asmListToArray`, `PM/androidMain/.../bindings.kt`) exists; no production path uses it. Also left:
`PM/artMain/kotlin/python/multiplatform/ffi/JniExport.kt` still exports composed `Python3`
- functions that nothing on the JVM side calls — decide whether to wire or delete them.
+ functions that nothing on the JVM side calls, decide whether to wire or delete them.
-## 6. Composition on desktop — measured, and the answer is no
+## 6. Composition on desktop, measured, and the answer is no
**Closed** (composing would buy ~10 % of an `exec`; not worth new native targets).
-- **6.1 `DesktopOverheadBenchmark.stringMarshallingShareOfARealisticCall` is `@Ignore`d — open.**
+- **6.1 `DesktopOverheadBenchmark.stringMarshallingShareOfARealisticCall` is `@Ignore`d, open.**
Running `exec` 200 000 times aborts the JVM inside `PyDict_New`; clearing the error indicator on
failure paths recovered most of it but the abort's cause is not pinned
(`PM/desktopTest/kotlin/python/native/ffi/DesktopOverheadBenchmark.kt`). Re-enable only once
@@ -99,42 +99,42 @@ String-carrying operations: **closed** — interning beats composing on every AP
Closed in its core: build-time table, KSP generator, one entry point per platform, generated proxies,
`tp_traverse` handle slot on all five targets, GraalVM native image (SPEC U-1…U-6, M-3, M-4;
-`ksp-fixtures/app/src/desktopTest/`, `PM/commonTest/.../reflection/UpcallTableTest.kt`,
+`python-multiplatform-ksp/fixtures/app/src/desktopTest/`, `PM/commonTest/.../reflection/UpcallTableTest.kt`,
`PM/*Test/.../ref/CycleCollectionTest.kt`). Upcall cost is measured
(`PM/commonTest/.../reflection/UpcallOverheadTest.kt`, `PM/commonTest/.../ffi/upcall/GeneratedProxyCostTest.kt`).
-- **7.1 Generated proxies exercised outside desktop — partial.** SPEC U-3: the full proxy behaviour
+- **7.1 Generated proxies exercised outside desktop, partial.** SPEC U-3: the full proxy behaviour
(construct, methods, properties, companions) is asserted on desktop only; outside it there are
- `ksp-fixtures/app/src/androidNativeArm64Test/.../NativeSmokeTest.kt` and
- `ksp-fixtures/android/.../GeneratedAndroidTableTest.kt`. The KSP-generated-proxy cycle proof
- (`ksp-fixtures/app/src/desktopTest/.../RefHolderCycleCollectionTest.kt`) likewise exists on desktop
- only, because `ksp-fixtures/app` has no iOS or device leaf; other targets prove the slot with a
+ `python-multiplatform-ksp/fixtures/app/src/androidNativeArm64Test/.../NativeSmokeTest.kt` and
+ `python-multiplatform-ksp/fixtures/android/.../GeneratedAndroidTableTest.kt`. The KSP-generated-proxy cycle proof
+ (`python-multiplatform-ksp/fixtures/app/src/desktopTest/.../RefHolderCycleCollectionTest.kt`) likewise exists on desktop
+ only, because `python-multiplatform-ksp/fixtures/app` has no iOS or device leaf; other targets prove the slot with a
hand-built subclass (`PM/nativeTest/.../CycleCollectionTest.kt`).
-- **7.2 Extension functions as methods on KSP-generated proxies — partial.** SPEC U-7: asserted for a
+- **7.2 Extension functions as methods on KSP-generated proxies, partial.** SPEC U-7: asserted for a
hand-written table (`PM/commonTest/.../pythonx/PythonxAdapterTest.kt`) and for walked jars
- (`ksp-fixtures/artifact/.../WalkedArtifactComposeModifierTest.kt`); not for a table KSP generated.
-- **7.3 Incremental KSP — open (deferred design change).** A one-file change regenerates the
+ (`python-multiplatform-gradle-plugin/fixtures/artifact/.../WalkedArtifactComposeModifierTest.kt`); not for a table KSP generated.
+- **7.3 Incremental KSP, open (deferred design change).** A one-file change regenerates the
module's whole `Fragment_` (100 % dirty, measured with `ksp.incremental.log`). The only
route is per-file fragments, which changes fragment naming and `UpcallTable`'s duplicate detection;
`docs/design/upcall.md` §2.7.
-- **7.4 Binary-size cost of blacklist exposure — open (product decision).** A table referencing every
+- **7.4 Binary-size cost of blacklist exposure, open (product decision).** A table referencing every
`public` declaration defeats dead-code elimination: ≈1.84 KB per entry in a stripped
`androidNativeArm64` binary (200 synthetic entries, 368 640 bytes; `docs/design/upcall.md` §2.6).
The measurement informs, but does not settle, whether that is acceptable.
-- **7.5 Silent exclusions without a KSP warning — open.** A `suspend` function *type* in a signature
+- **7.5 Silent exclusions without a KSP warning, open.** A `suspend` function *type* in a signature
now warns (`FragmentScanner.warnUnexposableType`); a declaration dropped by
`BindingPolicy.isExposedFunctionShape` (an extension receiver, type parameters) is still dropped with
no warning. `docs/design/upcall.md` §8 item 4.
-- **7.6 Kotlin calling a Python override — open.** Python can subclass a proxy, but a Kotlin caller of a
+- **7.6 Kotlin calling a Python override, open.** Python can subclass a proxy, but a Kotlin caller of a
virtual method never reaches the Python override; nothing implements or asserts it
(`docs/design/upcall.md` §4.4).
-- **7.7 `BYTES` marshalling is per item — open.** `PyBytes_AsStringAndSize` is not declared in
+- **7.7 `BYTES` marshalling is per item, open.** `PyBytes_AsStringAndSize` is not declared in
`EmbedAPI.kt`, so a `ByteArray` crosses one byte at a time; correct (NUL bytes survive, SPEC U-2) but
slow (`docs/design/upcall.md` §3.3).
-- **7.8 Fragment discovery from transitive and published artefacts — unverified.** Discovery is proven
+- **7.8 Fragment discovery from transitive and published artefacts, unverified.** Discovery is proven
for project modules and a `.klib` (`NativeSmokeTest`), not for a fragment arriving through a
transitive Maven dependency (`docs/design/upcall.md` §2.9).
-- **7.9 Proxy-cost tables predate the 100 000-iteration warmup — open (re-measure).**
+- **7.9 Proxy-cost tables predate the 100 000-iteration warmup, open (re-measure).**
`docs/design/upcall.md` §7.5/§7.6 absolutes were taken before the warmup fix; re-cut with
`GeneratedProxyCostTest` through `./benchmarks/cost-table.sh`. Android/androidNative proxy-cost rows
are missing.
@@ -145,9 +145,9 @@ Closed in its core: build-time table, KSP generator, one entry point per platfor
`PyValueLazyConversionTest.kt`). `complex`, builtin subclasses, `memoryview` and user-defined classes
are refused by design (SPEC O-7).
-- **7b.1 NATIVE conversion of a user-defined object — open (design question).** `pyObjectToNative`'s
- fallback returns `str(obj)`, which is lossy and one-way. Three candidates — `__dict__` projection,
- an upcall-table handle, or documenting NATIVE as builtins-only — are written at the branch
+- **7b.1 NATIVE conversion of a user-defined object, open (design question).** `pyObjectToNative`'s
+ fallback returns `str(obj)`, which is lossy and one-way. Three candidates, `__dict__` projection,
+ an upcall-table handle, or documenting NATIVE as builtins-only, are written at the branch
(`PM/commonMain/kotlin/python/multiplatform/ffi/types/collections/CollectionSupport.kt`,
`TODO(open design question)`), with what to measure first.
@@ -169,14 +169,14 @@ The 3.15 migration (`PyWeakref_GetRef`, the two removed functions) is done
(`PM/commonTest/kotlin/python/native/ffi/EmbedApiLowLevelTest.kt`), and 3.15.0rc1 checksums, including
free-threaded ones, are pinned in `python-checksums.properties`.
-- **9.1 `autoDrainInterval`'s default on the GIL build — open (decision).** Today
+- **9.1 `autoDrainInterval`'s default on the GIL build, open (decision).** Today
`if (BuildConfig.pythonFreeThreaded) 32 else 0` (`Python3.kt`). Off means a pure C-API embedder never
runs the cyclic collector: 20 000+ cyclic objects left with the automatic path off against ~1 970
with it on (`docs/investigations/gc-scheduling-investigation.md` §6–§7). On means a `__del__` or
weakref callback can run at a `withGIL` exit the caller did not ask to yield from. The checkpoint
costs ~128 ns over a 154.6 ns `withGIL` floor, ~4 ns amortised
(`EvalCheckpointTest.testCheckpointCostAgainstTheAlternatives`). A one-line change once decided.
-- **9.2 Free-threading beyond desktop — open (blocked upstream).** No free-threaded CPython prebuilt
+- **9.2 Free-threading beyond desktop, open (blocked upstream).** No free-threaded CPython prebuilt
exists for Android (python.org ships `libpython3.14.so`/`3.15.so` only) or iOS (neither BeeWare
nor python.org defines `Py_GIL_DISABLED`), checked 2026-08-12. SPEC N-1.
@@ -187,34 +187,34 @@ cycles, interning and `Py_ssize_t` are closed (`PM/wasmJsTest/`: `WasmFinalizati
`WasmCycleCollectionTest`, `WasmPySsizeTBoundaryTest`, `WasmBrowserRuntimeTest`,
`WasmSelectorsImportTest`); the plugin stages the runtime for consumers
(`GP/main/kotlin/python/multiplatform/gradle/WasmBrowserRuntimeStaging.kt`,
-`GenerateWasmProxyExportsTask.kt`). The interpreter is built by `tools/wasm/build-cpython.sh`
+`GenerateWasmProxyExportsTask.kt`). The interpreter is built by `python-multiplatform/scripts/wasm/build-cpython.sh`
(`docs/platforms/wasm-design.md`). Old §10c/§10d (browser test, CI split) are closed; their open
remainder is 10.1.
-- **10.1 The wasm test suite does not run in CI — open.** `.github/workflows/wasm.yml` runs the
+- **10.1 The wasm test suite does not run in CI, open.** `.github/workflows/wasm.yml` runs the
`test` job only when the repository variable `WASM_RUNTIME_URL` is set. It is not set (observed
2026-10-03: `gh variable list` empty; the latest `develop` run shows "WasmJS Node + browser tests:
skipped", compile job green). What closes it is uploading the
`python-multiplatform-wasm-runtime` zip (7.0 MB) somewhere a runner can fetch and setting the
variable; see also 15.1.
-- **10.2 `suspend` upcalls and `asyncio` on wasm — open.** SPEC U-5 is `planned` on wasm (no threads),
+- **10.2 `suspend` upcalls and `asyncio` on wasm, open.** SPEC U-5 is `planned` on wasm (no threads),
and `import asyncio` traps the instance (`asyncio.run` wants `socketpair`); the sample skips
section 7 there (`sample/src/wasmJsMain/.../bindings/ProxyDemo.wasmJs.kt`).
-- **10.3 A wasm consumer must hand-write the `pmp_invoke` export — open.** `@WasmExport` is honoured
+- **10.3 A wasm consumer must hand-write the `pmp_invoke` export, open.** `@WasmExport` is honoured
only in the compilation that produces the final `.wasm`, so the library cannot export the general
upcall dispatcher for a consumer; `GenerateWasmProxyExportsTask` generates only the three
proxy-type slots. Without the three-line export (`PM/wasmJsTest/.../UpcallExports.kt` shows it),
`UpcallBootstrap.publishToGlobals()` throws. Generating it from the plugin, as was done for the slots,
is the obvious step.
-- **10.4 A consumer's bundle in a real browser / production bundler — unverified.** The only browser
+- **10.4 A consumer's bundle in a real browser / production bundler, unverified.** The only browser
execution evidence is the in-repo `:sample` and `wasmJsBrowserTest`. An external consumer
(`consumer-plugin-android`) was verified to *build* `wasmJsBrowserDistribution` with the runtime
staged by the plugin; it was never run in a browser, and no production bundler other than the
Kotlin Gradle webpack setup was tried (also `docs/design/ecosystem.md` §5 "Unverified").
-- **10.5 The wasm CPython build lacks lzma, zstd and OpenSSL — open.** Listed as "NOT DONE" in
- `tools/wasm/build-cpython.sh`'s header; ABI-sensitive per the Pyodide flag list, though they did not
+- **10.5 The wasm CPython build lacks lzma, zstd and OpenSSL, open.** Listed as "NOT DONE" in
+ `python-multiplatform/scripts/wasm/build-cpython.sh`'s header; ABI-sensitive per the Pyodide flag list, though they did not
gate the one compiled wheel tested (`WasmCompiledWheelTest`).
-- **10.6 The sample's wasm upcall line (`presses x3 = 0`) was never cross-checked against desktop —
+- **10.6 The sample's wasm upcall line (`presses x3 = 0`) was never cross-checked against desktop,
unverified, minor.**
## 11. Build wiring
@@ -231,25 +231,25 @@ pre-release URL, `PyList.subList` (a live `PySubList`), the `TODO` triage, `runM
(`PythonBootstrapTest.initializeLeavesTempfileUsable`), the `EmbedAPI.kt` section order (sections
27–29 now sit in docs order), stale test headers, the sample revisit.
-- **12.1 `Python3.finalize` should call `Py_FinalizeEx` — open.** It still calls `Py_Finalize()`
+- **12.1 `Python3.finalize` should call `Py_FinalizeEx`, open.** It still calls `Py_Finalize()`
(`Python3.kt`, `finalize`); `Py_FinalizeEx`'s `expect` and every `actual` already exist. Blocked
only by finalisation being untestable in a suite that shares one interpreter (its only caller is
`PM/artMain/.../ffi/JniExport.kt`).
-- **12.2 `TMPDIR` for androidNative inside an app, and on an iOS device — open / unverified.** The
+- **12.2 `TMPDIR` for androidNative inside an app, and on an iOS device, open / unverified.** The
androidNative suite passes only because `adb shell` sets `TMPDIR=/data/local/tmp`; a binary shipped
in an app would have neither `/tmp` nor that variable. iOS passes on the simulator because `/tmp`
is the host's; a device needs `NSTemporaryDirectory()`, never run. Probe: the `scriptPath…` cases
of `RunAppTest`.
-- **12.3 Linux and Windows desktop have never run the suite — open.** `.github/workflows/desktop.yml`
+- **12.3 Linux and Windows desktop have never run the suite, open.** `.github/workflows/desktop.yml`
has `os: [macos-latest]` only, and no log anywhere shows a Linux or Windows run (SPEC P-2, N-4). Adding
the two runners to the matrix is the step.
-- **12.4 CI on `develop` is not green — open (observed 2026-10-03).** All four platform workflows now
+- **12.4 CI on `develop` is not green, open (observed 2026-10-03).** All four platform workflows now
run on GitHub Actions (the old "CI has never run" is closed). The latest `develop` Desktop CI run
failed one test, `PyValueBytesConversionTest.bytesConversionCostIsPaidOncePerValueAndIsReportedAgainstStr`
(a timing assertion: 1000 cached reads must cost less than one conversion), while the same commit's
- PR run passed — a timing assertion that is not stable on a shared runner. Decide whether such
+ PR run passed, a timing assertion that is not stable on a shared runner. Decide whether such
assertions belong in CI.
-- **12.5 BeeWare's iOS archives (≤ 3.14) carry no signature or checksum — not fixable here.** The
+- **12.5 BeeWare's iOS archives (≤ 3.14) carry no signature or checksum, not fixable here.** The
SHA-256 lockfile pin is the only integrity check for that source; python.org's signed XCframework
replaces it from 3.15. python-build-standalone likewise publishes no per-file signature
(`SHA256SUMS` only). `docs/platforms/python-version-acquisition.md` §5.
@@ -257,27 +257,27 @@ pre-release URL, `PyList.subList` (a live `PySubList`), the `TODO` triage, `runM
## 13. The sample, and the AGP version that shapes it
The sample runs on desktop, Android (both emulators), the iOS simulator and in a browser; the bindings
-plugin applies to Android modules (AGP 8.10.1); `ksp-fixtures/android` exists; the generated
+plugin applies to Android modules (AGP 8.10.1); `python-multiplatform-ksp/fixtures/android` exists; the generated
`installGeneratedUpcallTable` `actual` reaches intermediate source sets; the per-platform upcall
trampolines exist. GraalVM native image upcalls are verified by hand
(`docs/platforms/graal-native-image-verification.md`, `PM/desktopTest/.../ReachabilityMetadataTest.kt`).
-- **13.1 The iOS app bundle carries no Python standard library — implemented, simulator proof
+- **13.1 The iOS app bundle carries no Python standard library, implemented, simulator proof
open.** SPEC L-11, issue #59, `docs/platforms/ios-app-bundle.md`: per-slice staging
- (`stageIosPythonHome_*`, `stageIosPythonHomeForXcode`), `tools/xcode/install-python.sh` (stdlib to
+ (`stageIosPythonHome_*`, `stageIosPythonHomeForXcode`), `python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh` (stdlib to
`/python-multiplatform-home/`, payload to `/python/`, `.so` wrapped as frameworks with
- `.fwork` placeholders) replacing `iosApp/`'s two broken phases, and `IosPythonHome` +
+ `.fwork` placeholders) replacing the two broken phases of the old root `iosApp/` (now `sample/src/iosMain/app.xcodeproj`, #107), and `IosPythonHome` +
`Py_SetPythonHome` at run time. Left: the installed-app run on the simulator with no
`SIMCTL_CHILD_*` (the page's "Checking it"); a device run; and a way for consumers outside this
repository to stage the stdlib (the iOS archive download is in this build script, not the plugin).
-- **13.2 Native-image upcall verification as an automated test — open.** SPEC U-6/N-5: the procedure
+- **13.2 Native-image upcall verification as an automated test, open.** SPEC U-6/N-5: the procedure
is manual (`:sample:nativeCompile`, Liberica NIK); only the reachability metadata is guarded
automatically.
-- **13.3 iOS sample section 7, slow path — open (sample only).** "not wired on iOS": resuming a parked
+- **13.3 iOS sample section 7, slow path, open (sample only).** "not wired on iOS": resuming a parked
continuation needs a Kotlin/Native worker the sample does not start
(`sample/src/iosMain/.../bindings/ProxyDemo.ios.kt`). The library itself does it
(`PM/nativeTest/.../AsyncUpcallNative*Test.kt`).
-- **13.4 Targets compiled but never tested — open.** `iosArm64`, `iosX64`, `androidNativeX64` (SPEC
+- **13.4 Targets compiled but never tested, open.** `iosArm64`, `iosX64`, `androidNativeX64` (SPEC
P-1). For `iosX64`, `extractIosSimulatorStdlib` hardcodes `lib-arm64`
(`python-multiplatform/build.gradle.kts`, the `…/ios-arm64_x86_64-simulator/lib-arm64/python$libVersion`
copy); an x86_64 simulator needs `lib-x86_64`. Stale text nearby: that task's KDoc still says
@@ -300,22 +300,22 @@ stripped), the root metadata jar is 382 KB, `PythonBootstrap` stages the Android
desktop stdlib (`GP/test/kotlin/python/multiplatform/gradle/PythonHomeStagingTest.kt`, SPEC L-4).
Old §15a, §15b, §15d, §15f, §15g, §15h are closed; §15e item 4 is closed by §15h.
-- **15.1 Nothing is published to a remote repository — open.** No publishing repository or signing is
+- **15.1 Nothing is published to a remote repository, open.** No publishing repository or signing is
configured (`signing { }` is commented out in `python-multiplatform/build.gradle.kts`), and
`repo1.maven.org/maven2/io/github/thisisthepy/python-multiplatform/` returns 404 (2026-10-03).
Every consumer verification so far used `mavenLocal()`. 10.1 depends on this or on a release asset.
-- **15.2 The KSP processor and plugin versions are hand-copied literals — open.** Root
+- **15.2 The KSP processor and plugin versions are hand-copied literals, open.** Root
`build.gradle.kts` `allprojects { version = "3.13.0" }` (inherited by `python-multiplatform-ksp`) and
`python-multiplatform-gradle-plugin/build.gradle.kts` `version = "3.13.0"` agree only by hand, while
the library publishes `3.14.7-alpha01` from `gradle.properties`. The plugin bakes
`DEFAULT_PROCESSOR_COORDINATES` from its own version, so the two literals drifting would reproduce
the "processor coordinate that exists nowhere" defect with no configuration-time error. A check, or
one source for both, is what is left.
-- **15.3 `stagePythonHome` has run only on `macos-aarch64` — partial.** Linux-x86_64, macOS-x86_64 and
+- **15.3 `stagePythonHome` has run only on `macos-aarch64`, partial.** Linux-x86_64, macOS-x86_64 and
Windows-x86_64 are pinned as string assertions in `PythonHomeStagingTest`; Windows' `Lib/`+`DLLs/`
layout and `linux-aarch64` (no `libpython` in `desktopJar`, relies on `manager.loadFromSidecar`) have
never executed.
-- **15.4 A packaged desktop application carries no CPython prefix — implemented, end-to-end check
+- **15.4 A packaged desktop application carries no CPython prefix, implemented, end-to-end check
pending (issue #60, SPEC L-9).** The plugin copies the staged prefix's stdlib and `libpython` into
Compose Desktop's `prepare*AppResources` (so `createDistributable` and `package*`) as
`python-multiplatform-home/`; at run time the library resolves it through
@@ -325,44 +325,44 @@ Old §15a, §15b, §15d, §15f, §15g, §15h are closed; §15e item 4 is closed
`docs/platforms/desktop-packaged-app.md`. Left: Conveyor and non-Compose jpackage need the
property set by hand; no `.pyc` is shipped; only the build host's platform is packaged (jpackage
builds for its host anyway).
-- **15.5 The Android AAR duplicates the pure-Python stdlib per ABI — open (measured option, not
+- **15.5 The Android AAR duplicates the pure-Python stdlib per ABI, open (measured option, not
taken).** 790 files / 11.91 MB are byte-identical between `arm64-v8a` and `x86_64`; de-duplicating
saves 3.16 MB compressed but changes the published asset layout `/lib/python` that
`PythonBootstrap` and consumers read.
## 16. Bindings from resolved artefacts (the artefact walker)
-The walker binds jar declarations — statics, top-level functions, extensions, constructors, value
-classes, defaults, callbacks, `@Composable`s — by generating Kotlin source (SPEC B-1, B-3, B-4, B-6;
-`GP/test/.../artifact/ArtifactScannerTest.kt`, `ksp-fixtures/artifact/`, `ksp-fixtures/compose/`),
+The walker binds jar declarations, statics, top-level functions, extensions, constructors, value
+classes, defaults, callbacks, `@Composable`s, by generating Kotlin source (SPEC B-1, B-3, B-4, B-6;
+`GP/test/.../artifact/ArtifactScannerTest.kt`, `python-multiplatform-gradle-plugin/fixtures/artifact/`, `python-multiplatform-compose/fixtures/compose/`),
reads Kotlin metadata (`GP/main/.../artifact/KotlinMetadata.kt`), walks klibs through an isolated
worker (`KlibScanner.kt`, `KlibScanWorkAction.kt`), and takes several targets (`artifactTargets`,
`GP/test/.../ArtifactTargetsWiringTest.kt`). Consumers bootstrap `_pm_resolve`/`_pm_invoke` through
`UpcallBootstrap` (`PM/commonTest/.../ffi/upcall/UpcallBootstrapTest.kt`). Old §16a–§16d are
description, §16e/§16f's open remainder is below.
-- **16.1 klib declarations bound at run time — partial.** SPEC B-2/N-3. `KlibScannerTest` reads real
- klibs and declines with reasons; `ksp-fixtures/klib-artifact` walks
+- **16.1 klib declarations bound at run time, partial.** SPEC B-2/N-3. `KlibScannerTest` reads real
+ klibs and declines with reasons; `python-multiplatform-gradle-plugin/fixtures/klib-artifact` walks
`androidNativeArm64CompileKlibraries` through the plugin, but the namespace it walks
(`kotlinx.coroutines`) yields **zero** bindings by design, so a successful read and a silently
failed one still produce the same empty `ArtifactTable`
- (`ksp-fixtures/klib-artifact/src/androidNativeArm64Test/.../WalkedKlibArtifactTableTest.kt`). And no
- Gradle task runs that fixture's tests — they are compiled and linked, and run only by pushing
+ (`python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/androidNativeArm64Test/.../WalkedKlibArtifactTableTest.kt`). And no
+ Gradle task runs that fixture's tests, they are compiled and linked, and run only by pushing
`test.kexe` by hand. Next: a namespace that yields at least one binding, and a run task.
-- **16.2 The product klib path under a Kotlin version skew — open.** `KlibScannerTest` reads klibs
+- **16.2 The product klib path under a Kotlin version skew, open.** `KlibScannerTest` reads klibs
from Kotlin/Native 2.0.20 in-process; reading this build's 2.4.20-Beta2 klibs in-process fails with
`InvalidProtocolBufferException`. The worker path with a per-build reader classpath exists to avoid
that, and nothing asserts it does.
-- **16.3 `aar` files and project class directories are skipped silently — open.** Only `.jar` and
+- **16.3 `aar` files and project class directories are skipped silently, open.** Only `.jar` and
`.klib` are walked (`GP/main/.../artifact/PythonArtifactBindingsTask.kt` KDoc and filter).
-- **16.4 Member (instance) functions of jar classes are not bound — open.** The scanner drops
+- **16.4 Member (instance) functions of jar classes are not bound, open.** The scanner drops
non-`static` methods: "an instance method needs a receiver" (`ArtifactScanner.kt` KDoc table).
-- **16.5 A value-returning function slot (`() -> Float`) does not accept a Python callable — open.**
+- **16.5 A value-returning function slot (`() -> Float`) does not accept a Python callable, open.**
SPEC B-5; pinned red-by-design by `M3ProofRenderTest.aValueReturningFunctionSlotDoesNotYetAcceptAPythonCallable`.
-- **16.6 `.pyi` stubs for handle-returning functions are not wrapped — open.** SPEC B-7.
-- **16.7 Compose through Python on Android, iOS and wasm — open.** SPEC B-6/N-2: the render proofs
- (`ksp-fixtures/compose/src/desktopTest/`) are desktop only.
-- **16.8 Two material3 components not reachable from Python — open.** The dynamic `ColorScheme`
+- **16.6 `.pyi` stubs for handle-returning functions are not wrapped, open.** SPEC B-7.
+- **16.7 Compose through Python on Android, iOS and wasm, open.** SPEC B-6/N-2: the render proofs
+ (`python-multiplatform-compose/fixtures/compose/src/desktopTest/`) are desktop only.
+- **16.8 Two material3 components not reachable from Python, open.** The dynamic `ColorScheme`
factory is Android-only. Since #73 the class name resolves to `ColorScheme`'s own constructor (its
`Color` parameters no longer hide it), which needs every colour written out; nothing renders with one
yet (`M3ProofRenderTest.colorSchemeResolvesToItsConstructorAndABareCallNamesItsOverloads`). `DropdownMenu`
@@ -371,24 +371,24 @@ description, §16e/§16f's open remainder is below.
## 17. Owed to the ecosystem, and decisions waiting on the maintainer
-From `docs/design/ecosystem.md` §5, "What each repository owes — PythonMultiplatform". Of its gaps,
+From `docs/design/ecosystem.md` §5, "What each repository owes, PythonMultiplatform". Of its gaps,
the `@Composable` callable shape, the `sys.meta_path` finder (`_Finder` in
`PM/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt`, tested in
`PythonxAdapterTest.kt`), the `Composer` hand-off and Python-callable lifetime across recompositions
-(`ksp-fixtures/compose/src/desktopTest/.../ComposableRenderTest.kt`, `RecompositionAccumulationTest.kt`)
+(`python-multiplatform-compose/fixtures/compose/src/desktopTest/.../ComposableRenderTest.kt`, `RecompositionAccumulationTest.kt`)
are done on desktop; the other platforms are 16.7. The production-bundler question is 10.4.
-- **17.1 Retire `stagePythonHome` in favour of pypackpack's Python distribution — open (cross-repo).**
+- **17.1 Retire `stagePythonHome` in favour of pypackpack's Python distribution, open (cross-repo).**
Two acquisition paths disagree on source and destination (`ecosystem.md` §4 item 4). The work lands
in pypackpack first; this repository then removes `PythonHomeStaging.kt`. Needs the maintainer's go
(AGENTS.md §7).
-- **17.2 Hand `stageWasmBrowserRuntime` to toolchain — open (cross-repo).** Same shape as 17.1, for the
+- **17.2 Hand `stageWasmBrowserRuntime` to toolchain, open (cross-repo).** Same shape as 17.1, for the
wasm runtime staging (`WasmBrowserRuntimeStaging.kt`).
-- **17.3 Where the generic `pythonx` adapter machinery belongs — decision.** It lives in
+- **17.3 Where the generic `pythonx` adapter machinery belongs, decision.** It lives in
`PM/commonMain/kotlin/python/multiplatform/ffi/pythonx/`, while INTENT §2.3 puts `pythonx` in
pythonx-compose (SPEC "Outside intent" item 4). It no longer synthesises a `pythonx` module
(it installs `python_multiplatform.binding`); whether it stays here as a service is unstated.
-- **17.4 The committed CPython 3.13 trees — decision.** `python-multiplatform/src/nativeInterop/cinterop/lib`
- (15 555 tracked files, Android/desktop/iOS) and `binary/` (3.13.0 archives) are read only by the
- `-PpythonVersion=3.13.0` desktop branch; the Android 3.13 stdlib trees by nothing. INTENT §3 says the
+- **17.4 The committed CPython 3.13 trees, decision.** `python-multiplatform/src/nativeInterop/cinterop/lib`
+ (15 555 tracked files, Android/desktop/iOS) is read only by the
+ `-PpythonVersion=3.13.0` desktop branch (`binary/`, unreferenced 3.13.0 archives, was removed on 2026-10-04); the Android 3.13 stdlib trees by nothing. INTENT §3 says the
interpreter is not vendored. Removing them is a repository-size decision (`ecosystem.md` §5).
diff --git a/gradle.properties b/gradle.properties
index a0b3f7c09..c115670bb 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -23,9 +23,9 @@ org.jetbrains.compose.experimental.jscanvas.enabled=true
org.jetbrains.compose.experimental.uikit.enabled=true
# Python Build
-pythonVersion=3.14.7
+pythonVersion=3.15.0
pythonFreeThreaded=false
-pythonBuildStandaloneRelease=20260807
+pythonBuildStandaloneRelease=20261013
pythonAppleSupportBuild=b11
# GraalVM native-image (sample's nativeCompile task). Liberica NIK is not in a location Gradle's
@@ -34,3 +34,12 @@ pythonAppleSupportBuild=b11
# toolchain any existing task resolves to. See AGENTS.md §19 for why tooling lives
# under /Volumes/macMini rather than the internal disk.
org.gradle.java.installations.paths=/Volumes/macMini/toolchains/bellsoft-liberica-vm-full-openjdk25-25.0.4/Contents/Home
+
+# compose-multiplatform-core-extended (#191, #249): the Compose modules a native-image build takes from
+# the fork instead of upstream, and where from. Read in one place, by the notebook E2E's native image;
+# the fork's coordinates and version line are changing (to 1.11.1-ext.N), and only these lines follow.
+composeExtended.version=1.11.1001
+# The group the fork publishes under, as a prefix of upstream's (the fork moves to org.thisisthepy.compose).
+composeExtended.groupPrefix=org.jetbrains.compose
+composeExtended.modules=org.jetbrains.compose.ui:ui,org.jetbrains.compose.ui:ui-desktop,org.jetbrains.compose.ui:ui-text,org.jetbrains.compose.ui:ui-text-desktop,org.jetbrains.compose.foundation:foundation,org.jetbrains.compose.foundation:foundation-desktop
+composeExtended.mavenLocal=true
diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig
deleted file mode 100644
index 17b5a8b76..000000000
--- a/iosApp/Configuration/Config.xcconfig
+++ /dev/null
@@ -1,3 +0,0 @@
-TEAM_ID=
-BUNDLE_ID=org.thisisthepy.python.multiplatform.demo
-APP_NAME=PythonDemo
diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj
deleted file mode 100644
index 7f0dbc4af..000000000
--- a/iosApp/iosApp.xcodeproj/project.pbxproj
+++ /dev/null
@@ -1,436 +0,0 @@
-// !$*UTF8*$!
-{
- archiveVersion = 1;
- classes = {
- };
- objectVersion = 56;
- objects = {
-
-/* Begin PBXBuildFile section */
- 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; };
- 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; };
- 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
- 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; };
- 8B3F1A512CC49F1D00B85330 /* Python.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B3F1A502CC49F1D00B85330 /* Python.xcframework */; };
- 8B3F1A5C2CC574B200B85330 /* Python.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8B3F1A502CC49F1D00B85330 /* Python.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
-/* End PBXBuildFile section */
-
-/* Begin PBXCopyFilesBuildPhase section */
- 8B3F1A4F2CC498EB00B85330 /* Embed Frameworks */ = {
- isa = PBXCopyFilesBuildPhase;
- buildActionMask = 2147483647;
- dstPath = "";
- dstSubfolderSpec = 10;
- files = (
- 8B3F1A5C2CC574B200B85330 /* Python.xcframework in Embed Frameworks */,
- );
- name = "Embed Frameworks";
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXCopyFilesBuildPhase section */
-
-/* Begin PBXFileReference section */
- 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
- 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
- 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; };
- 7555FF7B242A565900829871 /* PythonDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PythonDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
- 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
- 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
- 8B3F1A502CC49F1D00B85330 /* Python.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Python.xcframework; path = "../sample/build/xcode-frameworks/Python.xcframework"; sourceTree = ""; };
- 8B3F1A5A2CC4A49700B85330 /* dylib-Info-template.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "dylib-Info-template.plist"; sourceTree = ""; };
- AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; };
-/* End PBXFileReference section */
-
-/* Begin PBXFrameworksBuildPhase section */
- B92378962B6B1156000C7307 /* Frameworks */ = {
- isa = PBXFrameworksBuildPhase;
- buildActionMask = 2147483647;
- files = (
- 8B3F1A512CC49F1D00B85330 /* Python.xcframework in Frameworks */,
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXFrameworksBuildPhase section */
-
-/* Begin PBXGroup section */
- 058557D7273AAEEB004C7B11 /* Preview Content */ = {
- isa = PBXGroup;
- children = (
- 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */,
- );
- path = "Preview Content";
- sourceTree = "";
- };
- 42799AB246E5F90AF97AA0EF /* Frameworks */ = {
- isa = PBXGroup;
- children = (
- 8B3F1A502CC49F1D00B85330 /* Python.xcframework */,
- );
- name = Frameworks;
- sourceTree = "";
- };
- 7555FF72242A565900829871 = {
- isa = PBXGroup;
- children = (
- AB1DB47929225F7C00F7AF9C /* Configuration */,
- 7555FF7D242A565900829871 /* iosApp */,
- 7555FF7C242A565900829871 /* Products */,
- 42799AB246E5F90AF97AA0EF /* Frameworks */,
- );
- sourceTree = "";
- };
- 7555FF7C242A565900829871 /* Products */ = {
- isa = PBXGroup;
- children = (
- 7555FF7B242A565900829871 /* PythonDemo.app */,
- );
- name = Products;
- sourceTree = "";
- };
- 7555FF7D242A565900829871 /* iosApp */ = {
- isa = PBXGroup;
- children = (
- 058557BA273AAA24004C7B11 /* Assets.xcassets */,
- 7555FF82242A565900829871 /* ContentView.swift */,
- 7555FF8C242A565B00829871 /* Info.plist */,
- 2152FB032600AC8F00CF470E /* iOSApp.swift */,
- 058557D7273AAEEB004C7B11 /* Preview Content */,
- 8B3F1A5A2CC4A49700B85330 /* dylib-Info-template.plist */,
- );
- path = iosApp;
- sourceTree = "";
- };
- AB1DB47929225F7C00F7AF9C /* Configuration */ = {
- isa = PBXGroup;
- children = (
- AB3632DC29227652001CCB65 /* Config.xcconfig */,
- );
- path = Configuration;
- sourceTree = "";
- };
-/* End PBXGroup section */
-
-/* Begin PBXNativeTarget section */
- 7555FF7A242A565900829871 /* iosApp */ = {
- isa = PBXNativeTarget;
- buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
- buildPhases = (
- F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */,
- 7555FF77242A565900829871 /* Sources */,
- B92378962B6B1156000C7307 /* Frameworks */,
- 7555FF79242A565900829871 /* Resources */,
- 8B3F1A532CC49F9C00B85330 /* Install Python Standard Library and Payload */,
- 8B3F1A4F2CC498EB00B85330 /* Embed Frameworks */,
- );
- buildRules = (
- );
- dependencies = (
- );
- name = iosApp;
- packageProductDependencies = (
- );
- productName = iosApp;
- productReference = 7555FF7B242A565900829871 /* PythonDemo.app */;
- productType = "com.apple.product-type.application";
- };
-/* End PBXNativeTarget section */
-
-/* Begin PBXProject section */
- 7555FF73242A565900829871 /* Project object */ = {
- isa = PBXProject;
- attributes = {
- BuildIndependentTargetsInParallel = YES;
- LastSwiftUpdateCheck = 1130;
- LastUpgradeCheck = 1600;
- ORGANIZATIONNAME = orgName;
- TargetAttributes = {
- 7555FF7A242A565900829871 = {
- CreatedOnToolsVersion = 11.3.1;
- };
- };
- };
- buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */;
- compatibilityVersion = "Xcode 14.0";
- developmentRegion = en;
- hasScannedForEncodings = 0;
- knownRegions = (
- en,
- Base,
- );
- mainGroup = 7555FF72242A565900829871;
- packageReferences = (
- );
- productRefGroup = 7555FF7C242A565900829871 /* Products */;
- projectDirPath = "";
- projectRoot = "";
- targets = (
- 7555FF7A242A565900829871 /* iosApp */,
- );
- };
-/* End PBXProject section */
-
-/* Begin PBXResourcesBuildPhase section */
- 7555FF79242A565900829871 /* Resources */ = {
- isa = PBXResourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */,
- 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */,
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXResourcesBuildPhase section */
-
-/* Begin PBXShellScriptBuildPhase section */
- 8B3F1A532CC49F9C00B85330 /* Install Python Standard Library and Payload */ = {
- isa = PBXShellScriptBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- inputFileListPaths = (
- );
- inputPaths = (
- );
- name = "Install Python Standard Library and Payload";
- outputFileListPaths = (
- );
- outputPaths = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "set -e\n# SPEC L-10 / issue #59: the CPython stdlib and this app's python/ payload into the .app, with\n# extension modules wrapped as frameworks. See docs/platforms/ios-app-bundle.md.\ncd \"$SRCROOT/..\"\nexport PYTHON_PAYLOAD_DIR=\"$SRCROOT/../sample/python/src/main\"\n/bin/bash tools/xcode/install-python.sh\n";
- };
- F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */ = {
- isa = PBXShellScriptBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- inputFileListPaths = (
- );
- inputPaths = (
- );
- name = "Compile Kotlin Framework";
- outputFileListPaths = (
- );
- outputPaths = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :python-multiplatform:embedAndSignAppleFrameworkForXcode\n./gradlew :sample:embedAndSignAppleFrameworkForXcode\n\n";
- };
-/* End PBXShellScriptBuildPhase section */
-
-/* Begin PBXSourcesBuildPhase section */
- 7555FF77242A565900829871 /* Sources */ = {
- isa = PBXSourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */,
- 7555FF83242A565900829871 /* ContentView.swift in Sources */,
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXSourcesBuildPhase section */
-
-/* Begin XCBuildConfiguration section */
- 7555FFA3242A565B00829871 /* Debug */ = {
- isa = XCBuildConfiguration;
- baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
- buildSettings = {
- ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
- CLANG_ANALYZER_NONNULL = YES;
- CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
- CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
- CLANG_CXX_LIBRARY = "libc++";
- CLANG_ENABLE_MODULES = YES;
- CLANG_ENABLE_OBJC_ARC = YES;
- CLANG_ENABLE_OBJC_WEAK = YES;
- CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
- CLANG_WARN_BOOL_CONVERSION = YES;
- CLANG_WARN_COMMA = YES;
- CLANG_WARN_CONSTANT_CONVERSION = YES;
- CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
- CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
- CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
- CLANG_WARN_EMPTY_BODY = YES;
- CLANG_WARN_ENUM_CONVERSION = YES;
- CLANG_WARN_INFINITE_RECURSION = YES;
- CLANG_WARN_INT_CONVERSION = YES;
- CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
- CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
- CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
- CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
- CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
- CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
- CLANG_WARN_STRICT_PROTOTYPES = YES;
- CLANG_WARN_SUSPICIOUS_MOVE = YES;
- CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
- CLANG_WARN_UNREACHABLE_CODE = YES;
- CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
- COPY_PHASE_STRIP = NO;
- DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
- ENABLE_STRICT_OBJC_MSGSEND = YES;
- ENABLE_TESTABILITY = YES;
- ENABLE_USER_SCRIPT_SANDBOXING = NO;
- GCC_C_LANGUAGE_STANDARD = gnu11;
- GCC_DYNAMIC_NO_PIC = NO;
- GCC_NO_COMMON_BLOCKS = YES;
- GCC_OPTIMIZATION_LEVEL = 0;
- GCC_PREPROCESSOR_DEFINITIONS = (
- "DEBUG=1",
- "$(inherited)",
- );
- GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
- GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
- GCC_WARN_UNDECLARED_SELECTOR = YES;
- GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
- GCC_WARN_UNUSED_FUNCTION = YES;
- GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 15.3;
- MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
- MTL_FAST_MATH = YES;
- ONLY_ACTIVE_ARCH = YES;
- SDKROOT = iphoneos;
- SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
- SWIFT_OPTIMIZATION_LEVEL = "-Onone";
- };
- name = Debug;
- };
- 7555FFA4242A565B00829871 /* Release */ = {
- isa = XCBuildConfiguration;
- baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
- buildSettings = {
- ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
- CLANG_ANALYZER_NONNULL = YES;
- CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
- CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
- CLANG_CXX_LIBRARY = "libc++";
- CLANG_ENABLE_MODULES = YES;
- CLANG_ENABLE_OBJC_ARC = YES;
- CLANG_ENABLE_OBJC_WEAK = YES;
- CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
- CLANG_WARN_BOOL_CONVERSION = YES;
- CLANG_WARN_COMMA = YES;
- CLANG_WARN_CONSTANT_CONVERSION = YES;
- CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
- CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
- CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
- CLANG_WARN_EMPTY_BODY = YES;
- CLANG_WARN_ENUM_CONVERSION = YES;
- CLANG_WARN_INFINITE_RECURSION = YES;
- CLANG_WARN_INT_CONVERSION = YES;
- CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
- CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
- CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
- CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
- CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
- CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
- CLANG_WARN_STRICT_PROTOTYPES = YES;
- CLANG_WARN_SUSPICIOUS_MOVE = YES;
- CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
- CLANG_WARN_UNREACHABLE_CODE = YES;
- CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
- COPY_PHASE_STRIP = NO;
- DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
- ENABLE_NS_ASSERTIONS = NO;
- ENABLE_STRICT_OBJC_MSGSEND = YES;
- ENABLE_USER_SCRIPT_SANDBOXING = NO;
- GCC_C_LANGUAGE_STANDARD = gnu11;
- GCC_NO_COMMON_BLOCKS = YES;
- GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
- GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
- GCC_WARN_UNDECLARED_SELECTOR = YES;
- GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
- GCC_WARN_UNUSED_FUNCTION = YES;
- GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 15.3;
- MTL_ENABLE_DEBUG_INFO = NO;
- MTL_FAST_MATH = YES;
- SDKROOT = iphoneos;
- SWIFT_COMPILATION_MODE = wholemodule;
- SWIFT_OPTIMIZATION_LEVEL = "-O";
- VALIDATE_PRODUCT = YES;
- };
- name = Release;
- };
- 7555FFA6242A565B00829871 /* Debug */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
- CODE_SIGN_IDENTITY = "Apple Development";
- CODE_SIGN_STYLE = Automatic;
- DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
- DEVELOPMENT_TEAM = ZP73KJVT6Q;
- ENABLE_PREVIEWS = YES;
- FRAMEWORK_SEARCH_PATHS = (
- "$(inherited)",
- "$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
- );
- INFOPLIST_FILE = iosApp/Info.plist;
- IPHONEOS_DEPLOYMENT_TARGET = 15.3;
- LD_RUNPATH_SEARCH_PATHS = (
- "$(inherited)",
- "@executable_path/Frameworks",
- );
- PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
- PRODUCT_NAME = "${APP_NAME}";
- PROVISIONING_PROFILE_SPECIFIER = "";
- SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
- };
- name = Debug;
- };
- 7555FFA7242A565B00829871 /* Release */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
- CODE_SIGN_IDENTITY = "Apple Development";
- CODE_SIGN_STYLE = Automatic;
- DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
- DEVELOPMENT_TEAM = ZP73KJVT6Q;
- ENABLE_PREVIEWS = YES;
- FRAMEWORK_SEARCH_PATHS = (
- "$(inherited)",
- "$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../sample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
- );
- INFOPLIST_FILE = iosApp/Info.plist;
- IPHONEOS_DEPLOYMENT_TARGET = 15.3;
- LD_RUNPATH_SEARCH_PATHS = (
- "$(inherited)",
- "@executable_path/Frameworks",
- );
- PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
- PRODUCT_NAME = "${APP_NAME}";
- PROVISIONING_PROFILE_SPECIFIER = "";
- SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
- };
- name = Release;
- };
-/* End XCBuildConfiguration section */
-
-/* Begin XCConfigurationList section */
- 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = {
- isa = XCConfigurationList;
- buildConfigurations = (
- 7555FFA3242A565B00829871 /* Debug */,
- 7555FFA4242A565B00829871 /* Release */,
- );
- defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
- };
- 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
- isa = XCConfigurationList;
- buildConfigurations = (
- 7555FFA6242A565B00829871 /* Debug */,
- 7555FFA7242A565B00829871 /* Release */,
- );
- defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
- };
-/* End XCConfigurationList section */
- };
- rootObject = 7555FF73242A565900829871 /* Project object */;
-}
diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme
deleted file mode 100644
index 946f638dd..000000000
--- a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
deleted file mode 100644
index ee7e3ca03..000000000
--- a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "colors" : [
- {
- "idiom" : "universal"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
\ No newline at end of file
diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
deleted file mode 100644
index 8edf56e7a..000000000
--- a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "images" : [
- {
- "filename" : "app-icon-1024.png",
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json
deleted file mode 100644
index 4aa7c5350..000000000
--- a/iosApp/iosApp/Assets.xcassets/Contents.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
\ No newline at end of file
diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift
deleted file mode 100644
index 3653dcb62..000000000
--- a/iosApp/iosApp/ContentView.swift
+++ /dev/null
@@ -1,19 +0,0 @@
-import UIKit
-import SwiftUI
-import Demo
-
-
-struct ComposeView: UIViewControllerRepresentable {
- func makeUIViewController(context: Context) -> UIViewController {
- MainViewControllerKt.MainViewController()
- }
-
- func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
-}
-
-struct ContentView: View {
- var body: some View {
- ComposeView()
- .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
- }
-}
diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist
deleted file mode 100644
index 412e37812..000000000
--- a/iosApp/iosApp/Info.plist
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
-
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- $(PRODUCT_NAME)
- CFBundlePackageType
- $(PRODUCT_BUNDLE_PACKAGE_TYPE)
- CFBundleShortVersionString
- 1.0
- CFBundleVersion
- 1
- LSRequiresIPhoneOS
-
- CADisableMinimumFrameDurationOnPhone
-
- UIApplicationSceneManifest
-
- UIApplicationSupportsMultipleScenes
-
-
- UILaunchScreen
-
- UIRequiredDeviceCapabilities
-
- armv7
-
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
-
-
diff --git a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
deleted file mode 100644
index 4aa7c5350..000000000
--- a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
\ No newline at end of file
diff --git a/iosApp/iosApp/dylib-Info-template.plist b/iosApp/iosApp/dylib-Info-template.plist
deleted file mode 100644
index f652e272f..000000000
--- a/iosApp/iosApp/dylib-Info-template.plist
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
- CFBundleDevelopmentRegion
- en
- CFBundleExecutable
-
- CFBundleIdentifier
-
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- 1.0
- CFBundleSupportedPlatforms
-
- iPhoneOS
-
- MinimumOSVersion
- 12.0
- CFBundleVersion
- 1
-
-
diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift
deleted file mode 100644
index d83dca611..000000000
--- a/iosApp/iosApp/iOSApp.swift
+++ /dev/null
@@ -1,10 +0,0 @@
-import SwiftUI
-
-@main
-struct iOSApp: App {
- var body: some Scene {
- WindowGroup {
- ContentView()
- }
- }
-}
\ No newline at end of file
diff --git a/ksp-fixtures/klib-artifact/build.gradle.kts b/ksp-fixtures/klib-artifact/build.gradle.kts
deleted file mode 100644
index cfb76ef3a..000000000
--- a/ksp-fixtures/klib-artifact/build.gradle.kts
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * The artefact walker's klib half, end to end: a real third-party `.klib` the build resolves,
- * walked at build time by `KlibScanner`, installed into the same `UpcallTable` KSP's own fragments
- * go into.
- *
- * `:ksp-fixtures:artifact` is `docs/design/ecosystem.md` §5b's second producer over a **jar** -- this is
- * the same producer over a **klib**, ROADMAP §16e's "investigated, not implemented" half. One target
- * only, and it is `androidNativeArm64` rather than `desktop`: a jar is what a JVM target resolves, a
- * klib is what a Kotlin/Native target resolves, and `PythonArtifactBindingsTask
- * .artifactConfiguration`/`.artifactSourceSet` are single-valued (ROADMAP §16f #1) -- one module can
- * wire one walked configuration, so this fixture is the klib producer's own module rather than a
- * second target bolted onto `:artifact`.
- *
- * `kotlinx-coroutines-core` is the klib walked, for the same reason JUnit is `:artifact`'s: a real,
- * independently-versioned third-party artefact nothing here controls or cooperates with.
- *
- * ### The suite is linked by the build and run by hand
- *
- * KGP gives `androidNativeArm64` a `linkDebugTestAndroidNativeArm64` and **no run task** -- it
- * registers one only where it knows how to reach a host, and an Android device is neither the build
- * machine nor `simctl`. `:python-multiplatform` solved that for itself with a hand-written
- * `androidNativeArm64Test` task (device selection, `adb push --sync` of a 60 MB stdlib, a
- * TeamCity-to-JUnit-XML parser) that is project-local. Until that is generalised, this module's
- * suite runs like this, reusing the payload that task already stages on the device:
- *
- * ./gradlew :python-multiplatform:androidNativeArm64Test # once: stages lib/ and the stdlib
- * ./gradlew :ksp-fixtures:klib-artifact:linkDebugTestAndroidNativeArm64
- * D=/data/local/tmp/pmp-nativetest-arm64-v8a
- * adb push ksp-fixtures/klib-artifact/build/bin/androidNativeArm64/debugTest/test.kexe \
- * $D/klibtest.kexe
- * adb shell "chmod 755 $D/klibtest.kexe && cd $D && \
- * LD_LIBRARY_PATH=$D/lib PYTHONHOME=$D ./klibtest.kexe --ktest_logger=TEAMCITY"
- *
- * **A green ordinary build therefore says these tests compile and link, not that they pass.**
- */
-plugins {
- alias(libs.plugins.kotlin.multiplatform)
- id("io.github.thisisthepy.python.multiplatform.bindings")
-}
-
-kotlin {
- androidNativeArm64 {
- // Mirrors `:ksp-fixtures:app`'s own androidNativeArm64 linker config: the test binary needs
- // the same CPython symbols resolved at final link time, since `python-multiplatform`'s klib
- // only declares them via cinterop and does not itself embed a static libpython.
- val downloadDir = project(":python-multiplatform").layout.buildDirectory.dir("python-standalone").get().asFile
- val pyVersion = project.findProperty("pythonVersion")?.toString() ?: project.rootProject.version.toString()
- val libVersion = pyVersion.split('.').subList(0, 2).joinToString(".")
- val targetExtractDir = "$downloadDir/extracted/$pyVersion/android-aarch64/prefix"
- binaries.getTest(org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.DEBUG).linkerOpts.addAll(
- listOf("-L$targetExtractDir/lib/", "-lpython$libVersion", "-Wl,--allow-shlib-undefined"),
- )
- }
-
- sourceSets {
- val commonMain by getting {
- dependencies {
- implementation(projects.pythonMultiplatform)
- }
- }
- val androidNativeArm64Main by getting {
- dependencies {
- // A real, separately-published klib. Nothing about it knows it is going to be
- // walked -- KlibScanner reads its ABI the same way it would read anyone else's.
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
- }
- }
- val androidNativeArm64Test by getting {
- dependencies {
- implementation(libs.kotlin.test)
- }
- }
- }
-}
-
-pythonBindings {
- role.set("app")
- processor.set(projects.pythonMultiplatformKsp)
-
- artifactConfiguration.set("androidNativeArm64CompileKlibraries")
- artifactSourceSet.set("androidNativeArm64Main")
- // Broad on purpose, unlike `:artifact`'s narrow `junit.runner`/`junit.framework`: coroutines'
- // public surface is almost entirely `CoroutineScope`/`Job`/`Flow`-typed extensions, plus a couple
- // of `@PublishedApi internal` declarations that are binary-visible but not source-visible outside
- // the library that owns them -- none of those are bindable today, so the whole namespace yields
- // **zero** declarations. `WalkedKlibArtifactTableTest` pins that as the correct number, not a gap:
- // an earlier version bound the `@PublishedApi internal` one and failed to compile the first time
- // its output was actually built rather than only unit-tested -- `KlibScanWorkAction`'s KDoc has
- // the story. See that file's own KDoc for what the walker declines and why.
- artifactIncludePackages.set(listOf("kotlinx.coroutines"))
-}
diff --git a/python-checksums.properties b/python-checksums.properties
index c7b46d0cf..806f0bf69 100644
--- a/python-checksums.properties
+++ b/python-checksums.properties
@@ -1,23 +1,23 @@
# Python Multiplatform Checksums
android-aarch64-3.14.7=6d50cc3aa66e414a439594089bcdfb5f1264358155c70c1f00471c24cfb477fb
-android-aarch64-3.15.0rc1=7d6fb28a69368841656f919a08141b0e3720837ac67987388037ee9d2a5c5492
+android-aarch64-3.15.0=571e4cf4e0aee3d1ab54a52ca3d4e6845b85ec0bb327f7debfb873172f445dab
android-x86_64-3.14.7=2c16ce2359565cd8c24f86cfb75630768ba6607e732946b294b969797f583b60
-android-x86_64-3.15.0rc1=f048f5c60b538efc64e4c26399a0a21202ce430949d5b2c10369d624548a5dc3
+android-x86_64-3.15.0=2413d51948d612cf42baefba25295bb83c2ed84df5726af6fe5bde2dec5d87e7
ios-3.14-b11=b591f3301bd22a4f423c49c746cac9e55558b909fd14d6eb8327ccc62234ab7b
-ios-3.15.0rc1-pythonorg=178bf7bef9cd0f18b27cacb98b14332a5ba44c9427543ffb71f8809bc1f11c3c
+ios-3.15.0-pythonorg=68e98ab35cc0643ba3a227a8c7768da45c2a5e67dbe1bc26598537e96680fd19
linux-x86_64-3.14.7-20260807=a2478d654ed51d443bae21ec20ad927f116b4f5aae4094ab74918a6aa38f0575
linux-x86_64-3.14.7-20260807-freethreaded=13f80b7fc242814c0be4e2a4d8283f39c7e1c0f3f0d8f6d6a15cedb5b62eb6d3
-linux-x86_64-3.15.0rc1-20260807=aeb0d2cfe5e7028170039a95b96d74aa6830efe2fc49808122aa8f835d6e9f31
-linux-x86_64-3.15.0rc1-20260807-freethreaded=c04c940dbf8eca39c66c73d8c51330341a5a7f9b3b13f689ec14521c6357a44d
+linux-x86_64-3.15.0-20261013=69740dcd9b3480b3d53760f2d3b75582adb14fa54434628dea32480ddede542a
+linux-x86_64-3.15.0-20261013-freethreaded=afd020a480ce1767d6ac1b56e97b59c6c06f85e3a95a42f69ae146d25a07689e
macos-aarch64-3.14.7-20260807=532645be202d3df8510ab318ca5faed92060a694c8327144e14fa81f31af0f6d
macos-aarch64-3.14.7-20260807-freethreaded=618fe8d34b0704ce4a8aa2cfa6fa96063d6aa48970a131f19e1591d9785d1111
-macos-aarch64-3.15.0rc1-20260807=bfd0fa8be7d7dfb5c75f6848757feb4bb9e4274da634e97e77aa00c8e521a6a0
-macos-aarch64-3.15.0rc1-20260807-freethreaded=7dc7e3758b6a77358b24d795e0a7eee3b5f3d9bc5fa0e3e3c65db52898f07815
+macos-aarch64-3.15.0-20261013=b4ab2009803cef142e4ebf2ebef225f6875f83cc35caf4eefb437aa9b2b76783
+macos-aarch64-3.15.0-20261013-freethreaded=94e34d776cff596017d15fce1b2175d463628a765ddb37412a39aaba6d31bf67
macos-x86_64-3.14.7-20260807=9964e2d618ebea03be8ea3e65ab0ecc0f2b030ce203345b8f92654641fd4de66
macos-x86_64-3.14.7-20260807-freethreaded=746207f0d2644c5c98619f9d0ed37824d9871ea8d27640edc779a46d85038b3b
-macos-x86_64-3.15.0rc1-20260807=f36c0921d03468747ed74c434e4b04749a35593718cd641d0f08fa01e9c7885e
-macos-x86_64-3.15.0rc1-20260807-freethreaded=69beed4659c78b3683f1ea56c12956c7234f16e0417edf24f7f69c5a9f7b09ec
+macos-x86_64-3.15.0-20261013=4760e8fb2821441c0dbc72be49a25400c6ae90be4daa7e2a49617ed09f6a2919
+macos-x86_64-3.15.0-20261013-freethreaded=c552d4f10738f35de58be64716ec5104f55337d71e962e2beb09164e98cff1ec
windows-x86_64-3.14.7-20260807=840e368318d1630bd8efbd3f7de8fab32cb4bbd735baef733f9034f2676c6a92
windows-x86_64-3.14.7-20260807-freethreaded=c8323293c9e76e8b244a0e533894512f067dac2a6aa2dfcb3494cbe74b3af58e
-windows-x86_64-3.15.0rc1-20260807=bc0d0f3c7b88873688f30bc78da8387a6653a5493424951d3a84c658c973198e
-windows-x86_64-3.15.0rc1-20260807-freethreaded=e5998e014f16074ac9f6d61eb2a31ff8ac8bc331b61da2c5acf7d15abf4a2650
+windows-x86_64-3.15.0-20261013=743d2269e74a98f7c4281dbc2e8710c171be1965693720aac818ff0157e4d965
+windows-x86_64-3.15.0-20261013-freethreaded=aadcd190365f1154854478da313219691ab14cc35497daec66a2149f3571ae36
diff --git a/python-multiplatform-compose/build.gradle.kts b/python-multiplatform-compose/build.gradle.kts
index bee7b9502..011f95798 100644
--- a/python-multiplatform-compose/build.gradle.kts
+++ b/python-multiplatform-compose/build.gradle.kts
@@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
/**
* The Compose host for python-multiplatform: the `@Composable` entry point that draws a Python-declared
- * root (`PythonContent`) and the composer/callable plumbing it shares with every Python call into a
+ * root (`PythonAppView`, `PythonWidget`, `PythonLauncher`) and the composer/callable plumbing it shares with every Python call into a
* composable.
*
* A separate module so that `:python-multiplatform` itself never depends on Compose (same naming as
@@ -35,6 +35,8 @@ kotlin {
commonMain.dependencies {
api(projects.pythonMultiplatform)
api(compose.runtime)
+ api(compose.ui)
+ api(compose.runtimeSaveable)
}
}
}
diff --git a/ksp-fixtures/compose/build.gradle.kts b/python-multiplatform-compose/fixtures/compose/build.gradle.kts
similarity index 89%
rename from ksp-fixtures/compose/build.gradle.kts
rename to python-multiplatform-compose/fixtures/compose/build.gradle.kts
index 9a111a31f..b953f711e 100644
--- a/ksp-fixtures/compose/build.gradle.kts
+++ b/python-multiplatform-compose/fixtures/compose/build.gradle.kts
@@ -5,7 +5,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
* The other end of `docs/archive/pythonx-adapter-design.md` §5: a real `@Composable`, called from Python,
* inside a real composition, drawing a real pixel.
*
- * ### Why this is not `:ksp-fixtures:artifact`
+ * ### Why this is not `:python-multiplatform-gradle-plugin-fixtures:artifact`
*
* That module takes Compose as **plain Maven coordinates with no Compose plugin**, and its own
* `build.gradle.kts` says why: what makes `Modifier.padding(16.dp)` reachable is the walker's gates
@@ -69,7 +69,7 @@ pythonBindings {
artifactConfiguration.set("desktopCompileClasspath")
artifactSourceSet.set("desktopMain")
- // Narrow on purpose, and narrower than `:ksp-fixtures:artifact`'s: binding all of Compose would
+ // Narrow on purpose, and narrower than `:python-multiplatform-gradle-plugin-fixtures:artifact`'s: binding all of Compose would
// make this fixture's compile time the cost of a proof it does not need. Each entry earns its
// place.
//
@@ -100,7 +100,7 @@ pythonBindings {
// 6. Compose state, from Python (issue #38): `mutableStateOf` (a generic function, its `T`
// read as `kotlin.Any?`) and the `value` property of `State`/`MutableState`. Classes, not
// the package: `androidx.compose.runtime` is the whole runtime, and this is the one
- // facade and two interfaces `PythonContentRenderTest` writes its root through.
+ // facade and two interfaces `PythonAppViewRenderTest` writes its root through.
"androidx.compose.runtime.SnapshotStateKt",
"androidx.compose.runtime.State",
"androidx.compose.runtime.MutableState",
@@ -111,16 +111,20 @@ pythonBindings {
// `TextFieldStateRenderTest` is what each one is for.
"androidx.compose.foundation.text.input.TextFieldState",
"androidx.compose.foundation.text.input.TextFieldStateKt",
+ // 8. `rememberSaveableWrapper` (issue #174): the one function of `python-multiplatform-compose`
+ // Python calls inside a composition. Its file facade, not the package, so that the host
+ // composables (`PythonAppView` and friends) stay out of the table.
+ "python.multiplatform.compose.RememberSaveableKt",
),
)
- // Stubs on: `generatePythonStubs` over the real Compose jars is what `tools/stubs/check-stubs.sh` (the mypy
+ // Stubs on: `generatePythonStubs` over the real Compose jars is what `.github/scripts/stubs/check-stubs.sh` (the mypy
// check) and `.github/workflows/stubs.yml` (the published artifact) both run. Nothing depends on the
// task, so the render tests do not pay for it.
generateStubs.set(true)
}
/**
- * The same JVM `:ksp-fixtures:artifact`'s tests need: JDK 21 with the preview FFM API, and a
+ * The same JVM `:python-multiplatform-gradle-plugin-fixtures:artifact`'s tests need: JDK 21 with the preview FFM API, and a
* `java.library.path` for the `libpython` that `manager.loadLibPython` extracts next to the working
* directory.
*/
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/ModifierComparisons.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/ModifierComparisons.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/ModifierComparisons.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/ModifierComparisons.kt
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonAnchoredDraggable.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonAnchoredDraggable.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonAnchoredDraggable.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonAnchoredDraggable.kt
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt
similarity index 96%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt
index 28ce7a08e..564ad6fa6 100644
--- a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt
@@ -16,9 +16,9 @@ import python.multiplatform.ffi.pythonx.PythonCallables
*
* The composer plumbing and the callable holder are no longer this fixture's: they are
* `python-multiplatform-compose`'s `withPythonComposer` and `PythonCallableArena`, which the host
- * entry point `PythonContent` is built on too. What stays is executing a *source string* per pass,
+ * entry point `PythonAppView` is built on too. What stays is executing a *source string* per pass,
* which is a test harness rather than an entry point -- a host draws a Python-declared root with
- * `PythonContent` (`PythonContentRenderTest`).
+ * `PythonAppView` (`PythonAppViewRenderTest`).
*
* ### Why exactly one, and not one per component
*
@@ -130,7 +130,7 @@ class CountingCallableArena : PythonCallableArena() {
* nor `pythonx` can supply on their own: a `Modifier` chain has to start somewhere, and the thing it
* starts from is `Modifier` the *expression* -- `androidx.compose.ui.Modifier.Companion`, an object
* instance the walker cannot hand out because it only binds functions.
- * `ksp-fixtures/artifact`'s `ComposeSeed.kt` solved the exact same gap the same way, for the
+ * `python-multiplatform-gradle-plugin/fixtures/artifact`'s `ComposeSeed.kt` solved the exact same gap the same way, for the
* non-composable half of Compose's surface; this is that solution, in this module, so `Spacer` --
* whose one parameter has no default and is therefore not omittable -- has something to be given.
*
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonContextMenuOpenDetector.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonContextMenuOpenDetector.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonContextMenuOpenDetector.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonContextMenuOpenDetector.kt
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDragAndDropSource.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDragAndDropSource.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDragAndDropSource.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDragAndDropSource.kt
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDraggable.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDraggable.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDraggable.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDraggable.kt
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonLayoutId.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonLayoutId.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonLayoutId.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonLayoutId.kt
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonModifierLocal.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonModifierLocal.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonModifierLocal.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonModifierLocal.kt
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPointerInput.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPointerInput.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPointerInput.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPointerInput.kt
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPullRefresh.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPullRefresh.kt
similarity index 91%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPullRefresh.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPullRefresh.kt
index d7f042280..642446801 100644
--- a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPullRefresh.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPullRefresh.kt
@@ -16,7 +16,7 @@ import python.multiplatform.ffi.PyObject
import python.multiplatform.ffi.types.basic.PyFloat
/**
- * Test harness for `pullRefresh`'s callback overload —
+ * Test harness for `pullRefresh`'s callback overload,
* `androidx.compose.material.pullrefresh.PullRefreshKt.pullRefresh(Modifier,
* onPull: (Float) -> Float, onRelease: suspend (Float) -> Float, enabled: Boolean)`.
*
@@ -33,7 +33,7 @@ import python.multiplatform.ffi.types.basic.PyFloat
*
* `onRelease: suspend (Float) -> Float` is a suspend slot with a return value. The same shape
* applies: Kotlin suspends, returns `0f`, and fires a one-way notification to Python. Python never
- * suspends — the Kotlin lambda body is the only thing that suspends, exactly the way
+ * suspends, the Kotlin lambda body is the only thing that suspends, exactly the way
* `pythonPointerInput`/`pythonDraggable` handle their own suspend slots.
*
* ### Why the scene is built here, not from a Python BODY string
@@ -42,12 +42,12 @@ import python.multiplatform.ffi.types.basic.PyFloat
* neither of which is in `artifactIncludePackages`. Building the nested-scroll tree in this
* `@Composable` avoids walking those packages, keeping the scope of what the walker covers intact.
*
- * ### Infinite-animation trap — deliberately avoided
+ * ### Infinite-animation trap, deliberately avoided
*
* §10 of the design doc confirmed that a component that animates forever (`CircularProgressIndicator`
* with no deterministic end state) prevents `ImageComposeScene.render()` from returning.
* `PullRefreshIndicator` contains such an animation. **This harness does not use
- * `PullRefreshIndicator`** — `pullRefresh` (the Modifier) and the indicator are separate APIs,
+ * `PullRefreshIndicator`**, `pullRefresh` (the Modifier) and the indicator are separate APIs,
* and the callback-reaching proof needs only the former.
*
* ### Lifetime
@@ -66,7 +66,7 @@ fun PythonPullRefreshHarness(onPull: PyObject, onRelease: PyObject, modifier: Mo
.background(Color.White)
.pullRefresh(
onPull = { pullAvailable: Float ->
- // Notify Python — one-way, return value stays in Kotlin.
+ // Notify Python, one-way, return value stays in Kotlin.
// pullAvailable > 0 when dragging downward at the top of the scrollable child.
val arg = PyFloat.from(pullAvailable.toDouble())
try {
@@ -77,7 +77,7 @@ fun PythonPullRefreshHarness(onPull: PyObject, onRelease: PyObject, modifier: Mo
pullAvailable // consume everything offered
},
onRelease = { velocity: Float ->
- // suspend slot — Kotlin suspends here; Python is a synchronous notification.
+ // suspend slot, Kotlin suspends here; Python is a synchronous notification.
val arg = PyFloat.from(velocity.toDouble())
try {
onRelease(arg).close()
diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonSwipeable.kt b/python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonSwipeable.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonSwipeable.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonSwipeable.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnchoredDraggableRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnchoredDraggableRenderTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnchoredDraggableRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnchoredDraggableRenderTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt
similarity index 97%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt
index 80b873ad3..9071ee233 100644
--- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt
@@ -4,7 +4,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.unit.Density
-import python.multiplatform.compose.PythonContent
+import python.multiplatform.compose.PythonWidget
import python.multiplatform.ffi.Python3
import python.multiplatform.ffi.pythonx.PythonxAdapter
import python.multiplatform.ffi.upcall.PythonProxySource
@@ -25,7 +25,7 @@ import kotlin.test.assertTrue
* `Int`, and incrementing it from Python (`counter.value = counter.value + 1`, the walked
* `MutableState.value` getter and setter) recomposes the root that read it.
*
- * Modelled on [PythonContentRenderTest]: `ImageComposeScene` has no frame clock, so the test is the
+ * Modelled on [PythonAppViewRenderTest]: `ImageComposeScene` has no frame clock, so the test is the
* clock (`Snapshot.sendApplyNotifications()` and a render), and the root counts its own invocations so
* a frame can be attributed.
*
@@ -81,7 +81,7 @@ class AnySlotScalarRenderTest {
val root = Python3.import("__main__").getAttr("_asr_root")
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
- PythonContent(root = root)
+ PythonWidget(root = root)
}
try {
scene.render()
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt
similarity index 98%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt
index efd432946..e862498b1 100644
--- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt
@@ -108,7 +108,7 @@ class CallbackDrivenRenderTest {
val checked = pixelsOf(CHECKBOX)
val moved = differing(unchecked, checked)
println(
- "compose input: Checkbox click -> onCheckedChange(True), " +
+ "compose input: Checkbox click -> on_checked_change(True), " +
"ink ${inkOf(unchecked)} -> ${inkOf(checked)} px, $moved pixels changed",
)
assertTrue(
@@ -162,7 +162,7 @@ class CallbackDrivenRenderTest {
val after = pixelsOf(SWITCH)
val moved = differing(before, after)
println(
- "compose input: Switch click -> onCheckedChange(True), " +
+ "compose input: Switch click -> on_checked_change(True), " +
"ink ${inkOf(before)} -> ${inkOf(after)} px, $moved pixels changed",
)
// Deliberately not an ink comparison: the thumb slides from one end of the track to the
@@ -272,7 +272,7 @@ class CallbackDrivenRenderTest {
val typed = pixelsOf(TEXT_FIELD, width = FIELD_WIDTH, height = FIELD_HEIGHT)
val moved = differing(empty, typed)
println(
- "compose input: TextField keystroke 'h' -> onValueChange, " +
+ "compose input: TextField keystroke 'h' -> on_value_change, " +
"ink ${inkOf(empty)} -> ${inkOf(typed)} px, $moved pixels changed",
)
assertTrue(
@@ -445,12 +445,12 @@ class CallbackDrivenRenderTest {
* show what the callback wrote. */
val CHECKBOX = """
from androidx.compose.material3 import Checkbox
- Checkbox(_cb_state[0], onCheckedChange=_cb_toggle)
+ Checkbox(_cb_state[0], on_checked_change=_cb_toggle)
""".trimIndent()
val SWITCH = """
from androidx.compose.material3 import Switch
- Switch(_cb_state[0], onCheckedChange=_cb_toggle)
+ Switch(_cb_state[0], on_checked_change=_cb_toggle)
""".trimIndent()
/** M3's `TextField` has no minimum-size default anywhere near `Checkbox`'s 48dp -- its own
@@ -466,7 +466,7 @@ class CallbackDrivenRenderTest {
* what makes a fresh scene show what the callback wrote. */
val TEXT_FIELD = """
from androidx.compose.material3 import TextField
- TextField(_tf_state[0], onValueChange=_tf_on_change)
+ TextField(_tf_state[0], on_value_change=_tf_on_change)
""".trimIndent()
/** `java.awt.event.KeyEvent`'s constructor requires a non-null source `Component`; nothing
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ColorFactoryTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ColorFactoryTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ColorFactoryTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ColorFactoryTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt
similarity index 98%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt
index 2650f6007..ae014ac90 100644
--- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt
@@ -495,13 +495,13 @@ class ComposableRenderTest {
val drawn = inkOf(
"""
from androidx.compose.material3 import Button, Text
- Button(onClick=lambda: None, content=lambda: Text('hi'))
+ Button(on_click=lambda: None, content=lambda: Text('hi'))
""".trimIndent(),
)
val empty = inkOf(
"""
from androidx.compose.material3 import Button
- Button(onClick=lambda: None, content=lambda: None)
+ Button(on_click=lambda: None, content=lambda: None)
""".trimIndent(),
)
println("compose render: Button(content=Text('hi')) -> $drawn px, empty content -> $empty px")
@@ -565,11 +565,11 @@ class ComposableRenderTest {
/**
* **`ListItem`.** `headlineContent` is the one parameter with no default (also `Function2`,
- * arity zero) and it is written under its **Kotlin** parameter name, `headlineContent` -- so
- * this also pins that a keyword argument reaches a real multi-word parameter by its Kotlin name,
- * not just the single-word ones (`content`, `modifier`) every other test here happens to use.
- * (It used to be `headline_content`: the binder snake_cased parameters, which it no longer does;
- * a Pythonic spelling is pythonx-compose's to define.)
+ * arity zero) and it is written under its **snake_cased** keyword, `headline_content` -- so
+ * this also pins that a keyword argument reaches a real multi-word parameter by its Pythonic name
+ * (issue #131), not just the single-word ones (`content`, `modifier`) every other test here
+ * happens to use. The Kotlin spelling `headlineContent=` reaches the same parameter; the empty
+ * control below is written that way, so both spellings render here.
*
* The empty control is not zero, unlike `Column`'s or `Row`'s: `ListItem`, like `Button`, always
* paints its own container surface (`colors.containerColor` at `tonalElevation`) whether or not
@@ -578,11 +578,11 @@ class ComposableRenderTest {
* `Column`'s does.
*/
@Test
- fun listItemComposesItsHeadlineContentUnderItsKotlinName() {
+ fun listItemComposesItsHeadlineContentUnderItsSnakeCasedName() {
val drawnPixels = pixelsOf(
"""
from androidx.compose.material3 import ListItem, Text
- ListItem(headlineContent=lambda: Text('hi'))
+ ListItem(headline_content=lambda: Text('hi'))
""".trimIndent(),
)
val emptyPixels = pixelsOf(
@@ -600,7 +600,7 @@ class ComposableRenderTest {
val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet()
val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet()
println(
- "compose render: ListItem(headlineContent=Text('hi')) -> ${drawnColors.size} distinct colors, " +
+ "compose render: ListItem(headline_content=Text('hi')) -> ${drawnColors.size} distinct colors, " +
"empty -> ${emptyColors.size} distinct colors",
)
assertTrue(emptyColors.isNotEmpty(), "a ListItem with no headline must still draw its own container surface")
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposedRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposedRenderTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposedRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposedRenderTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ContextMenuOpenDetectorRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ContextMenuOpenDetectorRenderTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ContextMenuOpenDetectorRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ContextMenuOpenDetectorRenderTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DragAndDropSourceRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/DragAndDropSourceRenderTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DragAndDropSourceRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/DragAndDropSourceRenderTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableLeakTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableLeakTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableLeakTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableLeakTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableRenderTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableRenderTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt
similarity index 99%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt
index ea984fb04..ec5c8156c 100644
--- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt
@@ -112,13 +112,13 @@ class IconRenderTest {
fun pythonLoadsAPainterAndIconDrawsIt() {
val drawn = inkOf(
"""
- from androidx.compose.ui.res import painterResource
+ from androidx.compose.ui.res import painter_resource
from androidx.compose.material3 import Icon
- Icon(painterResource('$RESOURCE'), contentDescription='a square')
+ Icon(painter_resource('$RESOURCE'), content_description='a square')
""".trimIndent(),
)
val blank = inkOf("pass")
- println("compose render: Icon(painterResource('$RESOURCE')) -> $drawn px, empty body -> $blank px")
+ println("compose render: Icon(painter_resource('$RESOURCE')) -> $drawn px, empty body -> $blank px")
assertEquals(0, blank, "an empty composition must draw nothing, or the measurement is not measuring")
assertEquals(SQUARE, drawn, "Icon did not draw the 24x24 square the resource holds")
}
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt
similarity index 89%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt
index ee2b4f920..589f85712 100644
--- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt
@@ -17,7 +17,7 @@ import kotlin.test.assertTrue
*
* pythonx-compose re-exposes Kotlin-named modules Pythonically by **one rule** applied to every
* module it lists, with no per-widget wrapper. That rule needs, for each function: its Kotlin
- * parameter names, which have defaults, the overload set behind a base name, which parameters are
+ * parameter names (and, since issue #131, the Pythonic keyword the binder serves for each), which have defaults, the overload set behind a base name, which parameters are
* value classes, and which one is the trailing `@Composable` content lambda. This pins that the
* binder publishes exactly that -- through `inspect.signature` and `python_multiplatform.describe`
* (`KotlinSurface`'s KDoc is the contract) -- for a real composable (`Checkbox`) and a real
@@ -39,7 +39,9 @@ class KotlinSignatureMetadataTest {
}
/**
- * `Checkbox(checked, onCheckedChange, modifier = Modifier, ...)`, as Kotlin declares it. Compose 1.11
+ * `Checkbox(checked, onCheckedChange, modifier = Modifier, ...)`, as Kotlin declares it -- and as
+ * `inspect.signature` shows it to Python since issue #131: `on_checked_change`, the keyword a
+ * Python caller writes, while `describe` keeps the Kotlin name beside it. Compose 1.11
* has two `Checkbox` overloads (the second adds `checkmarkStroke`/`outlineStroke`), so the base name
* answers `(*args, **kwargs)` and the signature lives on the specific overload callable.
*/
@@ -75,13 +77,14 @@ class KotlinSignatureMetadataTest {
'name': _ksm_d[0]['name'],
'composable': _ksm_d[0]['composable'],
'callback': _ksm_d[0]['parameters'][1]['type'].startswith('kotlin.Function1'),
- 'described': [_q['name'] for _q in _ksm_d[0]['parameters']] ==
+ 'described': [_q['python_name'] for _q in _ksm_d[0]['parameters']] ==
[_p.name for _p in _ksm_params],
+ 'kotlin_names': [_q['name'] for _q in _ksm_d[0]['parameters']][:2],
}
""".trimIndent(),
)
assertEquals(
- "[('checked', False, 'kotlin.Boolean'), ('onCheckedChange', False, " +
+ "[('checked', False, 'kotlin.Boolean'), ('on_checked_change', False, " +
"${eval("repr(_ksm_d[0]['parameters'][1]['type'])")}), " +
"('modifier', True, 'androidx.compose.ui.Modifier')]",
eval("repr(_ksm['first'])"),
@@ -103,7 +106,8 @@ class KotlinSignatureMetadataTest {
)
assertEquals("True", eval("_ksm['composable']"), order)
assertEquals("True", eval("_ksm['callback']"), order)
- assertEquals("True", eval("_ksm['described']"), "$order: describe and inspect.signature must agree")
+ assertEquals("True", eval("_ksm['described']"), "$order: describe's python_name and inspect.signature must agree")
+ assertEquals("['checked', 'onCheckedChange']", eval("repr(_ksm['kotlin_names'])"), "$order: describe keeps the Kotlin names")
}
}
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/LayoutIdRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/LayoutIdRenderTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/LayoutIdRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/LayoutIdRenderTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt
similarity index 93%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt
index 45967c713..5ee546467 100644
--- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt
@@ -24,21 +24,21 @@ import kotlin.test.assertTrue
* - A scene whose Python body calls the component has ink (non-background pixels > 0).
* - A scene whose Python body is `pass` or an empty equivalent has none (or fewer).
* - Where the component fills the entire scene with its own surface (FAB, TopAppBar, Scaffold,
- * TabRow), distinct-colour counting is used instead of ink counting — the same approach
- * [ComposableRenderTest.listItemComposesItsHeadlineContentUnderItsKotlinName] uses for ListItem.
+ * TabRow), distinct-colour counting is used instead of ink counting, the same approach
+ * [ComposableRenderTest.listItemComposesItsHeadlineContentUnderItsSnakeCasedName] uses for ListItem.
*
* Components judged here (10 of 27):
- * 1. HorizontalDivider — leaf, no required param
- * 2. RadioButton — selected: Boolean, onClick: Function0
- * 3. LinearProgressIndicator — progress: () -> Float (determinate; the indeterminate overload
+ * 1. HorizontalDivider, leaf, no required param
+ * 2. RadioButton, selected: Boolean, onClick: Function0
+ * 3. LinearProgressIndicator, progress: () -> Float (determinate; the indeterminate overload
* animates forever and hangs the scene, see that test)
- * 4. CircularProgressIndicator — progress: () -> Float, same reason
- * 5. Surface — content: @Composable () -> Unit
- * 6. Scaffold — content: @Composable (PaddingValues) -> Unit (fills scene — colour-set comparison)
- * 7. Slider — value: Float, onValueChange: (Float) -> Unit (pixel-set comparison)
- * 8. TopAppBar — title: @Composable () -> Unit (fills scene — colour-set comparison)
- * 9. FloatingActionButton — onClick: Function0, content: @Composable () -> Unit (colour-set)
- * 10. Tab + TabRow — selected/onClick/text inside TabRow (colour-set)
+ * 4. CircularProgressIndicator, progress: () -> Float, same reason
+ * 5. Surface, content: @Composable () -> Unit
+ * 6. Scaffold, content: @Composable (PaddingValues) -> Unit (fills scene, colour-set comparison)
+ * 7. Slider, value: Float, onValueChange: (Float) -> Unit (pixel-set comparison)
+ * 8. TopAppBar, title: @Composable () -> Unit (fills scene, colour-set comparison)
+ * 9. FloatingActionButton, onClick: Function0, content: @Composable () -> Unit (colour-set)
+ * 10. Tab + TabRow, selected/onClick/text inside TabRow (colour-set)
*/
class M3ProofRenderTest {
@@ -59,7 +59,7 @@ class M3ProofRenderTest {
// ── 1. HorizontalDivider ──────────────────────────────────────────────────
/**
- * HorizontalDivider — leaf, every parameter defaulted. Must draw a visible line on a 200x10
+ * HorizontalDivider, leaf, every parameter defaulted. Must draw a visible line on a 200x10
* scene; pass must draw nothing.
*/
@Test
@@ -80,7 +80,7 @@ class M3ProofRenderTest {
// ── 2. RadioButton ───────────────────────────────────────────────────────
/**
- * RadioButton — selected=True paints a filled ring, selected=False paints only a ring.
+ * RadioButton, selected=True paints a filled ring, selected=False paints only a ring.
* Both must draw; and the filled variant (selected) must have more ink than the hollow one.
*/
@Test
@@ -88,13 +88,13 @@ class M3ProofRenderTest {
val selected = inkOf(
"""
from androidx.compose.material3 import RadioButton
- RadioButton(selected=True, onClick=lambda: None)
+ RadioButton(selected=True, on_click=lambda: None)
""".trimIndent(),
)
val deselected = inkOf(
"""
from androidx.compose.material3 import RadioButton
- RadioButton(selected=False, onClick=lambda: None)
+ RadioButton(selected=False, on_click=lambda: None)
""".trimIndent(),
)
println("compose render: RadioButton selected=$selected px, deselected=$deselected px")
@@ -197,7 +197,7 @@ class M3ProofRenderTest {
// ── 5. Surface ───────────────────────────────────────────────────────────
/**
- * Surface — content: @Composable () -> Unit (Function2, arity 0, same shape as MaterialTheme).
+ * Surface, content: @Composable () -> Unit (Function2, arity 0, same shape as MaterialTheme).
* Surface's own container background does not fill the full 200x60 scene at its default size,
* so ink-counting works: with Text inside vs empty content gives different non-zero ink.
*/
@@ -223,7 +223,7 @@ class M3ProofRenderTest {
// ── 6. Scaffold ──────────────────────────────────────────────────────────
/**
- * Scaffold — content: @Composable (PaddingValues) -> Unit. Scaffold fills the scene with its
+ * Scaffold, content: @Composable (PaddingValues) -> Unit. Scaffold fills the scene with its
* own surface, so ink-counting cannot distinguish content from no content (both max out at
* scene pixels). Distinct-colour counting (same approach as ListItem) is the right tool: the
* glyph colour is not the container fill, so Text inside adds a new colour to the set.
@@ -260,9 +260,9 @@ class M3ProofRenderTest {
// ── 7. Slider ────────────────────────────────────────────────────────────
/**
- * Slider — value: Float, onValueChange: (Float) -> Unit, both required.
+ * Slider, value: Float, onValueChange: (Float) -> Unit, both required.
* Slider(0.0) places the thumb at the left end; Slider(1.0) at the right. The thumb is the
- * only element that moves, so the two pixel sets must differ — same argument
+ * only element that moves, so the two pixel sets must differ, same argument
* [ComposableRenderTest.theStringPythonWroteIsTheStringComposeDrew] uses for Text length.
*/
@Test
@@ -270,14 +270,14 @@ class M3ProofRenderTest {
val left = pixelsOf(
"""
from androidx.compose.material3 import Slider
- Slider(0.0, onValueChange=lambda v: None)
+ Slider(0.0, on_value_change=lambda v: None)
""".trimIndent(),
width = 200, height = 48,
)
val right = pixelsOf(
"""
from androidx.compose.material3 import Slider
- Slider(1.0, onValueChange=lambda v: None)
+ Slider(1.0, on_value_change=lambda v: None)
""".trimIndent(),
width = 200, height = 48,
)
@@ -286,14 +286,14 @@ class M3ProofRenderTest {
assertTrue(ink > 0, "Slider drew nothing at value=0.0")
assertTrue(
!left.contentEquals(right),
- "Slider(0.0) and Slider(1.0) produced identical pixels — the value argument was not read",
+ "Slider(0.0) and Slider(1.0) produced identical pixels, the value argument was not read",
)
}
// ── 8. TopAppBar ─────────────────────────────────────────────────────────
/**
- * TopAppBar — title: @Composable () -> Unit (Function2, arity 0), required.
+ * TopAppBar, title: @Composable () -> Unit (Function2, arity 0), required.
* TopAppBar fills the full scene with its own container, so ink counting gives the same
* result with or without a title. Distinct-colour counting finds the glyph colour.
*/
@@ -329,7 +329,7 @@ class M3ProofRenderTest {
// ── 9. FloatingActionButton ───────────────────────────────────────────────
/**
- * FloatingActionButton — onClick: Function0, content: @Composable () -> Unit, both required.
+ * FloatingActionButton, onClick: Function0, content: @Composable () -> Unit, both required.
* The FAB always draws its own container surface, which in a 200x60 scene fills nearly the
* full height. Distinct-colour counting finds the glyph colour added by Text inside.
*/
@@ -338,13 +338,13 @@ class M3ProofRenderTest {
val drawnPixels = pixelsOf(
"""
from androidx.compose.material3 import FloatingActionButton, Text
- FloatingActionButton(onClick=lambda: None, content=lambda: Text('hi'))
+ FloatingActionButton(on_click=lambda: None, content=lambda: Text('hi'))
""".trimIndent(),
)
val emptyPixels = pixelsOf(
"""
from androidx.compose.material3 import FloatingActionButton
- FloatingActionButton(onClick=lambda: None, content=lambda: None)
+ FloatingActionButton(on_click=lambda: None, content=lambda: None)
""".trimIndent(),
)
val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet()
@@ -363,8 +363,8 @@ class M3ProofRenderTest {
// ── 10. Tab + TabRow ──────────────────────────────────────────────────────
/**
- * TabRow — selectedTabIndex: Int, tabs: @Composable () -> Unit.
- * Tab — selected: Boolean, onClick: Function0, text: @Composable () -> Unit.
+ * TabRow, selectedTabIndex: Int, tabs: @Composable () -> Unit.
+ * Tab, selected: Boolean, onClick: Function0, text: @Composable () -> Unit.
*
* TabRow fills the scene with its indicator and background, so distinct-colour counting is
* again the right tool. Tab's text slot carries the glyph colour.
@@ -375,7 +375,7 @@ class M3ProofRenderTest {
"""
from androidx.compose.material3 import TabRow, Tab, Text
TabRow(
- selectedTabIndex=0,
+ selected_tab_index=0,
tabs=lambda: Tab(
selected=True,
onClick=lambda: None,
@@ -389,7 +389,7 @@ class M3ProofRenderTest {
"""
from androidx.compose.material3 import TabRow, Tab
TabRow(
- selectedTabIndex=0,
+ selected_tab_index=0,
tabs=lambda: Tab(
selected=True,
onClick=lambda: None,
@@ -419,13 +419,13 @@ class M3ProofRenderTest {
val checked = inkOf(
"""
from androidx.compose.material3 import Checkbox
- Checkbox(checked=True, onCheckedChange=lambda v: None)
+ Checkbox(checked=True, on_checked_change=lambda v: None)
""".trimIndent(),
)
val unchecked = inkOf(
"""
from androidx.compose.material3 import Checkbox
- Checkbox(checked=False, onCheckedChange=lambda v: None)
+ Checkbox(checked=False, on_checked_change=lambda v: None)
""".trimIndent(),
)
println("compose render: Checkbox checked=$checked px, unchecked=$unchecked px")
@@ -450,13 +450,13 @@ class M3ProofRenderTest {
val checked = pixelsOf(
"""
from androidx.compose.material3 import Switch
- Switch(checked=True, onCheckedChange=lambda v: None)
+ Switch(checked=True, on_checked_change=lambda v: None)
""".trimIndent(),
)
val unchecked = pixelsOf(
"""
from androidx.compose.material3 import Switch
- Switch(checked=False, onCheckedChange=lambda v: None)
+ Switch(checked=False, on_checked_change=lambda v: None)
""".trimIndent(),
)
val checkedInk = checked.count { it != BACKGROUND }
@@ -568,13 +568,13 @@ class M3ProofRenderTest {
val drawnPixels = pixelsOf(
"""
from androidx.compose.material3 import ExtendedFloatingActionButton, Text
- ExtendedFloatingActionButton(onClick=lambda: None, text=lambda: Text('hi'), icon=lambda: None)
+ ExtendedFloatingActionButton(on_click=lambda: None, text=lambda: Text('hi'), icon=lambda: None)
""".trimIndent(),
)
val emptyPixels = pixelsOf(
"""
from androidx.compose.material3 import ExtendedFloatingActionButton
- ExtendedFloatingActionButton(onClick=lambda: None, text=lambda: None, icon=lambda: None)
+ ExtendedFloatingActionButton(on_click=lambda: None, text=lambda: None, icon=lambda: None)
""".trimIndent(),
)
val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet()
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt
similarity index 51%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt
index d02a3b03f..983d935d0 100644
--- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt
@@ -36,6 +36,12 @@ class MemberResolverComposeTest {
UpcallTable.clear()
}
+ /**
+ * A name only a resolver knows (`full_width`, which is not the snake_case of any Kotlin name) is an
+ * `AttributeError` until a resolver answers it, runs Compose's real `fillMaxWidth` while it is
+ * registered, and is an `AttributeError` again once it is removed. The binder's own alias
+ * (`fill_max_width`) needs no resolver (issue #131) and is covered by the next test.
+ */
@Test
fun aResolvedSnakeNameRunsComposesFillMaxWidthInARealChain() {
Python3.exec(
@@ -47,32 +53,53 @@ class MemberResolverComposeTest {
_start = padding__Dp(emptyModifier(), 16.0)
try:
- _start.fill_max_width
- raise AssertionError('fill_max_width resolved with no resolver registered')
+ _start.full_width
+ raise AssertionError('full_width resolved with no resolver registered')
except AttributeError:
pass
- def _snake_to_camel(type_name, requested, kotlin_names):
- head, *rest = requested.split('_')
- camel = head + ''.join(p.capitalize() for p in rest)
- return camel if camel in kotlin_names else None
+ def _full_width(type_name, requested, kotlin_names):
+ return 'fillMaxWidth' if requested == 'full_width' and 'fillMaxWidth' in kotlin_names else None
- _b.add_member_resolver(_snake_to_camel)
+ _b.add_member_resolver(_full_width)
try:
- _chain = _start.fill_max_width()
+ _chain = _start.full_width()
assert equalsPaddingThenFillMaxWidth(_chain._pm_handle, 16.0), 'chain is not padding(16.dp).fillMaxWidth()'
assert not equalsPaddingThenFillMaxWidth(_chain._pm_handle, 17.0), '16dp matched 17dp'
assert not equalsFillMaxWidthThenPadding(_chain._pm_handle, 16.0), 'the chain order is not observed'
- assert 'fill_max_width' not in dir(_chain), 'the alias leaked into dir()'
- assert 'fill_max_width' not in vars(type(_chain)), 'the alias was written onto the proxy class'
+ assert 'full_width' not in dir(_chain), 'the alias leaked into dir()'
+ assert 'full_width' not in vars(type(_chain)), 'the alias was written onto the proxy class'
finally:
- _b.remove_member_resolver(_snake_to_camel)
+ _b.remove_member_resolver(_full_width)
try:
- _start.fill_max_width
+ _start.full_width
raise AssertionError('the alias survived removing the resolver')
except AttributeError:
pass
""".trimIndent(),
)
}
+
+ /**
+ * Issue #131 (SPEC U-12): with no resolver registered the binder serves `fill_max_width` itself, as
+ * the same declaration as `fillMaxWidth`; the alias is never listed and never written onto the class.
+ */
+ @Test
+ fun theBindersOwnSnakeAliasRunsComposesFillMaxWidthWithNoResolver() {
+ Python3.exec(
+ """
+ from androidx.compose.foundation.layout import padding__Dp
+ from fixture.compose import emptyModifier
+ from fixture.compose import equalsPaddingThenFillMaxWidth, equalsFillMaxWidthThenPadding
+
+ _start = padding__Dp(emptyModifier(), 16.0)
+ _chain = _start.fill_max_width()
+ assert equalsPaddingThenFillMaxWidth(_chain._pm_handle, 16.0), 'chain is not padding(16.dp).fillMaxWidth()'
+ assert not equalsPaddingThenFillMaxWidth(_chain._pm_handle, 17.0), '16dp matched 17dp'
+ assert not equalsFillMaxWidthThenPadding(_chain._pm_handle, 16.0), 'the chain order is not observed'
+ assert 'fill_max_width' not in dir(_chain), 'the alias leaked into dir()'
+ assert 'fill_max_width' not in vars(type(_chain)), 'the alias was written onto the proxy class'
+ """.trimIndent(),
+ )
+ }
}
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierLocalRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierLocalRenderTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierLocalRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierLocalRenderTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierSeededRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierSeededRenderTest.kt
similarity index 97%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierSeededRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierSeededRenderTest.kt
index 263996e41..e2e21f9db 100644
--- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierSeededRenderTest.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierSeededRenderTest.kt
@@ -29,7 +29,7 @@ import kotlin.test.assertTrue
* `Box__Modifier`, has the identical shape and is left equally unreached here). Filling that slot
* needs a `Modifier` *value*, and nothing the artefact walker binds can produce one on its own:
* `Modifier`'s only source is `androidx.compose.ui.Modifier.Companion`, an object instance, and
- * `ArtifactScanner` binds functions, not statics of an arbitrary object. `ksp-fixtures/artifact`'s
+ * `ArtifactScanner` binds functions, not statics of an arbitrary object. `python-multiplatform-gradle-plugin/fixtures/artifact`'s
* `ComposeSeed.kt` hit exactly this wall and solved it with one hand-written Kotlin function that
* KSP binds instead; `fixture.compose.emptyModifier` (`PythonComposition.kt`) is that same seed,
* placed in this module so `Spacer` has something to receive.
@@ -40,7 +40,7 @@ import kotlin.test.assertTrue
* reached through `pythonx` (`docs/archive/pythonx-adapter-design.md`'s dynamic loader is for the
* composable half of the surface). They come from the *other* producer,
* `PythonProxySource` + `FunctionTable`, the route `WalkedArtifactComposeModifierTest` uses for the
- * identical seed in `ksp-fixtures/artifact`. Both producers run in the same test here --
+ * identical seed in `python-multiplatform-gradle-plugin/fixtures/artifact`. Both producers run in the same test here --
* `PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)` for `Spacer` itself, `PythonProxySource.install()` for the `Modifier`
* chain that fills its one argument -- kept out of `ComposableRenderTest`'s shared setup so a
* regression in this newer combination cannot fail ten unrelated tests that never asked for it.
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ObjectConstantRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ObjectConstantRenderTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ObjectConstantRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ObjectConstantRenderTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PointerInputRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/PointerInputRenderTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PointerInputRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/PointerInputRenderTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PullRefreshRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/PullRefreshRenderTest.kt
similarity index 94%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PullRefreshRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/PullRefreshRenderTest.kt
index 8f77187aa..b4db81b15 100644
--- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PullRefreshRenderTest.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/PullRefreshRenderTest.kt
@@ -26,7 +26,7 @@ import kotlin.test.assertTrue
* .pullRefresh(Modifier, onPull: (Float) -> Float, onRelease: suspend (Float) -> Float, enabled:
* Boolean)`) is driven by **nested scroll** from a scrollable descendant, not by pointer events
* the modifier itself awaits. `docs/archive/pythonx-adapter-design.md` §9.4 identified this as needing
- * a larger harness — a real scrollable descendant composed under the `pullRefresh` parent, with
+ * a larger harness, a real scrollable descendant composed under the `pullRefresh` parent, with
* an actual drag that makes the scrollable child's nested-scroll callbacks flow upward.
*
* ### What is proven here
@@ -42,7 +42,7 @@ import kotlin.test.assertTrue
*
* `onPull: (Float) -> Float` must return the amount consumed. §9.6 settled the shape for
* value-returning slots: Kotlin supplies the return value, Python is given a one-way notification.
- * `pythonPullRefreshCallbacks` implements exactly that — the lambda always returns `pullAvailable`
+ * `pythonPullRefreshCallbacks` implements exactly that, the lambda always returns `pullAvailable`
* (consuming everything offered) while firing a notification to Python.
*
* `onRelease: suspend (Float) -> Float` is the same: Kotlin closes the coroutine returning `0f`,
@@ -53,7 +53,7 @@ import kotlin.test.assertTrue
* A scrollable descendant inside Compose requires `rememberScrollState()` and `verticalScroll`,
* which are not in `artifactIncludePackages`. Building the nested-scroll parent/child tree in
* Kotlin (inside `PythonPullRefreshHarness`) avoids adding those packages to the walker's scope
- * — this proof is about the callback boundary, not about what the walker covers.
+ *, this proof is about the callback boundary, not about what the walker covers.
*/
class PullRefreshRenderTest {
@@ -106,7 +106,7 @@ class PullRefreshRenderTest {
}
try {
scene.render()
- // Drag downward from near the top — the scrollable child is already at the top, so
+ // Drag downward from near the top, the scrollable child is already at the top, so
// over-scroll flows into PullRefreshNestedScrollConnection.onPreScroll -> onPull.
scene.sendPointerEvent(PointerEventType.Move, Offset(SCENE / 2f, START_Y), type = PointerType.Touch)
scene.sendPointerEvent(PointerEventType.Press, Offset(SCENE / 2f, START_Y), type = PointerType.Touch)
@@ -176,7 +176,7 @@ class PullRefreshRenderTest {
}
try {
scene.render()
- // Press and drag in the bottom-right corner — outside the Box that carries pullRefresh.
+ // Press and drag in the bottom-right corner, outside the Box that carries pullRefresh.
val at = Offset(SCENE - 2f, SCENE - 2f)
scene.sendPointerEvent(PointerEventType.Move, at, type = PointerType.Touch)
scene.sendPointerEvent(PointerEventType.Press, at, type = PointerType.Touch)
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonAppViewRenderTest.kt
similarity index 81%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonAppViewRenderTest.kt
index 95bf6abd4..8c5e45aa8 100644
--- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonAppViewRenderTest.kt
@@ -5,7 +5,9 @@ import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.unit.Density
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.Image
-import python.multiplatform.compose.PythonContent
+import python.multiplatform.compose.PythonAppView
+import python.multiplatform.compose.PythonLauncher
+import python.multiplatform.compose.PythonWidget
import python.multiplatform.ffi.Python3
import python.multiplatform.ffi.pythonx.PythonCallables
import python.multiplatform.ffi.pythonx.PythonxAdapter
@@ -35,13 +37,13 @@ import kotlin.test.assertTrue
* the next frame shows the new root, and nothing on the Kotlin side was called to make it so. The
* only thing the test does between the write and the frame is what a window's frame clock does on
* its own -- deliver the snapshot's apply notification -- and the per-root call counters show that the
- * host's `PythonContent` was not re-entered by anything but that.
+ * host's `PythonAppView` was not re-entered by anything but that.
*
* The lifetime half follows `ComposableRenderTest.aDisposedCompositionGivesEveryPythonCallableBack`:
* a named Python function crosses as a `content=` slot, its `sys.getrefcount` rises while composed,
* and comes back to its baseline exactly -- above means a leak, below means a double release.
*/
-class PythonContentRenderTest {
+class PythonAppViewRenderTest {
@BeforeTest
fun installBothProducers() {
@@ -63,7 +65,7 @@ class PythonContentRenderTest {
/**
* Declare, render, replace by a state write, render, replace back, render, dispose.
*
- * Reached through `PythonContent(module, attribute)`, the spelling a host that knows only a module
+ * Reached through `PythonAppView(module, attribute)`, the spelling a host that knows only a module
* name uses.
*/
@Test
@@ -73,7 +75,7 @@ class PythonContentRenderTest {
val baseInner = pyInt("sys.getrefcount(_inner)")
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
- PythonContent(module = "__main__", attribute = "_root_state")
+ PythonAppView(module = "__main__", attribute = "_root_state")
}
val inkA: Int
val inkB: Int
@@ -130,7 +132,7 @@ class PythonContentRenderTest {
val state = Python3.import("__main__").getAttr("_root_state")
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
- PythonContent(root = state)
+ PythonWidget(root = state)
}
val ink: Int
val held: Int
@@ -191,7 +193,7 @@ class PythonContentRenderTest {
fun aPlainPythonCallableIsAFixedRoot() {
val root = Python3.import("__main__").getAttr("_root_a")
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
- PythonContent(root = root)
+ PythonWidget(root = root)
}
val ink: Int
try {
@@ -204,6 +206,60 @@ class PythonContentRenderTest {
assertTrue(ink > 0, "the callable root drew nothing")
}
+ /** `PythonWidget(name, moduleName = ...)` draws exactly what `PythonWidget(root = )` draws. */
+ @Test
+ fun aNamedWidgetDrawsTheSamePixelsAsThePassedCallable() {
+ val root = Python3.import("__main__").getAttr("_root_a")
+ val byObject = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
+ PythonWidget(root = root)
+ }
+ val inkObject: Int
+ try {
+ inkObject = inkOfImage(byObject.render())
+ } finally {
+ byObject.close()
+ }
+ root.close()
+ val byName = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
+ PythonWidget("_root_a", moduleName = "__main__")
+ }
+ val inkName: Int
+ try {
+ inkName = inkOfImage(byName.render())
+ } finally {
+ byName.close()
+ }
+ assertTrue(inkObject > 0, "the callable drew nothing")
+ assertEquals(inkObject, inkName, "the named widget drew different pixels than the callable it names")
+ assertEquals(2, pyInt("_calls['a']"), "each of the two compositions should have called the root once")
+ }
+
+ /**
+ * `PythonLauncher` composes its content. The interpreter is already initialised here (the
+ * `@BeforeTest` of this class, and earlier classes in the same JVM, did it), so what is tested is
+ * that the launcher leaves a running interpreter alone and draws `PythonAppView` inside it; the
+ * not-initialised start cannot be arranged safely, as Python cannot be re-initialised after a
+ * finalize.
+ */
+ @Test
+ fun aLauncherComposesItsContent() {
+ Python3.exec("_root_state.value = _root_a")
+ val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
+ PythonLauncher {
+ PythonAppView(module = "__main__", attribute = "_root_state")
+ }
+ }
+ val ink: Int
+ try {
+ ink = inkOfImage(scene.render())
+ } finally {
+ scene.close()
+ }
+ assertTrue(Python3.isInitialized)
+ assertTrue(ink > 0, "the content of the launcher drew nothing")
+ assertEquals(1, pyInt("_calls['a']"))
+ }
+
/**
* What a window's frame clock does by itself: deliver the global snapshot's pending writes to the
* recomposer, then draw. `ImageComposeScene` has no clock, so the test is the clock.
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonxModules.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonxModules.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonxModules.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonxModules.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RecompositionAccumulationTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/RecompositionAccumulationTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RecompositionAccumulationTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/RecompositionAccumulationTest.kt
diff --git a/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/RememberSaveableRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/RememberSaveableRenderTest.kt
new file mode 100644
index 000000000..12ec71387
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/RememberSaveableRenderTest.kt
@@ -0,0 +1,185 @@
+package fixture.compose
+
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.saveable.LocalSaveableStateRegistry
+import androidx.compose.runtime.saveable.SaveableStateRegistry
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.unit.Density
+import python.multiplatform.compose.PythonWidget
+import python.multiplatform.ffi.Python3
+import python.multiplatform.ffi.pythonx.PythonxAdapter
+import python.multiplatform.ffi.upcall.UpcallBootstrap
+import python.multiplatform.generated.artifacts.ArtifactTable
+import python.multiplatform.reflection.UpcallTable
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * **`rememberSaveableWrapper` from Python** (issue #174): the original pycomposeui's
+ * `rememberSaveableWrapper(init, type)`, as `python.multiplatform.compose.rememberSaveableWrapper(initial)`,
+ * reached through the walk (`RememberSaveableKt`) and called inside a Python root.
+ *
+ * The root asks for one state per supported type (Int, Long, Double, Boolean, String), draws them, and
+ * keeps them in `_rs_states` so the test can write `.value` from Python. The scene is hosted inside
+ * `CompositionLocalProvider(LocalSaveableStateRegistry provides registry)`; the test writes, saves the
+ * registry, closes the scene, builds a new registry from the saved map and a new scene over the same
+ * root, and reads the values the new states hold.
+ *
+ * ### Red before the implementation
+ *
+ * `from python.multiplatform.compose import rememberSaveableWrapper` is an `ImportError` raised by the
+ * Python root when the first frame composes, so each test fails there and not at an assertion on a
+ * value, which is what tells that red from a regression.
+ */
+class RememberSaveableRenderTest {
+
+ @BeforeTest
+ fun installProducers() {
+ Python3.initialize(silent = true)
+ UpcallTable.clear()
+ UpcallTable.install(ArtifactTable.fragments)
+ check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" }
+ PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)
+ Python3.exec(ROOTS)
+ }
+
+ @AfterTest
+ fun cleanup() {
+ Python3.exec(
+ """
+ for _name in [n for n in globals() if n.startswith('_rs_')]:
+ del globals()[_name]
+ """.trimIndent(),
+ )
+ UpcallTable.clear()
+ }
+
+ /** Each supported type: initial value, written value, saved, restored into a new scene. */
+ @Test
+ fun everySupportedTypeSurvivesASaveAndARestoreThroughTheRegistry() {
+ val saved = firstScene(canBeSaved = true)
+ println("rememberSaveableWrapper: saved ${saved.size} entries")
+ assertTrue(saved.isNotEmpty(), "nothing was saved, so the states are not registered with the registry")
+
+ runScene(SaveableStateRegistry(restoredValues = saved, canBeSaved = { true }))
+ assertEquals(WRITTEN, snapshotOfStates(), "the restored states do not hold the values saved")
+ assertEquals(
+ listOf("int", "int", "float", "bool", "str"),
+ pyStr("[type(_rs_states[k].value).__name__ for k in 'ilfbs']").let { parseList(it) },
+ )
+ }
+
+ /**
+ * The check that can fail: the same flow hosted without a registry (so nothing can be saved) shows
+ * the initial values again.
+ */
+ @Test
+ fun withoutASaveableRegistryTheRecreatedSceneShowsTheInitialValues() {
+ val saved = firstScene(canBeSaved = null)
+ assertTrue(saved.isEmpty(), "a scene without a registry still saved $saved")
+
+ runScene(null)
+ assertEquals(INITIAL, snapshotOfStates(), "values came back although nothing was saved")
+ }
+
+ /** A value of any other type is an error that names the type. */
+ @Test
+ fun anUnsupportedTypeRaisesAnErrorNamingTheType() {
+ val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
+ CompositionLocalProvider(LocalSaveableStateRegistry provides SaveableStateRegistry(null) { true }) {
+ PythonWidget("_rs_root_bad", moduleName = "__main__")
+ }
+ }
+ try {
+ scene.render()
+ } finally {
+ scene.close()
+ }
+ val message = pyStr("_rs_error[0]")
+ println("rememberSaveableWrapper unsupported: $message")
+ assertTrue("list" in message, "the error does not name the Python list type: $message")
+ }
+
+ private fun firstScene(canBeSaved: Boolean?): Map> {
+ val registry = canBeSaved?.let { SaveableStateRegistry(restoredValues = null, canBeSaved = { _ -> it }) }
+ val scene = host(registry)
+ try {
+ scene.render()
+ assertEquals(INITIAL, snapshotOfStates(), "the states did not start at the values Python passed")
+ Python3.exec("_rs_states['i'].value = 8; _rs_states['l'].value = 6000000000; _rs_states['f'].value = 2.5; _rs_states['b'].value = False; _rs_states['s'].value = 'xyz'")
+ Snapshot.sendApplyNotifications()
+ scene.render()
+ assertEquals(WRITTEN, snapshotOfStates(), "the write from Python was not read back")
+ return registry?.performSave() ?: emptyMap()
+ } finally {
+ scene.close()
+ }
+ }
+
+ private fun runScene(registry: SaveableStateRegistry?) {
+ Python3.exec("_rs_states.clear()")
+ val scene = host(registry)
+ try {
+ scene.render()
+ } finally {
+ scene.close()
+ }
+ }
+
+ private fun host(registry: SaveableStateRegistry?) =
+ ImageComposeScene(width = 300, height = 200, density = Density(1f)) {
+ if (registry == null) {
+ PythonWidget("_rs_root", moduleName = "__main__")
+ } else {
+ CompositionLocalProvider(LocalSaveableStateRegistry provides registry) {
+ PythonWidget("_rs_root", moduleName = "__main__")
+ }
+ }
+ }
+
+ private fun snapshotOfStates(): String = pyStr("[_rs_states[k].value for k in 'ilfbs']")
+
+ private fun parseList(text: String): List =
+ text.removePrefix("[").removeSuffix("]").split(", ").map { it.trim('\'') }
+
+ private fun pyStr(expression: String): String = Python3.import("__main__").getAttr("__dict__").let { globals ->
+ Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString()
+ }
+
+ private companion object {
+ /** `Py_eval_input`. */
+ const val PY_EVAL_INPUT = 258
+ const val INITIAL = "[7, 5000000000, 1.5, True, 'abc']"
+ const val WRITTEN = "[8, 6000000000, 2.5, False, 'xyz']"
+
+ val ROOTS = """
+ _rs_states = {}
+ _rs_error = []
+
+ def _rs_root():
+ from python.multiplatform.compose import rememberSaveableWrapper
+ from androidx.compose.foundation.layout import Column
+ from androidx.compose.material3 import Text
+ _rs_states['i'] = rememberSaveableWrapper(7)
+ _rs_states['l'] = rememberSaveableWrapper(5000000000)
+ _rs_states['f'] = rememberSaveableWrapper(1.5)
+ _rs_states['b'] = rememberSaveableWrapper(True)
+ _rs_states['s'] = rememberSaveableWrapper('abc')
+ def _rs_body():
+ for _k in 'ilfbs':
+ Text(str(_rs_states[_k].value))
+ Column(content=_rs_body)
+
+ def _rs_root_bad():
+ from python.multiplatform.compose import rememberSaveableWrapper
+ try:
+ rememberSaveableWrapper([1, 2])
+ except BaseException as _e:
+ _rs_error.append(type(_e).__name__ + ': ' + str(_e))
+ """.trimIndent()
+ }
+}
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RetainedSlotSweepPreconditionTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/RetainedSlotSweepPreconditionTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RetainedSlotSweepPreconditionTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/RetainedSlotSweepPreconditionTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/SwipeableRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/SwipeableRenderTest.kt
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/SwipeableRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/SwipeableRenderTest.kt
diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt
similarity index 86%
rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt
index 2fe9a7c3c..dd485d64f 100644
--- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt
+++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt
@@ -147,7 +147,36 @@ class TextFieldStateRenderTest {
}
/**
- * Made inside a composition by `rememberTextFieldState`, drawn by `TextField(state=...)` in the same
+ * Issue #131: the Pythonic spellings are the same declarations as the Kotlin ones -- the
+ * `@Composable` `remember_text_field_state`, the constructor's `initial_text=`/`initial_selection=`
+ * keywords, and the extensions `clear_text()`/`set_text_and_place_cursor_at_end()` -- and the
+ * Kotlin spellings keep working beside them.
+ */
+ @Test
+ fun thePythonicSpellingsAreTheSameDeclarations() {
+ Python3.exec(
+ """
+ from androidx.compose.foundation.text.input import (
+ TextFieldState, rememberTextFieldState, remember_text_field_state,
+ )
+ from androidx.compose.ui.text import TextRange
+
+ assert remember_text_field_state is rememberTextFieldState
+ _tfs_py = TextFieldState(initial_text='kw', initial_selection=TextRange(0))
+ assert _tfs_py.text == 'kw', repr(_tfs_py.text)
+ _tfs_py.clear_text()
+ assert _tfs_py.text == '', repr(_tfs_py.text)
+ _tfs_py.set_text_and_place_cursor_at_end('snake')
+ assert _tfs_py.text == 'snake', repr(_tfs_py.text)
+ _tfs_py.setTextAndPlaceCursorAtEnd('camel')
+ assert _tfs_py.text == 'camel', repr(_tfs_py.text)
+ """.trimIndent(),
+ )
+ }
+
+ /**
+ * Made inside a composition by `remember_text_field_state` (the Pythonic spelling of
+ * `rememberTextFieldState`, issue #131), drawn by `TextField(state=...)` in the same
* body, then written from Python **outside** the composition: the state is Compose snapshot state,
* so the next frame of the *same* scene shows the new text with no host call and no Python body
* re-run being needed. Every value the body saw is the one remembered state (each reads the written
@@ -168,7 +197,7 @@ class TextFieldStateRenderTest {
"""
assert len(_tfs_seen) >= 1, _tfs_seen
assert type(_tfs_seen[0].text) is str and _tfs_seen[0].text == 'abc', repr(_tfs_seen[0].text)
- _tfs_seen[0].setTextAndPlaceCursorAtEnd('a much longer line of text')
+ _tfs_seen[0].set_text_and_place_cursor_at_end('a much longer line of text')
""".trimIndent(),
)
// `TextField(state=...)` does not finish reacting to a state write inside one frame: the
@@ -235,9 +264,9 @@ class TextFieldStateRenderTest {
const val MAX_FRAMES = 4
val REMEMBERED = """
- from androidx.compose.foundation.text.input import rememberTextFieldState
+ from androidx.compose.foundation.text.input import remember_text_field_state
from androidx.compose.material3 import TextField
- _tfs_state = rememberTextFieldState('abc')
+ _tfs_state = remember_text_field_state('abc')
_tfs_seen.append(_tfs_state)
TextField(state=_tfs_state)
""".trimIndent()
diff --git a/ksp-fixtures/compose/src/desktopTest/resources/opaque-square.png b/python-multiplatform-compose/fixtures/compose/src/desktopTest/resources/opaque-square.png
similarity index 100%
rename from ksp-fixtures/compose/src/desktopTest/resources/opaque-square.png
rename to python-multiplatform-compose/fixtures/compose/src/desktopTest/resources/opaque-square.png
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/README.md b/python-multiplatform-compose/fixtures/notebook-e2e/README.md
new file mode 100644
index 000000000..0a7ab678a
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/README.md
@@ -0,0 +1,133 @@
+# `:python-multiplatform-compose:fixtures:notebook-e2e`: pythonx-compose's `UI.ipynb`, end to end
+
+Issue #26. A desktop host draws a Python-declared root through `python-multiplatform-compose`
+(`PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")`, #18), with the **pythonx-compose wheel
+installed**, and runs the notebook's scenarios against it. Each scenario is its own test, so a
+regression fails under the notebook cell's number.
+
+The specification is the user's notebook, `pythonx-compose/UI.ipynb` (read in place, never copied).
+Where pythonx-compose has since decided a different spelling (its `docs/INTENT.md` §5 and
+`docs/SPEC.md`), the tests use the decided one: `@app` and an `app_root` state instead of
+`main.App.update(...)`. The UI state is the notebook's own: `remember_saveable` inside the root,
+attached as `App.messages`, read and written with `getValue()`/`setValue()` (`.value` is an alias),
+which needs `python.multiplatform.compose.RememberSaveableKt` in the bindings. Kotlin parameter names in
+snake_case; `Color(0xFF...)`; `DefaultIcons.Add` without parentheses;
+`TextField(state=..., modifier=Modifier.padding(8))`.
+
+## Building the wheel
+
+Build the wheel with uv from a clean copy of pythonx-compose's `develop`, so nothing is written into
+that checkout:
+
+```bash
+mkdir -p .tmp/pxc-src
+git -C ../pythonx-compose archive origin/develop | tar -x -C .tmp/pxc-src
+(cd .tmp/pxc-src && uv build --wheel --out-dir ../pythonx-compose-dist)
+```
+
+The result is `.tmp/pythonx-compose-dist/pythonx_compose--py3-none-any.whl`.
+
+## Running
+
+```bash
+JAVA_HOME= ./gradlew :python-multiplatform-compose:fixtures:notebook-e2e:desktopTest --rerun \
+ -PpythonxComposeWheel=.tmp/pythonx-compose-dist --console=plain > .tmp/notebook-e2e.log 2>&1; echo "EXIT=$?"
+```
+
+| Input | Gradle property | Environment |
+|---|---|---|
+| the wheel: a `.whl` file, or a directory holding `pythonx_compose-*.whl` (relative paths are from the repository root) | `-PpythonxComposeWheel=` | `PYTHONX_COMPOSE_WHEEL` |
+| the version to pick from that directory (or to require of the file) | `-PpythonxComposeVersion=` | `PYTHONX_COMPOSE_VERSION` |
+| do not run the scenarios | `-PnotebookE2e.skip=true` | `NOTEBOOK_E2E_SKIP=true` |
+
+**Without a wheel the tests fail, by name, with a message that says how to give one.** They do not
+skip. Only `-PnotebookE2e.skip=true` disables `desktopTest`, and the build log then says that the
+scenarios did not run. CI wants the scenarios run: the `notebook-e2e` job of `.github/workflows/desktop.yml` (#213) builds
+the wheel from pythonx-compose `develop` with uv and runs this module on every push to `develop`.
+
+"Installing" is unpacking the pure-Python wheel into `build/pythonx-compose/site-packages`
+(`installPythonxComposeWheel`), which is all installing a pure-Python wheel amounts to; the embedded
+interpreter has no installer of its own. The tests then require `pythonx.compose.__file__` to be
+inside that directory, so a stray source checkout on `sys.path` cannot stand in for the wheel.
+
+## In a GraalVM native image (macOS, #249)
+
+The same 20 scenarios, built into one executable and run on their own:
+
+```bash
+JAVA_HOME= ./gradlew :python-multiplatform-compose:fixtures:notebook-e2e:notebookE2eNativeRun \
+ -PpythonxComposeWheel=.tmp/pythonx-compose-dist --console=plain
+```
+
+- `NotebookE2eMain` calls each scenario after its class's `@BeforeTest`, with no JUnit, and prints
+ `NOTEBOOK_E2E: n passed, m failed of 20`. `notebookE2eJvm` runs it on the JVM first.
+- `notebookE2eNativeCompile` calls `native-image` from the JDK 25 toolchain (Liberica NIK Full). The
+ configuration Compose and skiko need is `src/desktopTest/native-image`, recorded by
+ `notebookE2eNativeAgent`; rerun that task after a dependency changes.
+- Beside the executable: `python-multiplatform-home/` (the plugin's `stageNativeImagePythonHome`),
+ `site-packages/` (the wheel) and `app/`. The image finds them there with no environment.
+- `notebookE2eNativeRun` removes `JAVA_HOME` and `PYTHONHOME`, requires 20 passed, and fails if
+ `otool -L` shows `libjvm`.
+- The image takes `ui`, `ui-text` and `foundation` from compose-multiplatform-core-extended's mavenLocal
+ build (`composeExtended.*` in the root `gradle.properties`): upstream Compose sends its main-thread
+ work to skiko's Swing queue, which needs AWT, and AWT needs `libjvm`. The fork sends it to
+ `Dispatchers.Main` under `compose.main.dispatcher=coroutines`, which `NotebookE2eMainDispatcherFactory`
+ supplies (registered by `src/desktopTest/native-image-main/` on the image's classpath only).
+
+## Scenarios
+
+| Notebook cells | Test | What must hold |
+|---|---|---|
+| 5 | `NotebookRootTest.cell05_theImportsResolveFromTheInstalledWheel` | the cell's imports (decided spellings) resolve |
+| 6–7 | `…cell06to07_importingMainDrawsTheAppItDeclares` | `import main` declares the root; the host draws exactly `Text(INITIAL)`; an idle frame does not rerun it |
+| 9–13 | `…cell09to13_theNotebookAndTheScreenShareOneState` | the notebook reads the shown state, writes it, the screen follows; restoring restores the screen |
+| 15–16 | `…cell15to16_redeclaringTheRootChangesTheScreenWithNoUpdateCall` | a cell redeclaring the root with `@app` puts it on screen within 4 frames, pixel-equal to Kotlin |
+| (negative) | `…negative_withAppStubbedOutTheRedeclarationDoesNotReachTheScreen` | with `@app` stubbed to not write `app_root`, the same observation reports no change |
+| (negative) | `…negative_aHostHandedTheRootOnceDoesNotFollowARedeclaration` | with the host given `main.App` instead of the state, the same observation reports no change |
+| 19, 20 | `NotebookPracticeTest.cell19_text`, `cell20_textWithAFontSize` | red text; at 30 sp |
+| 22, 23 | `…cell22_anEmptyBlackButton`, `cell23_aButtonHoldingText` | black `Button`, empty and with text |
+| 25 | `…cell25_aCard` | red `Card` with white text |
+| 28, 29, 30 | `…cell28_defaultIconsAddIsAnImageVector`, `cell29_anIcon`, `cell30_anIconInsideAButton` | `DefaultIcons.Add` is an `ImageVector`; `Icon` draws it, alone and in a button |
+| 35, 36, 38 | `…cell35_aColumnOfTwoButtons`, `cell36_aColumnAlignedToTheEnd`, `cell38_aRow` | `Column`, `Column(horizontal_alignment=Alignment.Horizontal.End)`, `Row` |
+| 37 (signature) | `…cell37_aRowWithAnArrangement` | `Row(horizontal_arrangement=Arrangement.Center)` |
+| 40 | `…cell40_aCardSpacedBySpacers` | `Card`/`Column`/`Row` spaced by `Spacer(modifier=Modifier.padding(...))` |
+| 31–33 | `NotebookTextFieldTest.cell32to33_aTextFieldKeepsInputMethodCompositionInComposeAndTheNotebookReadsTheResult` | see below |
+
+Every practice cell is compared **pixel for pixel** with the same composables drawn from Kotlin, so a
+pass means the screen shows what the cell declared.
+
+### The text field (cells 31–33, pythonx-compose #10)
+
+`TextField(state=remember_text_field_state(""))` is focused by a click (keyboard focus traversal if
+the click does not do it), then receives a Hangul input-method sequence (composing ㅎ, 하, 한, then
+committing 한) with a frame after each event. While it types, a `sys.monitoring` counter of every
+Python function started (`PY_START`, all threads) must stay at zero and the root must not rerun.
+Kotlin's `TextFieldState` must hold the composing text with composing range `0..1` at each step and
+`한` with no composing range after the commit; the notebook then reads `fields[-1].text == "한"`.
+Finally `set_text_and_place_cursor_at_end("x")` from outside the composition must reach the screen
+within 4 frames.
+
+**How the input method is driven.** `ImageComposeScene` (Compose Multiplatform 1.11.1) cannot take
+input-method composition: it builds its scene with a `PlatformContext.Empty` delegate that never
+hands out the text field's `PlatformTextInputMethodRequest`, has no parameter to replace it, and its
+public input API is `sendKeyEvent` (key presses) and pointer events. `InputMethodScene` therefore
+builds the same `CanvasLayersComposeScene` (`@InternalComposeUiApi`) with its own `PlatformContext`
+that records the request, and delivers each event the way the desktop window's
+`InputMethodSession.inputMethodTextChanged` does in 1.11.1: one `request.editText { commitText(committed, 1);
+if (composing.isNotEmpty()) setComposingText(composing, 1) }`. Not exercised: the AWT layer above it
+(decoding an `InputMethodEvent` into those two strings), which needs a window.
+
+## Gaps: notebook content not expressed here
+
+| Notebook | Why it is not in a test |
+|---|---|
+| cells 2–3 (`print`, numpy) | not UI |
+| cell 5: `remember_saveable`, `DefaultCoroutineScope`, `MainCoroutineScope` | undecided in pythonx-compose (INTENT §4.1, SPEC §8); nothing provides them |
+| cell 5/25/35/…: lower-case `modifier`, `modifier=modifier` | INTENT §5.4 replaces it with `modifier=Modifier`. The binder has no conversion for a proxy **class** passed as an argument (`Modifier.empty()` is used only for the class-spelling *method call*, `PythonxAdapter.kt` `_BoundMember`), so `Card(modifier=Modifier)` is not expected to work; the argument is left out, which is the same Kotlin default (`Modifier`). Not run to confirm. |
+| cell 22/23/30/35: `corner_radius=` on `Button`; cell 25/40: on `Card` | Kotlin's counterpart is `shape=RoundedCornerShape(...)` from `androidx.compose.foundation.shape`, which this fixture does not walk (its package list is `:python-multiplatform-compose:fixtures:compose`'s). Adding `"androidx.compose.foundation.shape"` to `artifactIncludePackages` is the next step; not tried. |
+| cell 22/23/25/…: `color=` on `Button`/`Card` | written as Kotlin's `colors=ButtonColors(...)` / `CardColors(...)` constructors. `ButtonDefaults.buttonColors(...)`, the usual Kotlin spelling, is a `@Composable` object function, which the walker declines (SPEC B-1). |
+| cell 27: `help(DefaultIcons)` | prints documentation; nothing to assert on screen |
+| cells 42, 48: empty; "채팅 화면 디자인하기" | no content |
+| cells 44–47: `from ws import client` | a websocket client outside pythonx-compose; marked "재 구현 필요" in the notebook |
+| cells 51–56: Llama 3.1 streaming into the UI | needs a model and `main.run_llama3`; the state half (writing `.value` from a loop) is cell 12's mechanism |
+| cell 54: `onclick=lambda: {...}` driving a state change | a click from Python reaching a Kotlin callback is `:python-multiplatform-compose:fixtures:compose`'s `CallbackDrivenRenderTest`; not repeated here |
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/build.gradle.kts b/python-multiplatform-compose/fixtures/notebook-e2e/build.gradle.kts
new file mode 100644
index 000000000..9dc84176f
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/build.gradle.kts
@@ -0,0 +1,368 @@
+import java.io.ByteArrayOutputStream
+import org.apache.tools.ant.util.TeeOutputStream
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+/**
+ * Issue #26: pythonx-compose's `UI.ipynb` scenarios, run end to end against a real host.
+ *
+ * The host is `python-multiplatform-compose`'s `PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")`
+ * (#18). The Python side is the **installed pythonx-compose wheel**, not a copy of its sources and
+ * not a stand-in: every `pythonx.compose.*` import in the tests must resolve to the files the wheel
+ * unpacked (the tests check `__file__`). The wheel is an input:
+ *
+ * | Input | Gradle property | Environment |
+ * |---|---|---|
+ * | a `.whl` file, or a directory holding `pythonx_compose-*.whl` | `-PpythonxComposeWheel=` | `PYTHONX_COMPOSE_WHEEL` |
+ * | which version to pick from that directory (or to require of the file) | `-PpythonxComposeVersion=` | `PYTHONX_COMPOSE_VERSION` |
+ * | deliberately not running the scenarios | `-PnotebookE2e.skip=true` | `NOTEBOOK_E2E_SKIP=true` |
+ *
+ * **No wheel is a failure, not a skip.** Without an input every test fails by name with the message
+ * below; only the explicit opt-out disables `desktopTest`, and it says so in the build log. CI decides
+ * which of the two it wants.
+ *
+ * "Installing" a pure-Python wheel is unpacking it onto `sys.path`, which is what
+ * `pip install --target` does for one; the embedded interpreter has no `pip` of its own to run.
+ * [installPythonxComposeWheel] unpacks it into `build/pythonx-compose/site-packages`.
+ *
+ * The rest -- the Compose compiler plugin, the walked packages, the JVM flags -- is
+ * `:python-multiplatform-compose:fixtures:compose`'s configuration, kept identical where it can be so that a difference in
+ * result is a difference in what the notebook asks for, not in the build.
+ */
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.jetpack.compose)
+ alias(libs.plugins.compose.compiler)
+ id("io.github.thisisthepy.python.multiplatform.bindings")
+}
+
+kotlin {
+ jvm("desktop") {
+ @OptIn(ExperimentalKotlinGradlePluginApi::class)
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+ }
+
+ sourceSets {
+ val commonMain by getting {
+ dependencies {
+ implementation(projects.pythonMultiplatform)
+ }
+ }
+ val desktopMain by getting {
+ dependencies {
+ implementation(projects.pythonMultiplatformCompose)
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material3)
+ implementation(compose.ui)
+ implementation(libs.compose.material.icons.core)
+ implementation(compose.desktop.currentOs)
+ }
+ }
+ val desktopTest by getting {
+ dependencies {
+ implementation(libs.kotlin.test)
+ }
+ }
+ }
+}
+
+pythonBindings {
+ role.set("app")
+ processor.set(projects.pythonMultiplatformKsp)
+
+ artifactConfiguration.set("desktopCompileClasspath")
+ artifactSourceSet.set("desktopMain")
+ // The same list as `:python-multiplatform-compose:fixtures:compose` (its build file says what each entry is for). Every
+ // `pythonx.compose.*` module the notebook imports maps onto one of these Kotlin packages
+ // (pythonx-compose's `pythonx-map.toml`); a package not walked here is a name pythonx cannot
+ // resolve, which is a fixture gap, not a pythonx one.
+ artifactIncludePackages.set(
+ listOf(
+ "androidx.compose.material3",
+ "androidx.compose.foundation.layout",
+ "androidx.compose.ui.graphics",
+ "androidx.compose.ui.res",
+ "androidx.compose.ui",
+ "androidx.compose.material.icons",
+ "androidx.compose.runtime.SnapshotStateKt",
+ "androidx.compose.runtime.State",
+ "androidx.compose.runtime.MutableState",
+ "androidx.compose.foundation.text.input.TextFieldState",
+ "androidx.compose.foundation.text.input.TextFieldStateKt",
+ "python.multiplatform.compose.RememberSaveableKt",
+ ),
+ )
+}
+
+// ---- The pythonx-compose wheel ---------------------------------------------------------------------
+
+val wheelInput: String? = providers.gradleProperty("pythonxComposeWheel")
+ .orElse(providers.environmentVariable("PYTHONX_COMPOSE_WHEEL")).orNull?.takeIf { it.isNotBlank() }
+val wheelVersion: String? = providers.gradleProperty("pythonxComposeVersion")
+ .orElse(providers.environmentVariable("PYTHONX_COMPOSE_VERSION")).orNull?.takeIf { it.isNotBlank() }
+val notebookSkipped: Boolean = providers.gradleProperty("notebookE2e.skip")
+ .orElse(providers.environmentVariable("NOTEBOOK_E2E_SKIP")).orNull?.toBoolean() == true
+
+/** The wheel to install, or why there is none. Exactly one of the two is non-null. */
+fun resolveWheel(): Pair {
+ val howTo = "Build it (python-multiplatform-compose/fixtures/notebook-e2e/README.md) and pass -PpythonxComposeWheel= " +
+ "(or PYTHONX_COMPOSE_WHEEL); to skip the notebook scenarios deliberately pass -PnotebookE2e.skip=true."
+ val input = wheelInput
+ ?: return null to "No pythonx-compose wheel was given, so the UI.ipynb scenarios cannot run. $howTo"
+ val given = rootProject.file(input)
+ if (!given.exists()) return null to "The pythonx-compose wheel input does not exist: $given. $howTo"
+ val versionPart = wheelVersion?.let { "pythonx_compose-$it-" }
+ fun isWheel(f: File) = f.isFile && f.name.startsWith("pythonx_compose-") && f.name.endsWith(".whl")
+ if (given.isDirectory) {
+ val candidates = given.listFiles().orEmpty().filter(::isWheel)
+ .filter { versionPart == null || it.name.startsWith(versionPart) }
+ .sortedBy { it.name }
+ return when (candidates.size) {
+ 1 -> candidates.single() to null
+ 0 -> null to "No pythonx_compose-${wheelVersion ?: "*"}-*.whl in $given. $howTo"
+ else -> null to "Several pythonx-compose wheels in $given (${candidates.joinToString { it.name }}); " +
+ "choose one with -PpythonxComposeVersion= or pass the file itself."
+ }
+ }
+ if (!isWheel(given)) return null to "$given is not a pythonx_compose-*.whl file. $howTo"
+ if (versionPart != null && !given.name.startsWith(versionPart)) {
+ return null to "$given is not version $wheelVersion (-PpythonxComposeVersion)."
+ }
+ return given to null
+}
+
+val resolvedWheel = resolveWheel()
+val pythonxComposeWheel: File? = resolvedWheel.first
+val pythonxComposeWheelError: String? = resolvedWheel.second
+val pythonxComposeSitePackages = layout.buildDirectory.dir("pythonx-compose/site-packages")
+
+val installPythonxComposeWheel by tasks.registering(Sync::class) {
+ group = "verification"
+ description = "Unpacks the pythonx-compose wheel (-PpythonxComposeWheel) where the notebook E2E tests put it on sys.path."
+ val wheel = pythonxComposeWheel
+ enabled = wheel != null
+ if (wheel != null) from(zipTree(wheel))
+ into(pythonxComposeSitePackages)
+}
+
+if (notebookSkipped) {
+ logger.lifecycle(
+ "notebook-e2e: desktopTest is DISABLED by -PnotebookE2e.skip=true -- the UI.ipynb scenarios did not run.",
+ )
+}
+
+/**
+ * The same JVM `:python-multiplatform-compose:fixtures:compose`'s tests need: JDK 21 with the preview FFM API, a
+ * `java.library.path` for the extracted `libpython`, and a headless AWT.
+ */
+tasks.named("desktopTest") {
+ javaLauncher.set(
+ javaToolchains.launcherFor {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ },
+ )
+ jvmArgs("--enable-preview", "-Djava.library.path=.")
+ systemProperty("java.awt.headless", "true")
+
+ // The application module the notebook imports as `main`.
+ val appDir = layout.projectDirectory.dir("src/desktopTest/python")
+ inputs.dir(appDir)
+ systemProperty("notebookE2e.appDir", appDir.asFile.absolutePath)
+
+ val wheel = pythonxComposeWheel
+ if (wheel != null) {
+ dependsOn(installPythonxComposeWheel)
+ inputs.file(wheel)
+ systemProperty("notebookE2e.wheel", wheel.absolutePath)
+ systemProperty("notebookE2e.sitePackages", pythonxComposeSitePackages.get().asFile.absolutePath)
+ } else {
+ systemProperty("notebookE2e.wheelError", checkNotNull(pythonxComposeWheelError))
+ }
+ enabled = !notebookSkipped
+}
+
+// ---- The notebook E2E in a GraalVM native image (issue #249, #191's first milestone) ----------
+//
+// `NotebookE2eMain` runs the same 20 scenarios as `desktopTest`, by direct calls, so they can run in a
+// native image. The scenes are offscreen (`ImageComposeScene`), so no window is involved. Built the way
+// `:sample:nativeCompile` builds its image: `native-image` called directly, because the GraalVM Gradle
+// plugin registers no tasks for a Kotlin Multiplatform `jvm()` target. The Compose and Skiko
+// configuration a closed world needs is recorded by `notebookE2eNativeAgent` into
+// `src/desktopTest/native-image` and committed; python-multiplatform's own FFM registrations come
+// from its jar's `META-INF/native-image`.
+
+val e2eTestCompilation = kotlin.targets.getByName("desktop").compilations.getByName("test")
+// `native-image-main/` registers the AWT-free `Dispatchers.Main` (`NotebookE2eMainDispatcher.kt`), first on
+// the path so it is found before kotlinx-coroutines-swing's; only the agent run and the image use it.
+val e2eNativeMainResources = layout.projectDirectory.dir("src/desktopTest/native-image-main")
+// The fork's Compose (#191): its `ui-desktop` sends the scene's main-thread work to `Dispatchers.Main`
+// when it is built with `-Dcompose.awt=false` (`ExtendedMainDispatcher`), where upstream's always uses
+// skiko's `SwingDispatcher`, which starts AWT; and AWT in a native image needs the JDK's `libawt`,
+// which links `libjvm`. So the image takes the modules `composeExtended.modules` names from the fork,
+// at `composeExtended.version` (root gradle.properties); `desktopTest` keeps upstream Compose.
+val composeExtendedVersion = providers.gradleProperty("composeExtended.version").orNull
+// The fork's group prefix: `org.jetbrains.compose` (its old coordinates) or its own, e.g.
+// `org.thisisthepy.compose`; a module `org.jetbrains.compose.ui:ui-desktop` is then taken as
+// `.ui:ui-desktop`.
+val composeExtendedGroupPrefix = providers.gradleProperty("composeExtended.groupPrefix").orNull ?: "org.jetbrains.compose"
+val composeExtendedModules = providers.gradleProperty("composeExtended.modules").orNull
+ ?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet().orEmpty()
+// Where the fork's modules come from: this machine's mavenLocal (`composeExtended.mavenLocal`), or a
+// Maven repository by URL (`composeExtended.repository`, e.g. the fork's GitHub Packages registry,
+// #251), read with `GITHUB_ACTOR`/`GITHUB_TOKEN` when they are set (GitHub Packages asks for a token
+// even for a public package). Either way only the fork's group at the fork's version is taken there.
+val composeExtendedMavenLocal = providers.gradleProperty("composeExtended.mavenLocal").orNull?.toBoolean() == true
+val composeExtendedRepository = providers.gradleProperty("composeExtended.repository").orNull?.takeIf { it.isNotBlank() }
+if (composeExtendedVersion != null && (composeExtendedMavenLocal || composeExtendedRepository != null)) {
+ val forkOnly: MavenRepositoryContentDescriptor.() -> Unit = {
+ includeVersionByRegex(Regex.escape(composeExtendedGroupPrefix) + ".*", ".*", Regex.escape(composeExtendedVersion))
+ }
+ // A project's own repositories replace the settings' ones, so those are repeated here.
+ repositories {
+ if (composeExtendedMavenLocal) mavenLocal { mavenContent(forkOnly) }
+ if (composeExtendedRepository != null) {
+ maven {
+ url = uri(composeExtendedRepository)
+ System.getenv("GITHUB_TOKEN")?.let { token ->
+ credentials {
+ username = System.getenv("GITHUB_ACTOR") ?: "token"
+ password = token
+ }
+ }
+ mavenContent(forkOnly)
+ }
+ }
+ google {
+ mavenContent {
+ includeGroupAndSubgroups("androidx")
+ includeGroupAndSubgroups("com.android")
+ includeGroupAndSubgroups("com.google")
+ }
+ }
+ mavenCentral()
+ }
+}
+val e2eNativeRuntime: Configuration = configurations.getByName("desktopTestRuntimeClasspath").copyRecursive().apply {
+ resolutionStrategy.eachDependency {
+ if (composeExtendedVersion != null && "${requested.group}:${requested.name}" in composeExtendedModules) {
+ useTarget("${requested.group.replaceFirst("org.jetbrains.compose", composeExtendedGroupPrefix)}:${requested.name}:$composeExtendedVersion")
+ because("compose-multiplatform-core-extended: an AWT-free main dispatcher for a native image (#249)")
+ }
+ }
+}
+// The copied configuration carries the dependencies only; the main compilation's output and the
+// walker's composable thunks (generated bytecode, not a dependency) are added as they are for desktopTest.
+val e2eMainCompilation = kotlin.targets.getByName("desktop").compilations.getByName("main")
+val e2eClasspath = files(
+ e2eNativeMainResources,
+ e2eTestCompilation.output.allOutputs,
+ e2eMainCompilation.output.allOutputs,
+ layout.buildDirectory.dir("generated/pythonComposableThunks/desktopMain"),
+ e2eNativeRuntime,
+)
+val e2eMainClass = "fixture.notebook.NotebookE2eMainKt"
+val e2eNativeConfig = layout.projectDirectory.dir("src/desktopTest/native-image")
+val e2eNativeDir = layout.buildDirectory.dir("native/notebook-e2e")
+val e2eAppDir = layout.projectDirectory.dir("src/desktopTest/python")
+val e2eJdk25 = javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(25)) }
+
+fun JavaExec.notebookE2eOnTheJvm() {
+ dependsOn(e2eTestCompilation.compileTaskProvider, installPythonxComposeWheel)
+ classpath = e2eClasspath
+ mainClass.set(e2eMainClass)
+ javaLauncher.set(e2eJdk25)
+ jvmArgs("--enable-native-access=ALL-UNNAMED", "-Djava.awt.headless=true", "-Dcompose.awt=false")
+ systemProperty("notebookE2e.appDir", e2eAppDir.asFile.absolutePath)
+ systemProperty("notebookE2e.sitePackages", pythonxComposeSitePackages.get().asFile.absolutePath)
+ pythonxComposeWheel?.let { systemProperty("notebookE2e.wheel", it.absolutePath) }
+ pythonxComposeWheelError?.let { systemProperty("notebookE2e.wheelError", it) }
+}
+
+tasks.register("notebookE2eJvm") {
+ group = "verification"
+ description = "Runs NotebookE2eMain on the JVM: the 20 scenarios outside JUnit, before a native image is built from it."
+ notebookE2eOnTheJvm()
+}
+
+tasks.register("notebookE2eNativeAgent") {
+ group = "native"
+ description = "Runs NotebookE2eMain under native-image's tracing agent and writes the configuration it records into src/desktopTest/native-image."
+ notebookE2eOnTheJvm()
+ doFirst { e2eNativeConfig.asFile.mkdirs() }
+ jvmArgs("-agentlib:native-image-agent=config-output-dir=${e2eNativeConfig.asFile.absolutePath}")
+}
+
+pythonBindings {
+ nativeImageDirectory.set(e2eNativeDir)
+}
+
+val notebookE2eNativeStage by tasks.registering(Sync::class) {
+ group = "native"
+ description = "Puts the pythonx-compose wheel and the notebook's app beside the native image."
+ dependsOn(installPythonxComposeWheel)
+ from(pythonxComposeSitePackages) { into("site-packages") }
+ from(e2eAppDir) { into("app") }
+ into(layout.buildDirectory.dir("native/notebook-e2e-files"))
+}
+
+val notebookE2eNativeCompile by tasks.registering(Exec::class) {
+ group = "native"
+ description = "Builds the notebook E2E as a GraalVM native image (build/native/notebook-e2e/notebook-e2e)."
+ dependsOn(e2eTestCompilation.compileTaskProvider, "stageNativeImagePythonHome", notebookE2eNativeStage)
+ inputs.files(e2eClasspath)
+ inputs.dir(e2eNativeConfig)
+ outputs.file(e2eNativeDir.map { it.file("notebook-e2e") })
+ val launcher = e2eJdk25
+ val outDir = e2eNativeDir
+ val staged = layout.buildDirectory.dir("native/notebook-e2e-files")
+ doFirst { outDir.get().asFile.mkdirs() }
+ executable = launcher.get().metadata.installationPath.asFile.resolve("bin/native-image").absolutePath
+ args(
+ "-cp", e2eClasspath.asPath,
+ "--no-fallback",
+ "--enable-native-access=ALL-UNNAMED",
+ "-Djava.awt.headless=true",
+ // The fork's switch: `ExtendedAwt.available` is a constant false, so nothing reaches AWT.
+ "-Dcompose.awt=false",
+ "-H:ConfigurationFileDirectories=${e2eNativeConfig.asFile.absolutePath}",
+ "-o", outDir.get().asFile.resolve("notebook-e2e").absolutePath,
+ e2eMainClass,
+ )
+ doLast {
+ project.copy { from(staged); into(outDir) }
+ }
+}
+
+tasks.register("notebookE2eNativeRun") {
+ group = "verification"
+ description = "Runs the native image on its own (no JAVA_HOME, no PYTHONHOME) and requires 20 of 20 scenarios to pass and no libjvm."
+ dependsOn(notebookE2eNativeCompile)
+ val dir = e2eNativeDir
+ workingDir(dir)
+ executable = dir.get().asFile.resolve("notebook-e2e").absolutePath
+ environment.remove("JAVA_HOME")
+ environment.remove("PYTHONHOME")
+ val captured = ByteArrayOutputStream()
+ standardOutput = TeeOutputStream(System.out, captured)
+ isIgnoreExitValue = true
+ doLast {
+ val out = captured.toString()
+ val summary = out.lineSequence().lastOrNull { it.startsWith("NOTEBOOK_E2E:") }
+ ?: throw GradleException("the native image printed no NOTEBOOK_E2E summary")
+ if (summary != "NOTEBOOK_E2E: 20 passed, 0 failed of 20") throw GradleException("notebook E2E in the native image: $summary")
+ val otool = ProcessBuilder("otool", "-L", executable).redirectErrorStream(true).start()
+ val linked = otool.inputStream.bufferedReader().readText().also { otool.waitFor() }
+ if ("libjvm" in linked) throw GradleException("the native image links libjvm:\n$linked")
+ logger.lifecycle("notebook E2E native image: $summary; no libjvm")
+ }
+}
+
+tasks.register("notebookE2eNativeClasspath") {
+ group = "native"
+ description = "Lists the Compose artefacts the notebook E2E's native image is built from."
+ val files = e2eNativeRuntime
+ doLast { files.files.map { it.name }.filter { it.startsWith("ui") || it.startsWith("foundation") || it.startsWith("skiko") }.sorted().forEach { println(it) } }
+}
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopMain/kotlin/fixture/notebook/EmptyModifier.kt b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopMain/kotlin/fixture/notebook/EmptyModifier.kt
new file mode 100644
index 000000000..a1c4509fc
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopMain/kotlin/fixture/notebook/EmptyModifier.kt
@@ -0,0 +1,14 @@
+package fixture.notebook
+
+import androidx.compose.ui.Modifier
+
+/**
+ * The one line of Kotlin pythonx-compose asks an application for: where an empty `Modifier` comes from.
+ *
+ * `Modifier` as a Kotlin expression is `Modifier.Companion`, an object, and the walker binds functions,
+ * so the empty modifier every `Modifier.padding(8)` chain starts from has no bound name of its own.
+ * pythonx-compose's `pythonx/compose/ui/modifier.py` says what to do about it: supply a zero-argument
+ * function returning `Modifier` and register its name with `install(...)`. KSP binds this one as
+ * `fixture.notebook.emptyModifier`; `NotebookHost` registers it.
+ */
+fun emptyModifier(): Modifier = Modifier
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/InputMethodScene.kt b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/InputMethodScene.kt
new file mode 100644
index 000000000..5f85afb3c
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/InputMethodScene.kt
@@ -0,0 +1,127 @@
+@file:OptIn(InternalComposeUiApi::class, ExperimentalComposeUiApi::class)
+
+package fixture.notebook
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.InternalComposeUiApi
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.asComposeCanvas
+import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.platform.PlatformContext
+import androidx.compose.ui.platform.PlatformTextInputMethodRequest
+import androidx.compose.ui.platform.WindowInfo
+import androidx.compose.ui.scene.CanvasLayersComposeScene
+import androidx.compose.ui.scene.ComposeScene
+import androidx.compose.ui.text.input.TextEditingScope
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.awaitCancellation
+import org.jetbrains.skia.Image
+import org.jetbrains.skia.Surface
+
+/**
+ * An offscreen scene that, unlike `ImageComposeScene`, lets a test act as the **input method**.
+ *
+ * ### Why not `ImageComposeScene`
+ *
+ * In Compose Multiplatform 1.11.1 `ImageComposeScene` builds its `ComposeScene` with
+ * `CanvasLayersComposeScene(..., platformContext = )` (read from
+ * `ImageComposeScene.class` and `ImageComposeScene$_platformContext$1.class` in `ui-desktop-1.11.1.jar`).
+ * `PlatformContext.Empty` never hands out the `PlatformTextInputMethodRequest` a focused text field
+ * starts an input session with, and `ImageComposeScene` has no parameter to replace the context. Its
+ * public API offers `sendKeyEvent` only -- key presses, not input-method composition.
+ *
+ * ### What this does instead
+ *
+ * The same `CanvasLayersComposeScene` `ImageComposeScene` wraps (`@InternalComposeUiApi`), drawn into a
+ * Skia raster surface exactly as `ImageComposeScene.render` does, with a [PlatformContext] whose
+ * `startInputMethod` records the request a focused field starts. [inputMethodEvent] then delivers an
+ * input-method event the way the desktop window does: `InputMethodSession.inputMethodTextChanged` in
+ * the same jar turns an AWT `InputMethodEvent` into one `request.editText { ... }` call that does
+ * `commitText(committed, 1)` and then, if there is composing text, `setComposingText(composing, 1)`.
+ * That is reproduced here call for call. What is *not* exercised is the AWT layer above it (decoding an
+ * `InputMethodEvent`'s attributed text into those two strings), which needs a window.
+ */
+internal class InputMethodScene(
+ private val width: Int,
+ private val height: Int,
+ content: @Composable () -> Unit,
+) : AutoCloseable {
+
+ private val requests = ArrayList()
+
+ /** The input session a focused text field started, if one is open. */
+ val activeRequest: PlatformTextInputMethodRequest? get() = requests.lastOrNull()
+
+ private val focusedWindow = object : WindowInfo {
+ override val isWindowFocused: Boolean get() = true
+ override val containerSize: IntSize get() = IntSize(width, height)
+ }
+
+ private val context = object : PlatformContext by PlatformContext.Empty() {
+ override val windowInfo: WindowInfo get() = focusedWindow
+
+ override suspend fun startInputMethod(request: PlatformTextInputMethodRequest): Nothing {
+ requests += request
+ try {
+ awaitCancellation()
+ } finally {
+ requests -= request
+ }
+ }
+ }
+
+ val scene: ComposeScene = CanvasLayersComposeScene(
+ Density(1f),
+ LayoutDirection.Ltr,
+ IntSize(width, height),
+ Dispatchers.Unconfined,
+ context,
+ )
+
+ private val surface = Surface.makeRasterN32Premul(width, height)
+
+ init {
+ scene.setContent(content)
+ }
+
+ /** `ImageComposeScene.render()`'s body: clear, draw the scene at time zero, snapshot. */
+ fun render(): Image {
+ surface.canvas.clear(0)
+ scene.render(surface.canvas.asComposeCanvas(), 0L)
+ return surface.makeImageSnapshot()
+ }
+
+ /** A primary-button click at [at], as `ImageComposeScene.sendPointerEvent` delivers one. */
+ fun click(at: Offset) {
+ scene.sendPointerEvent(PointerEventType.Move, at)
+ scene.sendPointerEvent(PointerEventType.Press, at)
+ scene.sendPointerEvent(PointerEventType.Release, at)
+ }
+
+ /** Keyboard-style focus traversal into the first focusable, the fallback when a click did not focus. */
+ fun focusFirst(): Boolean = scene.focusManager.takeFocus(FocusDirection.Next)
+
+ /**
+ * One input-method event: [committed] text committed and [composing] text left in composition --
+ * the two strings the desktop window reads from an AWT `InputMethodEvent`.
+ */
+ fun inputMethodEvent(committed: String, composing: String) {
+ val request = checkNotNull(activeRequest) { "no text input session is open: the field is not focused" }
+ val edit = checkNotNull(request.editText) { "the input session offers no editText" }
+ val event: TextEditingScope.() -> Unit = {
+ commitText(committed, 1)
+ if (composing.isNotEmpty()) setComposingText(composing, 1)
+ }
+ edit(event)
+ }
+
+ override fun close() {
+ scene.close()
+ surface.close()
+ }
+}
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookE2eMain.kt b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookE2eMain.kt
new file mode 100644
index 000000000..27693f071
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookE2eMain.kt
@@ -0,0 +1,74 @@
+package fixture.notebook
+
+import java.io.File
+import kotlin.system.exitProcess
+
+/**
+ * The notebook E2E as one executable (issue #249, #191's first milestone): the same 20 scenarios the
+ * `desktopTest` suite runs, called directly, so they can run inside a GraalVM native image, where
+ * there is no JUnit and no reflection to find a test with.
+ *
+ * Each scenario is its class's `@BeforeTest` (`host()`) and then the test method, exactly what JUnit
+ * does for these classes (none has an `@AfterTest`). A scenario passes when it returns and fails when
+ * it throws; the summary line is what `notebookE2eNativeRun` checks, and the exit status is non-zero
+ * on any failure.
+ *
+ * The wheel's `site-packages` and the application directory are the system properties the suite
+ * reads (`NotebookHost`); when they are not set -- a native image started by hand -- they are
+ * `site-packages/` and `app/` beside the executable, where the build stages them.
+ */
+fun main() {
+ // The fork's switch (compose-multiplatform-core-extended `ExtendedMainDispatcher`): Compose's
+ // main-thread work goes to `Dispatchers.Main`, which `NotebookE2eMainDispatcherFactory` supplies
+ // with no AWT, instead of skiko's Swing event queue. Read once, before Compose starts; upstream
+ // Compose ignores it.
+ System.setProperty("compose.main.dispatcher", "coroutines")
+ besideTheExecutable("notebookE2e.sitePackages", "site-packages")
+ besideTheExecutable("notebookE2e.appDir", "app")
+
+ val scenarios: List Unit>> = listOf(
+ "cell05_theImportsResolveFromTheInstalledWheel" to { NotebookRootTest().run { host(); cell05_theImportsResolveFromTheInstalledWheel() } },
+ "cell06to07_importingMainDrawsTheAppItDeclares" to { NotebookRootTest().run { host(); cell06to07_importingMainDrawsTheAppItDeclares() } },
+ "cell09to13_theNotebookAndTheScreenShareOneState" to { NotebookRootTest().run { host(); cell09to13_theNotebookAndTheScreenShareOneState() } },
+ "cell15to16_redeclaringTheRootChangesTheScreenWithNoUpdateCall" to { NotebookRootTest().run { host(); cell15to16_redeclaringTheRootChangesTheScreenWithNoUpdateCall() } },
+ "negative_withAppStubbedOutTheRedeclarationDoesNotReachTheScreen" to { NotebookRootTest().run { host(); negative_withAppStubbedOutTheRedeclarationDoesNotReachTheScreen() } },
+ "negative_aHostHandedTheRootOnceDoesNotFollowARedeclaration" to { NotebookRootTest().run { host(); negative_aHostHandedTheRootOnceDoesNotFollowARedeclaration() } },
+ "cell19_text" to { NotebookPracticeTest().run { host(); cell19_text() } },
+ "cell20_textWithAFontSize" to { NotebookPracticeTest().run { host(); cell20_textWithAFontSize() } },
+ "cell22_anEmptyBlackButton" to { NotebookPracticeTest().run { host(); cell22_anEmptyBlackButton() } },
+ "cell23_aButtonHoldingText" to { NotebookPracticeTest().run { host(); cell23_aButtonHoldingText() } },
+ "cell25_aCard" to { NotebookPracticeTest().run { host(); cell25_aCard() } },
+ "cell28_defaultIconsAddIsAnImageVector" to { NotebookPracticeTest().run { host(); cell28_defaultIconsAddIsAnImageVector() } },
+ "cell29_anIcon" to { NotebookPracticeTest().run { host(); cell29_anIcon() } },
+ "cell30_anIconInsideAButton" to { NotebookPracticeTest().run { host(); cell30_anIconInsideAButton() } },
+ "cell35_aColumnOfTwoButtons" to { NotebookPracticeTest().run { host(); cell35_aColumnOfTwoButtons() } },
+ "cell36_aColumnAlignedToTheEnd" to { NotebookPracticeTest().run { host(); cell36_aColumnAlignedToTheEnd() } },
+ "cell37_aRowWithAnArrangement" to { NotebookPracticeTest().run { host(); cell37_aRowWithAnArrangement() } },
+ "cell38_aRow" to { NotebookPracticeTest().run { host(); cell38_aRow() } },
+ "cell40_aCardSpacedBySpacers" to { NotebookPracticeTest().run { host(); cell40_aCardSpacedBySpacers() } },
+ "cell32to33_aTextFieldKeepsInputMethodCompositionInComposeAndTheNotebookReadsTheResult" to {
+ NotebookTextFieldTest().run { host(); cell32to33_aTextFieldKeepsInputMethodCompositionInComposeAndTheNotebookReadsTheResult() }
+ },
+ )
+
+ var failed = 0
+ for ((name, scenario) in scenarios) {
+ try {
+ scenario()
+ println("PASS $name")
+ } catch (t: Throwable) {
+ failed++
+ println("FAIL $name: $t")
+ t.printStackTrace(System.out)
+ }
+ }
+ println("NOTEBOOK_E2E: ${scenarios.size - failed} passed, $failed failed of ${scenarios.size}")
+ System.out.flush()
+ exitProcess(if (failed == 0) 0 else 1)
+}
+
+private fun besideTheExecutable(property: String, directory: String) {
+ if (System.getProperty(property) != null) return
+ val executable = ProcessHandle.current().info().command().orElse(null) ?: return
+ System.setProperty(property, File(File(executable).absoluteFile.parentFile, directory).path)
+}
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookE2eMainDispatcher.kt b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookE2eMainDispatcher.kt
new file mode 100644
index 000000000..a30a81935
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookE2eMainDispatcher.kt
@@ -0,0 +1,45 @@
+package fixture.notebook
+
+import kotlinx.coroutines.InternalCoroutinesApi
+import kotlinx.coroutines.MainCoroutineDispatcher
+import kotlinx.coroutines.internal.MainDispatcherFactory
+import java.util.concurrent.Executors
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * `Dispatchers.Main` for the notebook E2E's native image (issue #249): one dedicated thread, with no
+ * AWT behind it.
+ *
+ * Compose's main-thread work (`GlobalSnapshotManager`, `postDelayed`) goes to skiko's `SwingDispatcher`
+ * on upstream Compose, which starts AWT. The fork's Compose sends it to `Dispatchers.Main` instead when
+ * `compose.main.dispatcher` is `coroutines` (`NotebookE2eMain` sets it), and this supplies that. In a native image AWT
+ * needs the JDK's `libawt*.dylib` beside the executable, and those link `libjvm`, which the
+ * milestone rules out. The scenes are offscreen, so nothing needs the event dispatch thread; what
+ * Compose needs is a single thread its snapshot work is serialised on, which is what the EDT is in
+ * the headless JVM `desktopTest` runs in.
+ *
+ * Registered only on the native image's classpath (`src/desktopTest/native-image-main/`), where its
+ * [loadPriority] wins over Swing's; `desktopTest` keeps Swing, as before.
+ */
+@OptIn(InternalCoroutinesApi::class)
+class NotebookE2eMainDispatcherFactory : MainDispatcherFactory {
+
+ override val loadPriority: Int = Int.MAX_VALUE / 2
+
+ override fun createDispatcher(allFactories: List): MainCoroutineDispatcher = NotebookE2eMain
+
+ override fun hintOnError(): String = "the notebook E2E's single-thread Main dispatcher"
+}
+
+private object NotebookE2eMain : MainCoroutineDispatcher() {
+
+ private val thread = Executors.newSingleThreadExecutor { runnable ->
+ Thread(runnable, "notebook-e2e-main").apply { isDaemon = true }
+ }
+
+ override val immediate: MainCoroutineDispatcher get() = this
+
+ override fun dispatch(context: CoroutineContext, block: Runnable) = thread.execute(block)
+
+ override fun toString(): String = "NotebookE2eMain"
+}
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookHost.kt b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookHost.kt
new file mode 100644
index 000000000..68c7609cc
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookHost.kt
@@ -0,0 +1,229 @@
+package fixture.notebook
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.unit.Density
+import org.jetbrains.skia.Bitmap
+import org.jetbrains.skia.Image
+import python.multiplatform.compose.PythonAppView
+import python.multiplatform.ffi.Python3
+import python.multiplatform.ffi.pythonx.PythonxAdapter
+import python.multiplatform.ffi.upcall.PythonProxySource
+import python.multiplatform.ffi.upcall.UpcallBootstrap
+import python.multiplatform.generated.FunctionTable
+import python.multiplatform.generated.artifacts.ArtifactTable
+import python.multiplatform.reflection.UpcallTable
+import kotlin.test.fail
+
+/**
+ * The host the notebook talks to: one interpreter, the pythonx-compose **wheel** on `sys.path`, and a
+ * scene drawing `PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")` -- the line pythonx-compose's
+ * SPEC S5.4 says a host is configured with.
+ *
+ * The interpreter and the upcall table are installed once per test JVM and never cleared: a
+ * `pythonx.compose.*` module keeps the names it resolved in its own dict, and reinstalling the table
+ * would invalidate the proxies behind them. What a notebook session would carry between cells is reset
+ * per test instead ([install] calls [reset]): a fresh `app_root` state and a fresh `import main`.
+ *
+ * Python code runs in `__main__`, as a notebook kernel's cells do.
+ */
+internal object NotebookHost {
+
+ /** `Py_eval_input`. */
+ private const val PY_EVAL_INPUT = 258
+
+ /** The upper bound on frames a change may take to reach the screen (TextFieldStateRenderTest's). */
+ const val MAX_FRAMES = 4
+
+ private var installed = false
+ private var installFailure: Throwable? = null
+
+ /** Called from every test's `@BeforeTest`. Fails -- never skips -- when the wheel is not there. */
+ fun install() {
+ installFailure?.let { throw AssertionError("the notebook host could not be installed: ${it.message}", it) }
+ if (!installed) {
+ try {
+ installOnce()
+ } catch (t: Throwable) {
+ installFailure = t
+ throw t
+ }
+ installed = true
+ }
+ reset()
+ }
+
+ private fun installOnce() {
+ System.getProperty("notebookE2e.wheelError")?.let { fail("notebook-e2e: $it") }
+ val sitePackages = System.getProperty("notebookE2e.sitePackages")
+ ?: fail("notebook-e2e: no notebookE2e.sitePackages system property; run this through Gradle's desktopTest")
+ val wheel = System.getProperty("notebookE2e.wheel") ?: "(unknown wheel)"
+ val appDir = System.getProperty("notebookE2e.appDir")
+ ?: fail("notebook-e2e: no notebookE2e.appDir system property; run this through Gradle's desktopTest")
+ if (!java.io.File(sitePackages, "pythonx/compose/runtime/__init__.py").isFile) {
+ fail("notebook-e2e: $wheel was not unpacked into $sitePackages (no pythonx/compose/runtime/__init__.py there)")
+ }
+
+ Python3.initialize(silent = true)
+ UpcallTable.clear()
+ UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)
+ check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" }
+ PythonProxySource.install()
+ PythonxAdapter.install(setOf("androidx.compose.ui.unit.Dp"))
+
+ Python3.exec(
+ """
+ import sys, os, functools
+ sys.path[0:0] = [${pyLiteral(sitePackages)}, ${pyLiteral(appDir)}]
+
+ import pythonx.compose
+ import pythonx.compose.runtime as _nb_rt
+ _nb_site = os.path.realpath(${pyLiteral(sitePackages)}) + os.sep
+ for _nb_m in (pythonx.compose, _nb_rt):
+ assert os.path.realpath(_nb_m.__file__).startswith(_nb_site), \
+ _nb_m.__name__ + ' was imported from ' + _nb_m.__file__ + ', not from the installed wheel ' + ${pyLiteral(wheel)}
+ _nb_real_app = _nb_rt.app
+
+ # `Modifier.padding(8)` starts from an empty Modifier; pythonx-compose asks the application
+ # for the Kotlin function that makes one (pythonx/compose/ui/modifier.py).
+ import pythonx.compose.ui.modifier as _nb_modifier
+ _nb_modifier.install('fixture.notebook.emptyModifier')
+
+ # A counter of every Python function that starts while it is on (sys.monitoring, every
+ # thread). The callback and both switches are C callables, so turning it on and off from
+ # Kotlin runs no Python bytecode of its own and records nothing.
+ _nb_tool = next(_i for _i in range(6) if sys.monitoring.get_tool(_i) is None)
+ sys.monitoring.use_tool_id(_nb_tool, 'notebook-e2e')
+ _nb_started = {}
+ sys.monitoring.register_callback(_nb_tool, sys.monitoring.events.PY_START, _nb_started.__setitem__)
+ _nb_monitor_on = functools.partial(sys.monitoring.set_events, _nb_tool, sys.monitoring.events.PY_START)
+ _nb_monitor_off = functools.partial(sys.monitoring.set_events, _nb_tool, 0)
+ """.trimIndent(),
+ )
+ }
+
+ /**
+ * What a fresh notebook session sees after cell 6 (`import main`): a new root state, the real `@app`
+ * (a negative test may have stubbed it), and `main` imported again so its state starts over.
+ */
+ fun reset() {
+ Python3.exec(
+ """
+ _nb_monitor_off()
+ _nb_rt.app = _nb_real_app
+ _nb_rt.__dict__.pop('app_root', None)
+ sys.modules.pop('main', None)
+ import main
+ """.trimIndent(),
+ )
+ }
+
+ /** Runs a notebook cell in `__main__`. */
+ fun cell(source: String) = Python3.exec(source.trimIndent())
+
+ fun pyStr(expression: String): String = Python3.import("__main__").getAttr("__dict__").let { globals ->
+ Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString()
+ }
+
+ fun pyInt(expression: String): Int = pyStr(expression).toInt()
+
+ /** The host: `PythonAppView` reading pythonx-compose's root state. */
+ fun hostScene(width: Int, height: Int): ImageComposeScene =
+ ImageComposeScene(width = width, height = height, density = Density(1f)) {
+ PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")
+ }
+
+ /** What [content] draws from Kotlin, the control a Python-drawn screen is compared with. */
+ fun controlPixels(width: Int, height: Int, content: @Composable () -> Unit): IntArray {
+ val scene = ImageComposeScene(width = width, height = height, density = Density(1f), content = content)
+ try {
+ return pixelsOf(scene.render())
+ } finally {
+ scene.close()
+ }
+ }
+
+ /**
+ * What a window's frame clock does on its own: deliver the snapshot's pending writes, then draw.
+ * Nothing else -- no host call -- happens between a Python write and the frame that shows it.
+ */
+ fun nextFrame(render: () -> Image): IntArray {
+ Snapshot.sendApplyNotifications()
+ return pixelsOf(render())
+ }
+
+ /** Up to [MAX_FRAMES] frames, stopping at the first one [done] accepts. Returns it and the count. */
+ fun framesUntil(render: () -> Image, done: (IntArray) -> Boolean): Pair {
+ var frames = 0
+ var last: IntArray
+ do {
+ last = nextFrame(render)
+ frames++
+ } while (frames < MAX_FRAMES && !done(last))
+ return last to frames
+ }
+
+ /** One redeclaration observed: the screen before the cell, after it, and what Kotlin draws for it. */
+ class Redeclaration(val before: IntArray, val after: IntArray, val frames: Int, val expected: IntArray) {
+ val matchesExpected: Boolean get() = after.contentEquals(expected)
+ val changedPixels: Int get() = differing(before, after)
+ val pixelsOffExpected: Int get() = differing(after, expected)
+
+ fun describe(): String =
+ "after $frames frame(s) $changedPixels pixels changed and $pixelsOffExpected differ from the Kotlin control " +
+ "(ink before ${inkOf(before)}, after ${inkOf(after)}, control ${inkOf(expected)})"
+ }
+
+ /**
+ * Runs [cellSource] -- a cell that redeclares the root -- against a live [scene], then lets up to
+ * [MAX_FRAMES] frames pass. The positive tests assert [Redeclaration.matchesExpected]; the
+ * negative ones disable the wiring and assert that this same observation reports no change.
+ */
+ fun redeclare(scene: ImageComposeScene, cellSource: String, expected: IntArray): Redeclaration {
+ val before = pixelsOf(scene.render())
+ cell(cellSource)
+ val (after, frames) = framesUntil({ scene.render() }) { it.contentEquals(expected) }
+ return Redeclaration(before, after, frames, expected)
+ }
+
+ /**
+ * Runs [block] with the Python-function counter on, and returns what started: one line per code
+ * object (`qualname file:line`). Empty means no Python function ran.
+ */
+ fun pythonFunctionsStartedDuring(block: () -> Unit): List {
+ cell("_nb_started.clear()")
+ val main = Python3.import("__main__")
+ val on = main.getAttr("_nb_monitor_on")
+ val off = main.getAttr("_nb_monitor_off")
+ on().close()
+ try {
+ block()
+ } finally {
+ off().close()
+ on.close()
+ off.close()
+ }
+ val count = pyInt("len(_nb_started)")
+ if (count == 0) return emptyList()
+ return pyStr(
+ "'\\n'.join(sorted('%s %s:%d' % (_c.co_qualname, _c.co_filename, _c.co_firstlineno) for _c in _nb_started))",
+ ).lines()
+ }
+
+ fun pixelsOf(image: Image): IntArray {
+ val bitmap = Bitmap.makeFromImage(image)
+ return IntArray(bitmap.width * bitmap.height) { bitmap.getColor(it % bitmap.width, it / bitmap.width) }
+ }
+
+ fun inkOf(pixels: IntArray): Int = pixels.count { it != 0 }
+
+ fun differing(a: IntArray, b: IntArray): Int {
+ check(a.size == b.size) { "frames of different sizes: ${a.size} vs ${b.size}" }
+ return a.indices.count { a[it] != b[it] }
+ }
+
+ /** A Python string literal for [s]. */
+ private fun pyLiteral(s: String): String =
+ "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"
+}
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookPracticeTest.kt b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookPracticeTest.kt
new file mode 100644
index 000000000..2076c805f
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookPracticeTest.kt
@@ -0,0 +1,402 @@
+package fixture.notebook
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardColors
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import fixture.notebook.NotebookHost.cell
+import fixture.notebook.NotebookHost.inkOf
+import fixture.notebook.NotebookRootTest.Companion.CELL_05
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+/**
+ * The notebook's "UI 실습" section (cells 18-40): each practice cell redeclares the root, and the screen
+ * must then show exactly what the same composables draw from Kotlin. One test per cell, so a regression
+ * fails under the cell's number.
+ *
+ * Every cell is written in the spellings pythonx-compose decided, which differ from the notebook's text
+ * (pythonx-compose INTENT 2.4 and 5): `@app` instead of `main.App.update(...)`, Kotlin's own parameters
+ * snake-cased (`on_click`, `image_vector`, `tint`, `content_description`), `Color(0xFF...)` instead of a
+ * raw integer, `TextUnit(30, TextUnitType.Sp)` for a font size (a `TextUnit` refuses a raw number),
+ * `ButtonColors(...)` / `CardColors(...)` -- Kotlin's own classes -- for the notebook's `color=`,
+ * `Spacer(modifier=Modifier.padding(...))` instead of `Spacer(top=...)`, and `DefaultIcons.Add` read
+ * without parentheses. What the notebook writes and Kotlin has no counterpart for is listed in this
+ * module's README under "Gaps" and is left out of the cell rather than imitated.
+ */
+class NotebookPracticeTest {
+
+ @BeforeTest
+ fun host() = NotebookHost.install()
+
+ /** Cell 19: a red `Text`. */
+ @Test
+ fun cell19_text() = assertCellDraws(
+ "19",
+ """
+ from pythonx.compose.ui.graphics import Color
+
+ @app
+ @Composable
+ def Practice():
+ Text("나는 그냥 텍스트랍니다.", color=Color(0xFFFF0000))
+ """,
+ ) {
+ Text("나는 그냥 텍스트랍니다.", color = Color(0xFFFF0000))
+ }
+
+ /** Cell 20: the same, at `font_size=30`. */
+ @Test
+ fun cell20_textWithAFontSize() = assertCellDraws(
+ "20",
+ """
+ from pythonx.compose.ui.graphics import Color
+ from pythonx.compose.ui.unit import TextUnit, TextUnitType
+
+ @app
+ @Composable
+ def Practice():
+ Text("나는 그냥 텍스트랍니다. 좀 더 커져봤어요.", color=Color(0xFFFF0000), font_size=TextUnit(30, TextUnitType.Sp))
+ """,
+ ) {
+ Text("나는 그냥 텍스트랍니다. 좀 더 커져봤어요.", color = Color(0xFFFF0000), fontSize = 30.sp)
+ }
+
+ /** Cell 22: a black `Button` with nothing inside. (`corner_radius=50` is a gap, see the README.) */
+ @Test
+ fun cell22_anEmptyBlackButton() = assertCellDraws(
+ "22",
+ """
+ from pythonx.compose.ui.graphics import Color
+ from pythonx.compose.material3 import ButtonColors
+
+ @app
+ @Composable
+ def Practice(): # 내부 구성요소가 없는 버튼
+ Button(
+ on_click=lambda: None,
+ colors=ButtonColors(container_color=Color(0xFF000000), content_color=Color(0xFFFFFFFF),
+ disabled_container_color=Color(0xFF000000), disabled_content_color=Color(0xFFFFFFFF)),
+ content=lambda: None,
+ )
+ """,
+ ) {
+ Button(onClick = {}, colors = blackButton()) {}
+ }
+
+ /** Cell 23: the same button, holding cell 20's text. */
+ @Test
+ fun cell23_aButtonHoldingText() = assertCellDraws(
+ "23",
+ """
+ from pythonx.compose.ui.graphics import Color
+ from pythonx.compose.ui.unit import TextUnit, TextUnitType
+ from pythonx.compose.material3 import ButtonColors
+
+ @app
+ @Composable
+ def Practice(): # 텍스트가 하나 있는 버튼
+ Button(
+ on_click=lambda: None,
+ colors=ButtonColors(container_color=Color(0xFF000000), content_color=Color(0xFFFFFFFF),
+ disabled_container_color=Color(0xFF000000), disabled_content_color=Color(0xFFFFFFFF)),
+ content=lambda: Text("나는 그냥 텍스트랍니다. 좀 더 커져봤어요.", color=Color(0xFFFF0000), font_size=TextUnit(30, TextUnitType.Sp))
+ )
+ """,
+ ) {
+ Button(onClick = {}, colors = blackButton()) {
+ Text("나는 그냥 텍스트랍니다. 좀 더 커져봤어요.", color = Color(0xFFFF0000), fontSize = 30.sp)
+ }
+ }
+
+ /** Cell 25: a red `Card` with white text. (`modifier=modifier` and `corner_radius=20` are gaps.) */
+ @Test
+ fun cell25_aCard() = assertCellDraws(
+ "25",
+ """
+ from pythonx.compose.ui.graphics import Color
+ from pythonx.compose.material3 import CardColors
+
+ @app
+ @Composable
+ def Practice():
+ Card(
+ colors=CardColors(container_color=Color(0xFFFF0000), content_color=Color(0xFFFFFFFF),
+ disabled_container_color=Color(0xFFFF0000), disabled_content_color=Color(0xFFFFFFFF)),
+ content=lambda: Text("나는 그냥 텍스트랍니다.", color=Color(0xFFFFFFFF))
+ )
+ """,
+ ) {
+ Card(colors = CardColors(Color(0xFFFF0000), Color(0xFFFFFFFF), Color(0xFFFF0000), Color(0xFFFFFFFF))) {
+ Text("나는 그냥 텍스트랍니다.", color = Color(0xFFFFFFFF))
+ }
+ }
+
+ /** Cell 28: `DefaultIcons.Add` is Compose's `ImageVector` -- read, not called. */
+ @Test
+ fun cell28_defaultIconsAddIsAnImageVector() {
+ cell(CELL_05)
+ cell(
+ """
+ _nb_icon = DefaultIcons.Add
+ assert type(_nb_icon)._kotlin_type_name == 'androidx.compose.ui.graphics.vector.ImageVector', type(_nb_icon)
+ """,
+ )
+ }
+
+ /** Cell 29: a red `Add` icon. The notebook's `icon=`/`color=` are Kotlin's `image_vector=`/`tint=`. */
+ @Test
+ fun cell29_anIcon() = assertCellDraws(
+ "29",
+ """
+ from pythonx.compose.ui.graphics import Color
+
+ @app
+ @Composable
+ def Practice(): # 아이콘
+ Icon(
+ image_vector=DefaultIcons.Add,
+ content_description=None,
+ tint=Color(0xFFFF0000)
+ )
+ """,
+ ) {
+ Icon(Icons.Default.Add, contentDescription = null, tint = Color(0xFFFF0000))
+ }
+
+ /** Cell 30: a yellow `Add` icon inside a black button. */
+ @Test
+ fun cell30_anIconInsideAButton() = assertCellDraws(
+ "30",
+ """
+ from pythonx.compose.ui.graphics import Color
+ from pythonx.compose.material3 import ButtonColors
+
+ @app
+ @Composable
+ def Practice(): # 버튼 안에 아이콘
+ Button(
+ on_click=lambda: None,
+ colors=ButtonColors(container_color=Color(0xFF000000), content_color=Color(0xFFFFFFFF),
+ disabled_container_color=Color(0xFF000000), disabled_content_color=Color(0xFFFFFFFF)),
+ content=lambda: {
+ Icon(
+ image_vector=DefaultIcons.Add,
+ content_description=None,
+ tint=Color(0xFFFFFF00)
+ )
+ }
+ )
+ """,
+ ) {
+ Button(onClick = {}, colors = blackButton()) {
+ Icon(Icons.Default.Add, contentDescription = null, tint = Color(0xFFFFFF00))
+ }
+ }
+
+ /** Cell 35: a `Column` of a small and a large button. */
+ @Test
+ fun cell35_aColumnOfTwoButtons() = assertCellDraws(
+ "35",
+ """
+ from pythonx.compose.ui.graphics import Color
+ from pythonx.compose.ui.unit import TextUnit, TextUnitType
+ from pythonx.compose.material3 import ButtonColors
+
+ def _black():
+ return ButtonColors(container_color=Color(0xFF000000), content_color=Color(0xFFFFFFFF),
+ disabled_container_color=Color(0xFF000000), disabled_content_color=Color(0xFFFFFFFF))
+
+ @app
+ @Composable
+ def Practice(): # 세로 정렬
+ Column(
+ content=lambda: {
+ Button(
+ on_click=lambda: None,
+ colors=_black(),
+ content=lambda: Text("작은 버튼", color=Color(0xFFFFFFFF), font_size=TextUnit(10, TextUnitType.Sp))
+ ),
+ Button(
+ on_click=lambda: None,
+ colors=_black(),
+ content=lambda: Text("큰 버튼", color=Color(0xFFFFFFFF), font_size=TextUnit(30, TextUnitType.Sp))
+ )
+ }
+ )
+ """,
+ height = 200,
+ ) {
+ Column {
+ Button(onClick = {}, colors = blackButton()) { Text("작은 버튼", color = Color(0xFFFFFFFF), fontSize = 10.sp) }
+ Button(onClick = {}, colors = blackButton()) { Text("큰 버튼", color = Color(0xFFFFFFFF), fontSize = 30.sp) }
+ }
+ }
+
+ /** Cell 36: a right-aligned `Column`, in the notebook's grouped spelling `Alignment.Horizontal.End`. */
+ @Test
+ fun cell36_aColumnAlignedToTheEnd() = assertCellDraws(
+ "36",
+ """
+ from pythonx.compose.ui.graphics import Color
+ from pythonx.compose.ui.unit import TextUnit, TextUnitType
+
+ @app
+ @Composable
+ def Practice(): # 세로 정렬 + 오른쪽 정렬
+ Column(
+ horizontal_alignment=Alignment.Horizontal.End,
+ content=lambda: {
+ Text("작은 글자", color=Color(0xFF000000), font_size=TextUnit(10, TextUnitType.Sp)),
+ Text("큰 글자", color=Color(0xFF000000), font_size=TextUnit(30, TextUnitType.Sp))
+ }
+ )
+ """,
+ ) {
+ Column(horizontalAlignment = Alignment.End) {
+ Text("작은 글자", color = Color(0xFF000000), fontSize = 10.sp)
+ Text("큰 글자", color = Color(0xFF000000), fontSize = 30.sp)
+ }
+ }
+
+ /**
+ * Cell 37's signature: the notebook documents `Row(horizontal_arrangement=Arrangement.Center ...)`;
+ * no code cell passes one, so this is the one Arrangement scenario the notebook states. The row is
+ * given a width so that centring is visible.
+ */
+ @Test
+ fun cell37_aRowWithAnArrangement() = assertCellDraws(
+ "37",
+ """
+ @app
+ @Composable
+ def Practice():
+ Row(
+ modifier=Modifier.width(300),
+ horizontal_arrangement=Arrangement.Center,
+ content=lambda: {
+ Text("작은 글자"),
+ Text("큰 글자")
+ }
+ )
+ """,
+ ) {
+ Row(modifier = Modifier.width(300.dp), horizontalArrangement = Arrangement.Center) {
+ Text("작은 글자")
+ Text("큰 글자")
+ }
+ }
+
+ /** Cell 38: a `Row` of a small and a large text. */
+ @Test
+ fun cell38_aRow() = assertCellDraws(
+ "38",
+ """
+ from pythonx.compose.ui.graphics import Color
+ from pythonx.compose.ui.unit import TextUnit, TextUnitType
+
+ @app
+ @Composable
+ def Practice(): # 가로 정렬
+ Row(
+ content=lambda: {
+ Text("작은 글자", color=Color(0xFF000000), font_size=TextUnit(10, TextUnitType.Sp)),
+ Text("큰 글자", color=Color(0xFF000000), font_size=TextUnit(30, TextUnitType.Sp))
+ }
+ )
+ """,
+ ) {
+ Row {
+ Text("작은 글자", color = Color(0xFF000000), fontSize = 10.sp)
+ Text("큰 글자", color = Color(0xFF000000), fontSize = 30.sp)
+ }
+ }
+
+ /** Cell 40: cell 25's card again, its text spaced by `Spacer`s -- `Spacer(modifier=Modifier.padding(...))`. */
+ @Test
+ fun cell40_aCardSpacedBySpacers() = assertCellDraws(
+ "40",
+ """
+ from pythonx.compose.ui.graphics import Color
+ from pythonx.compose.material3 import CardColors
+
+ @app
+ @Composable
+ def Practice(): # 아까 카드에 여백 넣어보기
+ Card(
+ colors=CardColors(container_color=Color(0xFF000000), content_color=Color(0xFFFFFFFF),
+ disabled_container_color=Color(0xFF000000), disabled_content_color=Color(0xFFFFFFFF)),
+ content=lambda: {
+ Column(
+ content=lambda: {
+ Spacer(modifier=Modifier.padding(top=10)),
+ Row(
+ content=lambda: {
+ Spacer(modifier=Modifier.padding(start=20)),
+ Text("나는 그냥 텍스트랍니다.", color=Color(0xFFFFFFFF)),
+ Spacer(modifier=Modifier.padding(end=20))
+ }
+ ),
+ Spacer(modifier=Modifier.padding(bottom=10))
+ }
+ )
+ }
+ )
+ """,
+ ) {
+ Card(colors = CardColors(Color(0xFF000000), Color(0xFFFFFFFF), Color(0xFF000000), Color(0xFFFFFFFF))) {
+ Column {
+ Spacer(modifier = Modifier.padding(top = 10.dp))
+ Row {
+ Spacer(modifier = Modifier.padding(start = 20.dp))
+ Text("나는 그냥 텍스트랍니다.", color = Color(0xFFFFFFFF))
+ Spacer(modifier = Modifier.padding(end = 20.dp))
+ }
+ Spacer(modifier = Modifier.padding(bottom = 10.dp))
+ }
+ }
+ }
+
+ /**
+ * Runs cell 5 and then [source] against the live host, and requires the screen to equal [control]
+ * within [NotebookHost.MAX_FRAMES] frames.
+ */
+ private fun assertCellDraws(
+ cellNumber: String,
+ source: String,
+ height: Int = NotebookRootTest.H,
+ control: @Composable () -> Unit,
+ ) {
+ val width = NotebookRootTest.W
+ val expected = NotebookHost.controlPixels(width, height, control)
+ assertTrue(inkOf(expected) > 0, "cell $cellNumber: the Kotlin control drew nothing, so the comparison measures nothing")
+ val scene = NotebookHost.hostScene(width, height)
+ try {
+ scene.render()
+ val seen = NotebookHost.redeclare(scene, CELL_05 + source.trimIndent(), expected)
+ println("notebook cell $cellNumber: ${seen.describe()}")
+ assertTrue(seen.matchesExpected, "cell $cellNumber: the screen is not what the cell declares: ${seen.describe()}")
+ } finally {
+ scene.close()
+ }
+ }
+}
+
+private fun blackButton() = ButtonColors(Color(0xFF000000), Color(0xFFFFFFFF), Color(0xFF000000), Color(0xFFFFFFFF))
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookRootTest.kt b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookRootTest.kt
new file mode 100644
index 000000000..9a2994439
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookRootTest.kt
@@ -0,0 +1,236 @@
+package fixture.notebook
+
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Density
+import fixture.notebook.NotebookHost.cell
+import fixture.notebook.NotebookHost.differing
+import fixture.notebook.NotebookHost.framesUntil
+import fixture.notebook.NotebookHost.inkOf
+import fixture.notebook.NotebookHost.pixelsOf
+import fixture.notebook.NotebookHost.pyInt
+import fixture.notebook.NotebookHost.pyStr
+import python.multiplatform.compose.PythonAppView
+import python.multiplatform.compose.PythonWidget
+import python.multiplatform.ffi.Python3
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * The notebook's application-level scenarios (`UI.ipynb` cells 5-16): importing the app draws it, the
+ * notebook and the screen share state, and **redeclaring the root changes the screen with no update
+ * call** -- plus the negative checks that show the last one can fail.
+ *
+ * Every screen is compared pixel for pixel with the same composables drawn from Kotlin, so "the screen
+ * changed" means "the screen now shows what the cell declared", not merely "something moved".
+ */
+class NotebookRootTest {
+
+ @BeforeTest
+ fun host() = NotebookHost.install()
+
+ /**
+ * Cell 5's imports, in the spellings pythonx-compose decided (its INTENT section 5): everything the
+ * notebook imports that has a decided counterpart resolves against the installed wheel. The names it
+ * leaves out are the gaps in this module's README (`remember_saveable`, the coroutine scopes, the
+ * lower-case `modifier`).
+ */
+ @Test
+ fun cell05_theImportsResolveFromTheInstalledWheel() {
+ cell(CELL_05)
+ cell(
+ """
+ import pythonx.compose.material3 as _nb_m3
+ assert DefaultIcons is not None and Icon is not None and TextField is not None
+ assert Column is _nb_m3.Column and Row is _nb_m3.Row and Spacer is _nb_m3.Spacer
+ assert Modifier is not None and Alignment is not None and Arrangement is not None
+ assert app is _nb_real_app and Composable(len) is len
+ """,
+ )
+ }
+
+ /**
+ * Cells 6-7: `import main` declares the app, and the host -- configured once with
+ * `PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")` -- draws it. `main.App` is the root on
+ * screen. An idle frame does not run it again: nothing polls.
+ */
+ @Test
+ fun cell06to07_importingMainDrawsTheAppItDeclares() {
+ val expected = NotebookHost.controlPixels(W, H) { Text(INITIAL_MESSAGE) }
+ val scene = NotebookHost.hostScene(W, H)
+ try {
+ val first = pixelsOf(scene.render())
+ assertEquals("True", pyStr("main.App is _nb_rt.app_root.value"), "cell 7: main.App is not the declared root")
+ assertEquals(1, pyInt("main.calls['App']"), "the declared root was not composed exactly once")
+ assertTrue(inkOf(expected) > 0, "the Kotlin control drew nothing, so the comparison measures nothing")
+ assertEquals(0, differing(first, expected), "main.App did not draw what Text(INITIAL_MESSAGE) draws from Kotlin")
+
+ scene.render()
+ assertEquals(1, pyInt("main.calls['App']"), "an idle frame ran the root again, so something polls")
+ } finally {
+ scene.close()
+ }
+ }
+
+ /**
+ * Cells 9-13: the notebook reads the state the screen shows (cell 10), writes it (cell 12) and the
+ * screen follows, then restores it (cell 13) and the screen is back -- with no call into the host.
+ * The root runs again because it read the state (a recomposition), not because anything was
+ * redeclared. The other direction, screen to notebook, is `NotebookTextFieldTest`.
+ */
+ @Test
+ fun cell09to13_theNotebookAndTheScreenShareOneState() {
+ val initial = NotebookHost.controlPixels(W, H) { Text(INITIAL_MESSAGE) }
+ val changed = NotebookHost.controlPixels(W, H) { Text(CHANGED_MESSAGE) }
+ val scene = NotebookHost.hostScene(W, H)
+ try {
+ assertEquals(0, differing(pixelsOf(scene.render()), initial), "the app's first frame is not its initial message")
+
+ // Cell 9: the state the root remembered is reachable as main.App.messages.
+ assertEquals("True", pyStr("hasattr(main.App, 'messages')"), "cell 9: main.App.messages does not exist after the first frame")
+ // Cell 10.
+ cell("message_backup = main.App.messages.getValue()")
+ assertEquals("", pyStr("type(message_backup)"), "the state did not read back as a str")
+ assertEquals(INITIAL_MESSAGE, pyStr("message_backup"), "cell 10 read something other than what is on screen")
+
+ // Cell 12.
+ cell("main.App.messages.setValue('$CHANGED_MESSAGE')")
+ val (afterWrite, writeFrames) = framesUntil({ scene.render() }) { it.contentEquals(changed) }
+ assertEquals(
+ 0, differing(afterWrite, changed),
+ "cell 12: after $writeFrames frame(s) the screen does not show the message the notebook wrote",
+ )
+ assertEquals(2, pyInt("main.calls['App']"), "the write did not recompose the root exactly once")
+
+ // Cell 13.
+ cell("main.App.messages.setValue(message_backup) # 복원")
+ val (afterRestore, restoreFrames) = framesUntil({ scene.render() }) { it.contentEquals(initial) }
+ assertEquals(
+ 0, differing(afterRestore, initial),
+ "cell 13: after $restoreFrames frame(s) the screen does not show the restored message",
+ )
+ assertEquals(INITIAL_MESSAGE, pyStr("main.App.messages.getValue()"))
+ assertEquals(INITIAL_MESSAGE, pyStr("main.App.messages.value"), ".value is not an alias of getValue()")
+ } finally {
+ scene.close()
+ }
+ }
+
+ /**
+ * Cells 15-16, the scenario issue #26 is about: a cell redeclares the root and the screen shows it.
+ * The notebook's `main.App.update(NewUI)` is gone (pythonx-compose INTENT 5.1); `@app` on the new
+ * function is the whole of it. Between the cell and the frame the test does only what a frame clock
+ * does ([NotebookHost.nextFrame]).
+ */
+ @Test
+ fun cell15to16_redeclaringTheRootChangesTheScreenWithNoUpdateCall() {
+ val expected = NotebookHost.controlPixels(W, H) { NewUiControl() }
+ val scene = NotebookHost.hostScene(W, H)
+ try {
+ scene.render()
+ val seen = NotebookHost.redeclare(scene, CELL_05 + CELL_15, expected)
+ println("notebook cell 15: ${seen.describe()}")
+ assertTrue(inkOf(expected) > 0, "the Kotlin control drew nothing")
+ assertTrue(seen.matchesExpected, "cell 15: the redeclared root is not on screen: ${seen.describe()}")
+ assertEquals("True", pyStr("_nb_rt.app_root.value is NewUI"), "@app did not make NewUI the root")
+ assertEquals(1, pyInt("main.calls['App']"), "the replaced root ran again")
+ } finally {
+ scene.close()
+ }
+ }
+
+ /**
+ * **Negative check, Python half.** `@app` replaced by a stub that returns the function without
+ * writing `app_root` -- the root-replacement wiring disabled on the pythonx side. The same
+ * observation [cell15to16_redeclaringTheRootChangesTheScreenWithNoUpdateCall] asserts on must now
+ * report that nothing changed; if it did not, that test could not fail.
+ */
+ @Test
+ fun negative_withAppStubbedOutTheRedeclarationDoesNotReachTheScreen() {
+ val expected = NotebookHost.controlPixels(W, H) { NewUiControl() }
+ val scene = NotebookHost.hostScene(W, H)
+ try {
+ scene.render()
+ cell("_nb_rt.app = lambda root: root")
+ val seen = NotebookHost.redeclare(scene, CELL_05 + CELL_15, expected)
+ println("notebook negative (stubbed @app): ${seen.describe()}")
+ assertFalse(seen.matchesExpected, "with @app stubbed out the screen still showed the redeclared root")
+ assertEquals(0, seen.changedPixels, "with @app stubbed out the screen changed anyway: ${seen.describe()}")
+ } finally {
+ scene.close()
+ }
+ }
+
+ /**
+ * **Negative check, host half.** The host given the root *function* once (`PythonWidget(root =
+ * main.App)`) instead of the state that holds it -- the root-replacement wiring disabled on the
+ * Kotlin side. A redeclaration then writes a state no composition reads, and the screen stays.
+ */
+ @Test
+ fun negative_aHostHandedTheRootOnceDoesNotFollowARedeclaration() {
+ val expected = NotebookHost.controlPixels(W, H) { NewUiControl() }
+ val fixedRoot = Python3.import("main").getAttr("App")
+ val scene = ImageComposeScene(width = W, height = H, density = Density(1f)) { PythonWidget(root = fixedRoot) }
+ try {
+ scene.render()
+ val seen = NotebookHost.redeclare(scene, CELL_05 + CELL_15, expected)
+ println("notebook negative (fixed root): ${seen.describe()}")
+ assertFalse(seen.matchesExpected, "a host that never reads app_root still showed the redeclared root")
+ assertEquals(0, seen.changedPixels, "a host that never reads app_root changed anyway: ${seen.describe()}")
+ assertEquals("True", pyStr("_nb_rt.app_root.value is NewUI"), "the redeclaration itself did not happen")
+ } finally {
+ scene.close()
+ fixedRoot.close()
+ }
+ }
+
+ internal companion object {
+ const val W = 360
+ const val H = 120
+
+ /** `main.py`'s `INITIAL_MESSAGE`. */
+ const val INITIAL_MESSAGE = "안녕하세요, Python 앱입니다."
+
+ /** Cell 12's text. */
+ const val CHANGED_MESSAGE = "UI 상태를 변경해보는 테스트입니다."
+
+ /**
+ * Cell 5 in the decided spellings: `app` beside `Composable`; no `remember_saveable`,
+ * `DefaultCoroutineScope`, `MainCoroutineScope` (undecided, pythonx-compose INTENT 4.1 and SPEC
+ * section 8) and no lower-case `modifier` (INTENT 5.4).
+ */
+ val CELL_05 = """
+ from pythonx.compose.runtime import Composable, app
+ from pythonx.compose.material3 import Icon, DefaultIcons, Text, TextField
+ from pythonx.compose.material3 import Column, Row, Button, Card, Spacer
+ from pythonx.compose.ui import Modifier, Alignment
+ from pythonx.compose.layout import Arrangement
+
+ """.trimIndent() + "\n"
+
+ /** Cells 15-16: `NewUI`, declared with `@app` instead of `main.App.update(NewUI)`; `color=` is `Color(...)` (INTENT 5.5). */
+ val CELL_15 = """
+ from pythonx.compose.ui.graphics import Color
+
+ @app
+ @Composable
+ def NewUI():
+ Button(
+ on_click=lambda: None,
+ content=lambda: {
+ Text(f"버튼이 하나만 있는 화면으로 바꿔보자", color=Color(0xFFFFFFFF))
+ }
+ )
+ """.trimIndent()
+ }
+}
+
+@androidx.compose.runtime.Composable
+private fun NewUiControl() {
+ Button(onClick = {}) { Text("버튼이 하나만 있는 화면으로 바꿔보자", color = Color(0xFFFFFFFF)) }
+}
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookTextFieldTest.kt b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookTextFieldTest.kt
new file mode 100644
index 000000000..fb417526f
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/kotlin/fixture/notebook/NotebookTextFieldTest.kt
@@ -0,0 +1,163 @@
+@file:OptIn(ExperimentalFoundationApi::class)
+
+package fixture.notebook
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.text.TextRange
+import fixture.notebook.NotebookHost.cell
+import fixture.notebook.NotebookHost.differing
+import fixture.notebook.NotebookHost.framesUntil
+import fixture.notebook.NotebookHost.inkOf
+import fixture.notebook.NotebookHost.nextFrame
+import fixture.notebook.NotebookHost.pixelsOf
+import fixture.notebook.NotebookHost.pyInt
+import fixture.notebook.NotebookHost.pyStr
+import fixture.notebook.NotebookRootTest.Companion.CELL_05
+import python.multiplatform.compose.PythonAppView
+import python.multiplatform.reflection.HandleTable
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.test.fail
+
+/**
+ * Cells 31-33: the text field. The screen writes, the notebook reads (cell 33, "화면에서 값을 변경한 후
+ * 출력해보세요" -- change it on screen, then print it).
+ *
+ * In the decided spelling (pythonx-compose INTENT 5.8, SPEC S5.5) the field is `TextField(state=...)`
+ * over a `TextFieldState` from `remember_text_field_state("")`, so Compose owns the text buffer and the
+ * input method's composing region and **nothing crosses into Python per keystroke**. This is
+ * pythonx-compose #10's completion criterion, checked here against a real composition:
+ *
+ * 1. The cell declares the field; the host draws it; a click focuses it and it opens an input session.
+ * 2. A Hangul input-method sequence -- ㅎ, 하, 한 composing, then 한 committed -- is delivered the way the
+ * desktop window delivers one ([InputMethodScene.inputMethodEvent]), a frame after each event. A
+ * counter of every Python function started ([NotebookHost.pythonFunctionsStartedDuring]) must stay at
+ * zero across all of it, and the root must not run again.
+ * 3. Kotlin's own `TextFieldState` holds the composing text with its composing range during the
+ * sequence, and the committed text with no composing range after it; the notebook then reads
+ * `field.text == "한"`.
+ * 4. Outside the composition, `field.set_text_and_place_cursor_at_end("x")` reaches the screen within
+ * [NotebookHost.MAX_FRAMES] frames.
+ */
+class NotebookTextFieldTest {
+
+ @BeforeTest
+ fun host() = NotebookHost.install()
+
+ @Test
+ fun cell32to33_aTextFieldKeepsInputMethodCompositionInComposeAndTheNotebookReadsTheResult() {
+ val scene = InputMethodScene(WIDTH, HEIGHT) {
+ PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")
+ }
+ try {
+ scene.render()
+ cell(CELL_05 + CELL_32)
+ framesUntil({ scene.render() }) { pyInt("len(fields)") > 0 }
+ assertTrue(pyInt("len(fields)") > 0, "cell 32: the redeclared Practice root never composed")
+ val state = HandleTable.resolveRaw(pyStr("int(fields[-1]._pm_handle)").toLong()) as? TextFieldState
+ ?: fail("fields[-1] is not a Kotlin TextFieldState: ${pyStr("type(fields[-1])")}")
+ assertEquals("", state.text.toString())
+
+ // 1. Focus, by a click on the field; keyboard traversal if the click did not do it.
+ scene.click(Offset(FIELD_CENTRE_X, FIELD_CENTRE_Y))
+ framesUntil({ scene.render() }) { scene.activeRequest != null }
+ var focusedBy = "click"
+ if (scene.activeRequest == null) {
+ focusedBy = "focus traversal (the click did not focus the field)"
+ scene.focusFirst()
+ framesUntil({ scene.render() }) { scene.activeRequest != null }
+ }
+ if (scene.activeRequest == null) fail("the TextField never opened a text input session, so no input method can reach it")
+
+ // 2-3. The Hangul sequence, with the Python-function counter on.
+ val practiceRuns = pyInt("practice_calls[0]")
+ val observed = ArrayList>()
+ val started = NotebookHost.pythonFunctionsStartedDuring {
+ for ((committed, composing) in HANGUL_EVENTS) {
+ scene.inputMethodEvent(committed, composing)
+ nextFrame { scene.render() }
+ observed += state.text.toString() to state.composition
+ }
+ }
+ println("notebook cell 32: focused by $focusedBy; IME states $observed; Python functions started: ${started.size}")
+ assertEquals(
+ emptyList(), started,
+ "Python ran while the input method typed into TextField(state=...); every key should stay in Compose",
+ )
+ assertEquals(practiceRuns, pyInt("practice_calls[0]"), "typing re-ran the Practice root")
+ assertEquals(
+ listOf(
+ "ㅎ" to TextRange(0, 1),
+ "하" to TextRange(0, 1),
+ "한" to TextRange(0, 1),
+ "한" to null,
+ ),
+ observed,
+ "the TextFieldState did not hold the composing text and its range, then the committed text",
+ )
+
+ // Cell 33: the notebook reads what the screen wrote.
+ assertEquals("", pyStr("type(fields[-1].text)"))
+ assertEquals("한", pyStr("fields[-1].text"), "cell 33: the notebook does not read what was typed on screen")
+ assertEquals("True", pyStr("all(_f.text == '한' for _f in fields)"), "the root saw more than one state")
+
+ // 4. Written from the notebook, outside the composition.
+ val before = pixelsOf(scene.render())
+ cell("fields[-1].set_text_and_place_cursor_at_end('x')")
+ val (after, frames) = framesUntil({ scene.render() }) { differing(before, it) > 10 }
+ println("notebook cell 32: set_text_and_place_cursor_at_end moved ${differing(before, after)} px after $frames frame(s)")
+ assertEquals("x", state.text.toString(), "the write from Python did not reach the Kotlin state")
+ assertTrue(inkOf(before) > 0, "the field drew nothing")
+ assertTrue(
+ differing(before, after) > 10,
+ "the text written from the notebook did not reach the screen within ${NotebookHost.MAX_FRAMES} frames",
+ )
+ } finally {
+ scene.close()
+ }
+ }
+
+ private companion object {
+ const val WIDTH = 320
+ const val HEIGHT = 80
+
+ /** Inside material3's `TextField` (280 x 56 by default), drawn at `padding(8)`. */
+ const val FIELD_CENTRE_X = 148f
+ const val FIELD_CENTRE_Y = 36f
+
+ /** (committed, composing) per input-method event: a Hangul IME typing 한 (ㅎ, ㅏ, ㄴ). */
+ val HANGUL_EVENTS = listOf(
+ "" to "ㅎ",
+ "" to "하",
+ "" to "한",
+ "한" to "",
+ )
+
+ /**
+ * Cell 32 in the decided spelling: `TextField(state=..., modifier=Modifier.padding(8))` over a
+ * `remember_text_field_state("")`, instead of `TextField(text_state=main.App.messages, padding=8)`.
+ * `fields` and `practice_calls` are the test's handles on what the root made and how often it ran.
+ */
+ val CELL_32 = """
+ from pythonx.compose.foundation.text.input import remember_text_field_state
+
+ fields = []
+ practice_calls = [0]
+
+ @app
+ @Composable
+ def Practice(): # 텍스트 입력란
+ practice_calls[0] += 1
+ field = remember_text_field_state("")
+ fields.append(field)
+ TextField(
+ state=field,
+ modifier=Modifier.padding(8)
+ )
+ """.trimIndent()
+ }
+}
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/native-image-main/META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/native-image-main/META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory
new file mode 100644
index 000000000..b12a346d1
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/native-image-main/META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory
@@ -0,0 +1 @@
+fixture.notebook.NotebookE2eMainDispatcherFactory
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/native-image/reachability-metadata.json b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/native-image/reachability-metadata.json
new file mode 100644
index 000000000..3439d77ec
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/native-image/reachability-metadata.json
@@ -0,0 +1,1448 @@
+{
+ "reflection": [
+ {
+ "type": "android.os.Build"
+ },
+ {
+ "type": "fixture.notebook.NotebookE2eMainDispatcherFactory",
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "java.io.OutputStream",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "flush",
+ "parameterTypes": []
+ },
+ {
+ "name": "write",
+ "parameterTypes": [
+ "byte[]",
+ "int",
+ "int"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "java.lang.Boolean",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "booleanValue",
+ "parameterTypes": []
+ },
+ {
+ "name": "getBoolean",
+ "parameterTypes": [
+ "java.lang.String"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "java.lang.Float",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "float"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "java.lang.RuntimeException",
+ "jniAccessible": true
+ },
+ {
+ "type": "java.lang.String",
+ "jniAccessible": true
+ },
+ {
+ "type": "java.lang.System",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "getProperty",
+ "parameterTypes": [
+ "java.lang.String"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "java.lang.Throwable",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "printStackTrace",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "java.lang.foreign.Arena",
+ "methods": [
+ {
+ "name": "allocateFrom",
+ "parameterTypes": [
+ "java.lang.String"
+ ]
+ },
+ {
+ "name": "global",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "java.lang.foreign.FunctionDescriptor",
+ "methods": [
+ {
+ "name": "of",
+ "parameterTypes": [
+ "java.lang.foreign.MemoryLayout",
+ "java.lang.foreign.MemoryLayout[]"
+ ]
+ },
+ {
+ "name": "ofVoid",
+ "parameterTypes": [
+ "java.lang.foreign.MemoryLayout[]"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "java.lang.foreign.Linker",
+ "methods": [
+ {
+ "name": "defaultLookup",
+ "parameterTypes": []
+ },
+ {
+ "name": "downcallHandle",
+ "parameterTypes": [
+ "java.lang.foreign.FunctionDescriptor",
+ "java.lang.foreign.Linker$Option[]"
+ ]
+ },
+ {
+ "name": "downcallHandle",
+ "parameterTypes": [
+ "java.lang.foreign.MemorySegment",
+ "java.lang.foreign.FunctionDescriptor",
+ "java.lang.foreign.Linker$Option[]"
+ ]
+ },
+ {
+ "name": "nativeLinker",
+ "parameterTypes": []
+ },
+ {
+ "name": "upcallStub",
+ "parameterTypes": [
+ "java.lang.invoke.MethodHandle",
+ "java.lang.foreign.FunctionDescriptor",
+ "java.lang.foreign.Arena",
+ "java.lang.foreign.Linker$Option[]"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "java.lang.foreign.Linker$Option"
+ },
+ {
+ "type": "java.lang.foreign.Linker$Option[]"
+ },
+ {
+ "type": "java.lang.foreign.MemoryLayout"
+ },
+ {
+ "type": "java.lang.foreign.MemoryLayout[]"
+ },
+ {
+ "type": "java.lang.foreign.MemorySegment",
+ "fields": [
+ {
+ "name": "NULL"
+ }
+ ],
+ "methods": [
+ {
+ "name": "address",
+ "parameterTypes": []
+ },
+ {
+ "name": "copy",
+ "parameterTypes": [
+ "java.lang.foreign.MemorySegment",
+ "long",
+ "java.lang.foreign.MemorySegment",
+ "long",
+ "long"
+ ]
+ },
+ {
+ "name": "getString",
+ "parameterTypes": [
+ "long"
+ ]
+ },
+ {
+ "name": "ofAddress",
+ "parameterTypes": [
+ "long"
+ ]
+ },
+ {
+ "name": "ofArray",
+ "parameterTypes": [
+ "byte[]"
+ ]
+ },
+ {
+ "name": "reinterpret",
+ "parameterTypes": [
+ "long"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "java.lang.foreign.SymbolLookup",
+ "methods": [
+ {
+ "name": "find",
+ "parameterTypes": [
+ "java.lang.String"
+ ]
+ },
+ {
+ "name": "loaderLookup",
+ "parameterTypes": []
+ },
+ {
+ "name": "or",
+ "parameterTypes": [
+ "java.lang.foreign.SymbolLookup"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "java.lang.foreign.ValueLayout",
+ "fields": [
+ {
+ "name": "ADDRESS"
+ },
+ {
+ "name": "JAVA_BYTE"
+ },
+ {
+ "name": "JAVA_DOUBLE"
+ },
+ {
+ "name": "JAVA_FLOAT"
+ },
+ {
+ "name": "JAVA_INT"
+ },
+ {
+ "name": "JAVA_LONG"
+ },
+ {
+ "name": "JAVA_SHORT"
+ }
+ ]
+ },
+ {
+ "type": "java.util.Iterator",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "hasNext",
+ "parameterTypes": []
+ },
+ {
+ "name": "next",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "kotlin.coroutines.SafeContinuation"
+ },
+ {
+ "type": "kotlin.jvm.functions.Function0",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "invoke",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "kotlin.reflect.jvm.internal.ReflectionFactoryImpl"
+ },
+ {
+ "type": "kotlinx.atomicfu.AtomicInt"
+ },
+ {
+ "type": "kotlinx.atomicfu.AtomicRef"
+ },
+ {
+ "type": "kotlinx.coroutines.CancellableContinuationImpl"
+ },
+ {
+ "type": "kotlinx.coroutines.CancelledContinuation"
+ },
+ {
+ "type": "kotlinx.coroutines.CompletedExceptionally"
+ },
+ {
+ "type": "kotlinx.coroutines.EventLoopImplBase"
+ },
+ {
+ "type": "kotlinx.coroutines.JobSupport"
+ },
+ {
+ "type": "kotlinx.coroutines.JobSupport$Finishing"
+ },
+ {
+ "type": "kotlinx.coroutines.channels.BufferedChannel"
+ },
+ {
+ "type": "kotlinx.coroutines.flow.StateFlowImpl"
+ },
+ {
+ "type": "kotlinx.coroutines.internal.ConcurrentLinkedListNode"
+ },
+ {
+ "type": "kotlinx.coroutines.internal.DispatchedContinuation"
+ },
+ {
+ "type": "kotlinx.coroutines.internal.LimitedDispatcher"
+ },
+ {
+ "type": "kotlinx.coroutines.internal.LockFreeLinkedListNode"
+ },
+ {
+ "type": "kotlinx.coroutines.internal.LockFreeTaskQueue"
+ },
+ {
+ "type": "kotlinx.coroutines.internal.LockFreeTaskQueueCore"
+ },
+ {
+ "type": "kotlinx.coroutines.internal.Segment"
+ },
+ {
+ "type": "kotlinx.coroutines.internal.ThreadSafeHeap"
+ },
+ {
+ "type": "kotlinx.coroutines.scheduling.CoroutineScheduler"
+ },
+ {
+ "type": "kotlinx.coroutines.scheduling.CoroutineScheduler$Worker"
+ },
+ {
+ "type": "kotlinx.coroutines.scheduling.WorkQueue"
+ },
+ {
+ "type": "kotlinx.coroutines.sync.MutexImpl"
+ },
+ {
+ "type": "kotlinx.coroutines.sync.SemaphoreAndMutexImpl"
+ },
+ {
+ "type": "org.jetbrains.skia.AnimationFrameInfo",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "int",
+ "int",
+ "boolean",
+ "int",
+ "boolean",
+ "int",
+ "int",
+ "org.jetbrains.skia.IRect"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.Color4f",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.Drawable",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "_onDraw",
+ "parameterTypes": [
+ "long"
+ ]
+ },
+ {
+ "name": "onGetBounds",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.FontFamilyName",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "java.lang.String",
+ "java.lang.String"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.FontFeature",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "_tag"
+ },
+ {
+ "name": "end"
+ },
+ {
+ "name": "start"
+ },
+ {
+ "name": "value"
+ }
+ ],
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "java.lang.String",
+ "int"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.FontMetrics",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "java.lang.Float",
+ "java.lang.Float",
+ "java.lang.Float",
+ "java.lang.Float"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.FontVariation",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "_tag"
+ },
+ {
+ "name": "value"
+ }
+ ],
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "int",
+ "float"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.FontVariationAxis",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "int",
+ "float",
+ "float",
+ "float",
+ "boolean"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.IPoint",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "int",
+ "int"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.IRect",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "bottom"
+ },
+ {
+ "name": "left"
+ },
+ {
+ "name": "right"
+ },
+ {
+ "name": "top"
+ }
+ ],
+ "methods": [
+ {
+ "name": "makeLTRB",
+ "parameterTypes": [
+ "int",
+ "int",
+ "int",
+ "int"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.ImageInfo",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "int",
+ "int",
+ "int",
+ "int",
+ "long"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.PaintFilterCanvas",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "onFilter",
+ "parameterTypes": [
+ "long"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.Path",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "long"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.PathSegment",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": []
+ },
+ {
+ "name": "",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "boolean"
+ ]
+ },
+ {
+ "name": "",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "boolean"
+ ]
+ },
+ {
+ "name": "",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "boolean"
+ ]
+ },
+ {
+ "name": "",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float",
+ "boolean",
+ "boolean"
+ ]
+ },
+ {
+ "name": "",
+ "parameterTypes": [
+ "int",
+ "float",
+ "float",
+ "boolean"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.Point",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "x"
+ },
+ {
+ "name": "y"
+ }
+ ],
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "float",
+ "float"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.RRect",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "radii"
+ }
+ ],
+ "methods": [
+ {
+ "name": "makeComplexLTRB",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float",
+ "float[]"
+ ]
+ },
+ {
+ "name": "makeLTRB",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float",
+ "float"
+ ]
+ },
+ {
+ "name": "makeLTRB",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float"
+ ]
+ },
+ {
+ "name": "makeLTRB",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float"
+ ]
+ },
+ {
+ "name": "makeNinePatchLTRB",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float",
+ "float"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.RSXform",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.Rect",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "bottom"
+ },
+ {
+ "name": "left"
+ },
+ {
+ "name": "right"
+ },
+ {
+ "name": "top"
+ }
+ ],
+ "methods": [
+ {
+ "name": "makeLTRB",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.impl.Native",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "_ptr"
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.paragraph.DecorationStyle",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "boolean",
+ "boolean",
+ "boolean",
+ "boolean",
+ "int",
+ "int",
+ "float"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.paragraph.LineMetrics",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "int",
+ "int",
+ "int",
+ "int",
+ "boolean",
+ "double",
+ "double",
+ "double",
+ "double",
+ "double",
+ "double",
+ "double",
+ "int"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.paragraph.LineMetrics[]"
+ },
+ {
+ "type": "org.jetbrains.skia.paragraph.Shadow",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "int",
+ "float",
+ "float",
+ "double"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.paragraph.TextBox",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "float",
+ "float",
+ "float",
+ "float",
+ "int"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.paragraph.TextBox[]"
+ },
+ {
+ "type": "org.jetbrains.skia.shaper.BidiRun",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "end"
+ },
+ {
+ "name": "level"
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.shaper.FontMgrRunIterator",
+ "jniAccessible": true
+ },
+ {
+ "type": "org.jetbrains.skia.shaper.FontRun",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "end"
+ }
+ ],
+ "methods": [
+ {
+ "name": "_getFontPtr",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.shaper.HbIcuScriptRunIterator",
+ "jniAccessible": true
+ },
+ {
+ "type": "org.jetbrains.skia.shaper.IcuBidiRunIterator",
+ "jniAccessible": true
+ },
+ {
+ "type": "org.jetbrains.skia.shaper.LanguageRun",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "end"
+ },
+ {
+ "name": "language"
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.shaper.RunHandler",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "beginLine",
+ "parameterTypes": []
+ },
+ {
+ "name": "commitLine",
+ "parameterTypes": []
+ },
+ {
+ "name": "commitRun",
+ "parameterTypes": [
+ "org.jetbrains.skia.shaper.RunInfo",
+ "short[]",
+ "org.jetbrains.skia.Point[]",
+ "int[]"
+ ]
+ },
+ {
+ "name": "commitRunInfo",
+ "parameterTypes": []
+ },
+ {
+ "name": "runInfo",
+ "parameterTypes": [
+ "org.jetbrains.skia.shaper.RunInfo"
+ ]
+ },
+ {
+ "name": "runOffset",
+ "parameterTypes": [
+ "org.jetbrains.skia.shaper.RunInfo"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.shaper.RunInfo",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "_fontPtr"
+ }
+ ],
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "long",
+ "int",
+ "float",
+ "float",
+ "int",
+ "int",
+ "int"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.shaper.ScriptRun",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "end"
+ },
+ {
+ "name": "scriptTag"
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.shaper.ShapingOptions",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "features"
+ },
+ {
+ "name": "fontMgr"
+ },
+ {
+ "name": "isApproximatePunctuation"
+ },
+ {
+ "name": "isApproximateSpaces"
+ },
+ {
+ "name": "isLeftToRight"
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.shaper.TextBlobBuilderRunHandler",
+ "jniAccessible": true
+ },
+ {
+ "type": "org.jetbrains.skia.skottie.LogLevel",
+ "jniAccessible": true,
+ "fields": [
+ {
+ "name": "ERROR"
+ },
+ {
+ "name": "WARNING"
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.skottie.Logger",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "log",
+ "parameterTypes": [
+ "org.jetbrains.skia.skottie.LogLevel",
+ "java.lang.String",
+ "java.lang.String"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.svg.SVGLength",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "float",
+ "int"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "org.jetbrains.skia.svg.SVGPreserveAspectRatio",
+ "jniAccessible": true,
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": [
+ "int",
+ "int"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "python.multiplatform.ffi.ProxyType",
+ "methods": [
+ {
+ "name": "tp_clear",
+ "parameterTypes": [
+ "long"
+ ]
+ },
+ {
+ "name": "tp_dealloc",
+ "parameterTypes": [
+ "long"
+ ]
+ },
+ {
+ "name": "tp_traverse",
+ "parameterTypes": [
+ "long",
+ "long",
+ "long"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "python.native.ffi.Panama",
+ "methods": [
+ {
+ "name": "modernLongToSegment",
+ "parameterTypes": [
+ "long"
+ ]
+ }
+ ]
+ },
+ {
+ "type": "python.native.ffi.UpcallTarget",
+ "methods": [
+ {
+ "name": "upcallCancelCall",
+ "parameterTypes": [
+ "long"
+ ]
+ },
+ {
+ "name": "upcallInvokeWithArgs",
+ "parameterTypes": [
+ "long",
+ "long"
+ ]
+ },
+ {
+ "name": "upcallReleaseObject",
+ "parameterTypes": [
+ "long"
+ ]
+ },
+ {
+ "name": "upcallResolveHandle",
+ "parameterTypes": [
+ "long"
+ ]
+ }
+ ]
+ }
+ ],
+ "resources": [
+ {
+ "glob": "META-INF/services/kotlin.test.AsserterContributor"
+ },
+ {
+ "glob": "META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory"
+ },
+ {
+ "glob": "lib/macos-aarch64/libpython3.15.dylib"
+ },
+ {
+ "glob": "libskiko-macos-arm64.dylib.sha256"
+ },
+ {
+ "glob": "python"
+ },
+ {
+ "module": "java.base",
+ "glob": "jdk/internal/icu/impl/data/icudt76b/nfc.nrm"
+ }
+ ],
+ "foreign": {
+ "downcalls": [
+ {
+ "returnType": "void",
+ "parameterTypes": [
+ "jint"
+ ]
+ },
+ {
+ "returnType": "jint",
+ "parameterTypes": [
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jint",
+ "parameterTypes": [
+ "jint"
+ ]
+ },
+ {
+ "returnType": "jint",
+ "parameterTypes": [
+ "jlong",
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jint",
+ "parameterTypes": [
+ "jlong",
+ "jlong",
+ "jint"
+ ]
+ },
+ {
+ "returnType": "jdouble",
+ "parameterTypes": []
+ },
+ {
+ "returnType": "void",
+ "parameterTypes": [
+ "jlong",
+ "jint",
+ "jint"
+ ]
+ },
+ {
+ "returnType": "void",
+ "parameterTypes": [
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "void",
+ "parameterTypes": [
+ "jlong",
+ "jint"
+ ]
+ },
+ {
+ "returnType": "void",
+ "parameterTypes": []
+ },
+ {
+ "returnType": "void",
+ "parameterTypes": [
+ "jlong",
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "void",
+ "parameterTypes": [
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong",
+ "jint"
+ ]
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jdouble"
+ ]
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong",
+ "jlong",
+ "jlong",
+ "jlong",
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": []
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong",
+ "jlong",
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong",
+ "jint",
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jint",
+ "parameterTypes": [
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jdouble",
+ "parameterTypes": [
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong",
+ "jlong",
+ "jlong",
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong",
+ "jlong",
+ "jlong",
+ "jlong",
+ "jint"
+ ]
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong",
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "returnType": "jint",
+ "parameterTypes": []
+ },
+ {
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong",
+ "jlong",
+ "jint"
+ ]
+ },
+ {
+ "returnType": "jint",
+ "parameterTypes": [
+ "jlong",
+ "jlong",
+ "jlong",
+ "jint",
+ "jlong",
+ "jlong"
+ ]
+ }
+ ],
+ "directUpcalls": [
+ {
+ "class": "python.multiplatform.ffi.ProxyType",
+ "method": "tp_clear",
+ "returnType": "jint",
+ "parameterTypes": [
+ "jlong"
+ ]
+ },
+ {
+ "class": "python.native.ffi.UpcallTarget",
+ "method": "upcallCancelCall",
+ "returnType": "jint",
+ "parameterTypes": [
+ "jlong"
+ ]
+ },
+ {
+ "class": "python.multiplatform.ffi.ProxyType",
+ "method": "tp_traverse",
+ "returnType": "jint",
+ "parameterTypes": [
+ "jlong",
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "class": "python.multiplatform.ffi.ProxyType",
+ "method": "tp_dealloc",
+ "returnType": "void",
+ "parameterTypes": [
+ "jlong"
+ ]
+ },
+ {
+ "class": "python.native.ffi.UpcallTarget",
+ "method": "upcallReleaseObject",
+ "returnType": "jint",
+ "parameterTypes": [
+ "jlong"
+ ]
+ },
+ {
+ "class": "python.native.ffi.UpcallTarget",
+ "method": "upcallInvokeWithArgs",
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong",
+ "jlong"
+ ]
+ },
+ {
+ "class": "python.native.ffi.UpcallTarget",
+ "method": "upcallResolveHandle",
+ "returnType": "jlong",
+ "parameterTypes": [
+ "jlong"
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/python/main.py b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/python/main.py
new file mode 100644
index 000000000..1d56282b2
--- /dev/null
+++ b/python-multiplatform-compose/fixtures/notebook-e2e/src/desktopTest/python/main.py
@@ -0,0 +1,25 @@
+"""The application module `UI.ipynb` imports as `main` (cell 6), written with pythonx-compose's API.
+
+It follows the notebook's own spelling, as pythonx-compose ships it: the screen is *declared* with
+`@app` (there is no `main.App.update(...)`), and the UI state is the root's `remember_saveable`
+value, which the app attaches to the function itself (`App.messages`), so the notebook reads and
+writes it as `main.App.messages.getValue()` / `setValue(...)` (cells 9-13).
+
+`calls` counts how often each root ran, so a test can tell a recomposition from a redeclaration and
+an idle frame from a poll. It is the test's instrument, not part of the notebook.
+"""
+
+from pythonx.compose.runtime import Composable, app, remember_saveable
+from pythonx.compose.material3 import Text
+
+INITIAL_MESSAGE = "안녕하세요, Python 앱입니다."
+
+calls = {"App": 0}
+
+
+@app
+@Composable
+def App():
+ calls["App"] += 1
+ App.messages = messages = remember_saveable(INITIAL_MESSAGE)
+ Text(messages.getValue())
diff --git a/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonAppView.kt
similarity index 58%
rename from python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt
rename to python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonAppView.kt
index 8de24e3e6..59cb1622c 100644
--- a/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt
+++ b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonAppView.kt
@@ -5,6 +5,8 @@ import androidx.compose.runtime.RememberObserver
import androidx.compose.runtime.State
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.Layout
import python.multiplatform.ffi.PyObject
import python.multiplatform.ffi.Python3
import python.multiplatform.ffi.pythonx.PythonCallables
@@ -12,7 +14,70 @@ import python.multiplatform.reflection.HandleTable
import python.native.ffi.PyLong_AsLongLong
/**
- * Composes the Python application root found at [root], and recomposes whenever Python replaces it.
+ * Starts the Python interpreter if it is not running yet, then composes [content].
+ *
+ * This is the first of the three host composables (`PythonLauncher`, [PythonWidget], [PythonAppView])
+ * and its job is the interpreter start only: wrap the part of the UI that reaches Python in it.
+ * Registering the composer with the Python runtime is **not** done here once for the whole tree: it
+ * is done per Python call, by [withPythonComposer], each time a root or widget is composed, so that
+ * the composer a bound `@Composable` sees is always the one of the composition that called it.
+ */
+@Composable
+fun PythonLauncher(content: @Composable () -> Unit) {
+ if (!Python3.isInitialized) {
+ Python3.initialize()
+ }
+ content()
+}
+
+/**
+ * The module [PythonAppView] and [PythonWidget] look in when none is named: Python's `__main__`.
+ */
+const val DEFAULT_PYTHON_MODULE: String = "__main__"
+
+/**
+ * Composes the Python application root found at `module.attribute`, and recomposes whenever Python
+ * replaces it. The declarative app root: `PythonAppView(module = "main", attribute = "App")` for a
+ * Python `main.App`.
+ *
+ * `attribute` is a Python callable taking no arguments, or a Compose [State] that Python holds whose
+ * value is such a callable (or `None` for "nothing yet"); see [PythonAppView] with a `root` for the
+ * semantics. The attribute is resolved **once**, when this enters the composition (and again only if
+ * [module] or [attribute] change). Rebinding the module attribute afterwards is not observed;
+ * replacing the root is a write into the state the attribute names, which is.
+ *
+ * [modifier] sizes and places the area the root draws into. The original design's `Surface` and
+ * colour parameters are left out on purpose: that needs material3, which this module does not depend
+ * on. Wrap the call in a `Surface` where one is wanted.
+ */
+@Composable
+fun PythonAppView(
+ modifier: Modifier = Modifier,
+ module: String = DEFAULT_PYTHON_MODULE,
+ attribute: String = "App",
+) {
+ val resolved = remember(module, attribute) { ResolvedAttribute(module, attribute) }
+ PythonAppView(resolved.value, modifier)
+}
+
+/**
+ * Composes the named Python composable `moduleName.composableName`: a callable taking no arguments,
+ * or a Compose [State] that Python holds whose value is one. Same semantics as [PythonAppView] with
+ * a `root`. Unlike the original design, no Kotlin `content` slot is passed to the callable, and the
+ * `Surface` parameters are left out (see [PythonAppView]).
+ */
+@Composable
+fun PythonWidget(
+ composableName: String,
+ modifier: Modifier = Modifier,
+ moduleName: String = DEFAULT_PYTHON_MODULE,
+) {
+ val resolved = remember(moduleName, composableName) { ResolvedAttribute(moduleName, composableName) }
+ PythonWidget(resolved.value, modifier)
+}
+
+/**
+ * [PythonWidget] for a Python object the host already holds.
*
* [root] is one of:
*
@@ -37,28 +102,32 @@ import python.native.ffi.PyLong_AsLongLong
* [root] is the caller's: it is neither consumed nor closed.
*/
@Composable
-fun PythonContent(root: PyObject) {
+fun PythonWidget(root: PyObject, modifier: Modifier = Modifier) {
val source = remember(root) { RootSource.of(root) }
val current = source.current()
- if (current != null) {
- key(current) {
- PythonRoot(current)
+ Layout(
+ content = {
+ if (current != null) {
+ key(current) {
+ PythonRoot(current)
+ }
+ }
+ },
+ modifier = modifier,
+ ) { measurables, constraints ->
+ val placeables = measurables.map { it.measure(constraints) }
+ val width = placeables.maxOfOrNull { it.width } ?: constraints.minWidth
+ val height = placeables.maxOfOrNull { it.height } ?: constraints.minHeight
+ layout(width, height) {
+ placeables.forEach { it.place(0, 0) }
}
}
}
-/**
- * [PythonContent] for a root state that lives at `module.attribute` -- `PythonContent("main", "app")`
- * for a Python `main.app`.
- *
- * The attribute is resolved **once**, when this enters the composition (and again only if [module]
- * or [attribute] change). Rebinding the module attribute afterwards is not observed; replacing the
- * root is a write into the state the attribute names, which is.
- */
+/** [PythonWidget] with a `root`, under the app-root name. */
@Composable
-fun PythonContent(module: String, attribute: String) {
- val resolved = remember(module, attribute) { ResolvedAttribute(module, attribute) }
- PythonContent(resolved.value)
+private fun PythonAppView(root: PyObject, modifier: Modifier) {
+ PythonWidget(root, modifier)
}
/**
diff --git a/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/RememberSaveable.kt b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/RememberSaveable.kt
new file mode 100644
index 000000000..947a1cab2
--- /dev/null
+++ b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/RememberSaveable.kt
@@ -0,0 +1,53 @@
+package python.multiplatform.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableDoubleStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import python.multiplatform.ffi.PyObject
+
+/**
+ * `rememberSaveable { mutableStateOf(initial) }` for a value Python holds, so that a Python screen's
+ * state survives what `rememberSaveable` survives (recreating an activity, process death with a
+ * saved instance state, a `SaveableStateRegistry` saved and restored).
+ *
+ * Follows `rememberSaveableWrapper(init, type)` of the original pycomposeui
+ * (`Runtime.android.kt`), which dispatched on a type name Python passed along. Here the value alone
+ * is passed: a Python scalar written into a `kotlin.Any?` slot arrives boxed, so its runtime type is
+ * the type name.
+ *
+ * | Python value | Kotlin type | State created |
+ * |---|---|---|
+ * | `int` that fits 32 bits | `Int` | `mutableIntStateOf` |
+ * | larger `int` | `Long` | `mutableLongStateOf` |
+ * | `float` | `Double` | `mutableDoubleStateOf` |
+ * | `bool` | `Boolean` | `mutableStateOf` |
+ * | `str` | `String` | `mutableStateOf` |
+ *
+ * Any other value throws [IllegalArgumentException] naming its type (`list` for a Python list): like the original, only these
+ * are supported, because only these are saveable without a custom `Saver`.
+ *
+ * The returned state is Compose's own: Python reads and writes its `value`, and a write is saved by
+ * the next `SaveableStateRegistry.performSave`. Call it inside a composition only.
+ */
+@Composable
+fun rememberSaveableWrapper(initial: Any?): MutableState<*> = when (initial) {
+ is Int -> rememberSaveable { mutableIntStateOf(initial) }
+ is Long -> rememberSaveable { mutableLongStateOf(initial) }
+ is Double -> rememberSaveable { mutableDoubleStateOf(initial) }
+ is Boolean -> rememberSaveable { mutableStateOf(initial) }
+ is String -> rememberSaveable { mutableStateOf(initial) }
+ else -> throw IllegalArgumentException(
+ "rememberSaveableWrapper supports Int, Long, Double, Boolean and String, not " + typeNameOf(initial),
+ )
+}
+
+/** The type of [value] as Python spells it for a Python object (`list`), as Kotlin does otherwise. */
+private fun typeNameOf(value: Any?): String = when (value) {
+ null -> "null"
+ is PyObject -> value.Type.getAttr("__name__").toString()
+ else -> value::class.qualifiedName ?: value::class.simpleName ?: "unknown"
+}
diff --git a/python-multiplatform-gradle-plugin/build.gradle.kts b/python-multiplatform-gradle-plugin/build.gradle.kts
index 4f767181e..e19b5dd37 100644
--- a/python-multiplatform-gradle-plugin/build.gradle.kts
+++ b/python-multiplatform-gradle-plugin/build.gradle.kts
@@ -72,7 +72,7 @@ dependencies {
// - not in the library. `python-multiplatform` does not depend on this plugin;
// `:python-multiplatform:dependencies` reports `kotlin-compiler-embeddable:2.0.20` in no
// configuration at all, `desktopRuntimeClasspath` included.
- // - not on a consumer's *application* classpath. `:ksp-fixtures:app:dependencies` reports zero
+ // - not on a consumer's *application* classpath. `:python-multiplatform-ksp:fixtures:app:dependencies` reports zero
// occurrences of `2.0.20`. (It does report `kotlin-compiler-embeddable:2.4.0` and
// `2.4.20-Beta2` under `kotlinCompilerPluginClasspath*` and the commonizer's configurations
// -- KGP's own, present before this dependency existed.)
@@ -125,7 +125,7 @@ dependencies {
// classloader's parent is Gradle's own minimal worker infrastructure, not that root scope, so
// `org.jetbrains.kotlin.library.*` has exactly one source inside the worker. Confirmed against a
// real consumer build, not only this plugin's own unit tests:
- // `:ksp-fixtures:klib-artifact:generatePythonArtifactBindings` and `:generatePythonStubs` both
+ // `:python-multiplatform-gradle-plugin-fixtures:klib-artifact:generatePythonArtifactBindings` and `:generatePythonStubs` both
// pass and emit the walker's real output (`kotlinx.coroutines.flow.internal.checkIndexOverflow`).
//
// ### Compile-only here, resolved by the consumer at klib-walking time
@@ -243,8 +243,8 @@ val generateCoordinates = tasks.register("generateCoordinates") {
val file = rootDir.resolve("../gradle.properties")
if (file.isFile) file.inputStream().use { load(it) }
}
- val pythonVersionValue = rootProperties.getProperty("pythonVersion") ?: "3.14.7"
- val pbsReleaseValue = rootProperties.getProperty("pythonBuildStandaloneRelease") ?: "20260807"
+ val pythonVersionValue = rootProperties.getProperty("pythonVersion") ?: "3.15.0"
+ val pbsReleaseValue = rootProperties.getProperty("pythonBuildStandaloneRelease") ?: "20261013"
val freeThreadedValue = rootProperties.getProperty("pythonFreeThreaded")?.toBoolean() ?: false
// `python-multiplatform` itself is versioned "$pythonVersion-alpha01" -- see that build's own
diff --git a/ksp-fixtures/artifact-valueclass/build.gradle.kts b/python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/build.gradle.kts
similarity index 90%
rename from ksp-fixtures/artifact-valueclass/build.gradle.kts
rename to python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/build.gradle.kts
index 5c12a588a..7a0218307 100644
--- a/ksp-fixtures/artifact-valueclass/build.gradle.kts
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/build.gradle.kts
@@ -4,7 +4,7 @@
*
* `kotlin.time.Duration` (the real-world case `ArtifactScanner`'s KDoc discusses) cannot prove a
* *positive* round trip: its constructor is `internal`, so no generated Kotlin outside
- * `kotlin-stdlib` can ever build one. This module exists only to be walked -- like `:ksp-fixtures
+ * `kotlin-stdlib` can ever build one. This module exists only to be walked -- like `:python-multiplatform-gradle-plugin-fixtures
* :artifact`'s `junit` dependency, it is a plain `kotlin("jvm")` library with no bindings plugin of
* its own applied, resolved as an ordinary dependency and never compiled by the module that walks
* it.
diff --git a/ksp-fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/Meters.kt b/python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/Meters.kt
similarity index 82%
rename from ksp-fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/Meters.kt
rename to python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/Meters.kt
index e627cab6a..92d67b377 100644
--- a/ksp-fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/Meters.kt
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/Meters.kt
@@ -1,4 +1,4 @@
-// Never compiled by `:ksp-fixtures:artifact`, which only ever sees this module's resolved jar --
+// Never compiled by `:python-multiplatform-gradle-plugin-fixtures:artifact`, which only ever sees this module's resolved jar --
// see the `build.gradle.kts` doc comment on why this module exists at all.
package fixture.valueclass
diff --git a/python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/member/Members.kt b/python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/member/Members.kt
new file mode 100644
index 000000000..259792250
--- /dev/null
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/member/Members.kt
@@ -0,0 +1,49 @@
+// Walked by `:python-multiplatform-gradle-plugin-fixtures:artifact` (its `fixture.valueclass` include covers this
+// package): the member functions issue #215 binds, on a class and on an interface.
+package fixture.valueclass.member
+
+/** A class with a constructor, overloaded members and a member that returns another handle. */
+class Tally(var total: Int) {
+ fun add(step: Int): Int {
+ total += step
+ return total
+ }
+
+ fun add(step: Int, label: String): String = label + add(step)
+
+ fun reset() {
+ total = 0
+ }
+
+ fun twin(): Tally = Tally(total)
+}
+
+/** The `kotlinx.coroutines.Job` shape: only its members say anything about it. */
+interface Switch {
+ val on: Boolean
+
+ fun turnOff()
+}
+
+fun newSwitch(): Switch = object : Switch {
+ override var on: Boolean = true
+
+ override fun turnOff() {
+ on = false
+ }
+}
+
+/** Kotlin's factory idiom (`kotlinx.coroutines.Job` and `fun Job()`): an interface and a function of one name (issue #217). */
+interface Gauge {
+ val level: Int
+
+ fun bump()
+}
+
+fun Gauge(): Gauge = object : Gauge {
+ override var level: Int = 0
+
+ override fun bump() {
+ level += 1
+ }
+}
diff --git a/python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/sam/Sam.kt b/python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/sam/Sam.kt
new file mode 100644
index 000000000..fbb20e980
--- /dev/null
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/sam/Sam.kt
@@ -0,0 +1,22 @@
+// Walked by `:python-multiplatform-gradle-plugin-fixtures:artifact` (its `fixture.valueclass` include covers this
+// package): functional-interface parameters issue #216 fills with a Python callable.
+package fixture.valueclass.sam
+
+/** A Kotlin `fun interface` that is handed two values. */
+fun interface Listener {
+ fun onEvent(name: String, count: Int)
+}
+
+/** The `CoroutineDispatcher.dispatch(context, block: Runnable)` shape, which runs the block at once. */
+fun runRunnable(block: Runnable) = block.run()
+
+/** Hands the listener a name and its length. */
+fun notifyListener(listener: Listener, name: String) = listener.onEvent(name, name.length)
+
+private var heard = ""
+
+/** A listener Kotlin made, so the same slot is shown taking a Kotlin handle as well as a callable. */
+fun loggingListener(): Listener = Listener { name, count -> heard = "$name:$count" }
+
+/** What [loggingListener]'s listener heard last. */
+fun lastHeard(): String = heard
diff --git a/ksp-fixtures/artifact/build.gradle.kts b/python-multiplatform-gradle-plugin/fixtures/artifact/build.gradle.kts
similarity index 93%
rename from ksp-fixtures/artifact/build.gradle.kts
rename to python-multiplatform-gradle-plugin/fixtures/artifact/build.gradle.kts
index 0b37dedd8..f7369a9c5 100644
--- a/ksp-fixtures/artifact/build.gradle.kts
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact/build.gradle.kts
@@ -6,11 +6,11 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
* installed into the same `UpcallTable` KSP's own fragments go into, and imported from Python.
*
* `docs/design/ecosystem.md` §5b names two producers of bindings and splits them by *what they look at* --
- * KSP the consumer's own source, the walker everything the build resolves. `:ksp-fixtures:app`
+ * KSP the consumer's own source, the walker everything the build resolves. `:python-multiplatform-ksp:fixtures:app`
* covers the first. Nothing covered the second, which is why `androidx.compose.material3` was
* believed to be unreachable.
*
- * Deliberately a module of its own rather than another target in `:ksp-fixtures:app`: that module's
+ * Deliberately a module of its own rather than another target in `:python-multiplatform-ksp:fixtures:app`: that module's
* `CommonInstallTest` asserts `UpcallTable.moduleNames` **exactly**, and a walked artefact appearing
* there would be a change to what `FunctionTable` means rather than an addition beside it. Keeping
* the two apart in the build is the same judgement `ArtifactTableRenderingTest` records for the
@@ -46,7 +46,7 @@ kotlin {
implementation(libs.junit)
// See its own `build.gradle.kts`: a real jar for the value-class round trip
// `kotlin.time.Duration` cannot prove.
- implementation(projects.kspFixtures.artifactValueclass)
+ implementation(projects.pythonMultiplatformGradlePluginFixtures.artifactValueclass)
// Compose Multiplatform, as plain Maven coordinates and **without** the Compose
// Gradle or compiler plugin. That is deliberate and is the whole shape of the claim:
@@ -99,7 +99,7 @@ pythonBindings {
// `ArtifactScanner`'s KDoc names as unreachable before it read `@Metadata` -- a top-level
// extension function behind a `MULTI_FILE_CLASS_FACADE`. `WalkedArtifactPythonImportTest` calls
// it from real Python.
- // `fixture.valueclass` is `:ksp-fixtures:artifact-valueclass`'s `Meters`/`sumMeters` -- see that
+ // `fixture.valueclass` is `:python-multiplatform-gradle-plugin-fixtures:artifact-valueclass`'s `Meters`/`sumMeters` -- see that
// module's `build.gradle.kts` for why `kotlin.time.Duration` cannot stand in for it.
//
// `androidx.compose.foundation.layout` is the scorecard of
@@ -127,6 +127,10 @@ pythonBindings {
"androidx.compose.foundation.layout",
"androidx.compose.foundation.ClickableKt",
"kotlin.system.TimingKt",
+ // `TextUnit(30, TextUnitType.Sp)`: a value class over `Long` whose property is internal,
+ // so it can only ever be held in Python as a boxed handle (issue #168).
+ "androidx.compose.ui.unit.TextUnitKt",
+ "androidx.compose.ui.unit.TextUnitType",
),
)
}
diff --git a/ksp-fixtures/artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt
similarity index 100%
rename from ksp-fixtures/artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt
rename to python-multiplatform-gradle-plugin/fixtures/artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt
diff --git a/ksp-fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt
similarity index 94%
rename from ksp-fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt
rename to python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt
index ef0651b0c..25a8f5873 100644
--- a/ksp-fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt
@@ -6,7 +6,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
/**
* The declarations `WalkedArtifactComposeModifierTest` needs that the *walker* cannot supply,
@@ -84,3 +86,8 @@ fun equalsVerticalPadding(modifier: Modifier, vertical: Double): Boolean =
/** The negative control for [equalsVerticalPadding]: the same number in the *horizontal* slot. */
fun equalsHorizontalPadding(modifier: Modifier, horizontal: Double): Boolean =
modifier == Modifier.padding(horizontal = horizontal.dp)
+
+/** Structural equality of a boxed `TextUnit` against `size.sp`; the box is what a handle resolves to. */
+fun equalsSp(value: TextUnit, size: Double): Boolean = value == size.sp
+
+fun describeValue(value: TextUnit): String = value.toString()
diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/PythonxModules.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/PythonxModules.kt
similarity index 100%
rename from ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/PythonxModules.kt
rename to python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/PythonxModules.kt
diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt
similarity index 95%
rename from ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt
rename to python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt
index 2169d808d..87c11218d 100644
--- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt
@@ -91,10 +91,13 @@ class StubSignatureAgreesWithRuntimeTest {
# a value class bound as its primitive: the raw number crosses (`Dp | float`, or `float`
# where the class name is taken by a function in its module)
return TAG[tag] in parts
- # an object: the stub class named by its FQN (or bare inside its own module), or `Any`
- # where a function in its module owns the name -- never an `int` handle
+ # an object: the stub class named by its FQN (or bare inside its own module), or -- where a
+ # function in its module owns the name -- its stub-private class `_Name` (issue #217), or
+ # `Any`; never an `int` handle
simple = runtime.rsplit('.', 1)[-1]
- return parts[0].endswith('Any') or runtime in stub or parts[0] == simple
+ private = '_' + simple
+ return (parts[0].endswith('Any') or runtime in stub or parts[0] == simple or
+ parts[0] == private or parts[0].endswith('.' + private))
def stub_parameters(fn):
a = fn.args
diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt
similarity index 96%
rename from ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt
rename to python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt
index f5c06d447..8c6447608 100644
--- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt
@@ -41,7 +41,7 @@ import kotlin.test.assertTrue
* () -> Unit)` has the identical slot (`kotlin.Function0()->kotlin.Unit`, same walker, same
* fragment, same `PythonFunction` wrapper) and invokes it at the moment it is called, so it is what
* makes "the callback is called" a measurement rather than an inference. Rendering a real click is
- * `:ksp-fixtures:compose`'s job and needs the Compose compiler plugin this module deliberately does
+ * `:python-multiplatform-compose:fixtures:compose`'s job and needs the Compose compiler plugin this module deliberately does
* not apply.
*
* ### Lifetime
@@ -87,13 +87,13 @@ class WalkedArtifactCallbackTest {
PythonCallables.withScope(PythonCallables.newScope()) {
Python3.exec(
"""
- from kotlin.system import measureTimeMillis
+ from kotlin.system import measure_time_millis
_ran = []
_never = []
_control = lambda: _never.append('should not run')
- _elapsed = measureTimeMillis(lambda: _ran.append('ran'))
+ _elapsed = measure_time_millis(lambda: _ran.append('ran'))
assert _ran == ['ran'], 'the Python callable was not invoked: ' + repr(_ran)
assert _never == [], 'a callable nobody passed anywhere ran: ' + repr(_never)
@@ -120,9 +120,9 @@ class WalkedArtifactCallbackTest {
PythonCallables.withScope(PythonCallables.newScope()) {
Python3.exec(
"""
- from kotlin.system import measureTimeMillis
+ from kotlin.system import measure_time_millis
try:
- measureTimeMillis(lambda wanted: None)
+ measure_time_millis(lambda wanted: None)
raise AssertionError('a one-argument callable filled a zero-argument slot')
except AssertionError:
raise
@@ -166,7 +166,7 @@ class WalkedArtifactCallbackTest {
assert modifierElementCount(_empty) == 0
_clicks = []
- _tappable = clickable(_empty, onClick=lambda: _clicks.append('tap'))
+ _tappable = clickable(_empty, on_click=lambda: _clicks.append('tap'))
assert not isTheEmptyModifier(_tappable._pm_handle), (
'clickable returned the receiver unchanged'
diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactComposeModifierTest.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactComposeModifierTest.kt
similarity index 100%
rename from ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactComposeModifierTest.kt
rename to python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactComposeModifierTest.kt
diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt
similarity index 95%
rename from ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt
rename to python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt
index 25adbc2cd..16abfcf44 100644
--- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt
@@ -73,7 +73,8 @@ class WalkedArtifactDefaultOmissionTest {
/**
* The judgement, in Compose's own code: two calls that write **no** optional argument.
*
- * `fillMaxWidth(m)` and `wrapContentSize(m)` are called with the receiver alone --
+ * `fill_max_width(m)` and `wrapContentSize(m)` -- the first by its Pythonic name (issue #131), the
+ * second by its Kotlin name -- are called with the receiver alone --
* `f(x)`, not `f(x, None, None)` -- and the receiver is the only required parameter either of
* them declares.
*/
@@ -81,14 +82,14 @@ class WalkedArtifactDefaultOmissionTest {
fun composeFunctionsAreCalledWithTheirRequiredArgumentsAlone() {
Python3.exec(
"""
- from androidx.compose.foundation.layout import fillMaxWidth, wrapContentSize
+ from androidx.compose.foundation.layout import fill_max_width, wrapContentSize
from fixture.artifact import emptyModifier
from fixture.artifact import equalsFillMaxWidth, equalsFillMaxWidthFraction
from fixture.artifact import equalsWrapContentSize, equalsWrapContentSizeTopStart
- _filled = fillMaxWidth(emptyModifier())
+ _filled = fill_max_width(emptyModifier())
assert equalsFillMaxWidth(_filled._pm_handle), (
- 'fillMaxWidth() did not produce Modifier.fillMaxWidth()'
+ 'fill_max_width() did not produce Modifier.fillMaxWidth()'
)
# The controls. `fraction` defaults to 1f, so a body that had passed the sentinel through
# as a number would have produced one of these instead.
diff --git a/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactMemberFunctionTest.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactMemberFunctionTest.kt
new file mode 100644
index 000000000..fdee2f946
--- /dev/null
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactMemberFunctionTest.kt
@@ -0,0 +1,84 @@
+package fixture.artifact
+
+import python.multiplatform.ffi.Python3
+import python.multiplatform.ffi.pythonx.PythonxAdapter
+import python.multiplatform.ffi.upcall.PythonProxySource
+import python.multiplatform.ffi.upcall.UpcallBootstrap
+import python.multiplatform.generated.FunctionTable
+import python.multiplatform.generated.artifacts.ArtifactTable
+import python.multiplatform.reflection.UpcallTable
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+/**
+ * Issue #215, end to end: member functions of a walked class and interface
+ * (`fixture.valueclass.member`, from the `artifact-valueclass` jar) are called from Python on the
+ * handles Kotlin returned, through the generated table and the binding layer.
+ *
+ * Red before #215: `tally.add(3)` and `switch.turn_off()` are `AttributeError`s, because the walker
+ * bound only the constructor and the properties.
+ */
+class WalkedArtifactMemberFunctionTest {
+
+ @BeforeTest
+ fun installBothProducers() {
+ Python3.initialize(silent = true)
+ UpcallTable.clear()
+ UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)
+ check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" }
+ PythonProxySource.install()
+ PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)
+ }
+
+ @AfterTest
+ fun cleanup() {
+ UpcallTable.clear()
+ }
+
+ @Test
+ fun aClassesMembersRunOnTheKotlinObject() {
+ Python3.exec(
+ """
+ from fixture.valueclass.member import Tally
+ _mf_t = Tally(1)
+ assert _mf_t.add(3) == 4, 'add(Int) did not run on the Kotlin object'
+ assert _mf_t.add(1, 'n=') == 'n=5', 'add(Int, String) was not chosen by its arguments'
+ assert _mf_t.add__Int(10) == 15, 'the explicit overload name did not reach add(Int)'
+ assert _mf_t.total == 15, 'the members did not change the object the proxy holds'
+ _mf_twin = _mf_t.twin()
+ _mf_t.reset()
+ assert _mf_t.total == 0, 'reset() did not run'
+ assert _mf_twin.total == 15, "a member's handle result is not a separate Kotlin object"
+ assert type(_mf_twin)._kotlin_type_name == 'fixture.valueclass.member.Tally', type(_mf_twin)._kotlin_type_name
+ """.trimIndent(),
+ )
+ }
+
+ @Test
+ fun anInterfacesMemberRunsOnTheObjectBehindIt() {
+ Python3.exec(
+ """
+ from fixture.valueclass.member import newSwitch
+ _mf_s = newSwitch()
+ assert _mf_s.on is True
+ _mf_s.turn_off()
+ assert _mf_s.on is False, 'turn_off() did not run on the object behind the interface'
+ """.trimIndent(),
+ )
+ }
+
+ /** Issue #217: at run time the factory owns the name, and the value it makes keeps its members. */
+ @Test
+ fun aValueOfATypeWhoseNameAFactoryTakesKeepsItsMembers() {
+ Python3.exec(
+ """
+ from fixture.valueclass.member import Gauge
+ _mf_g = Gauge()
+ assert _mf_g.level == 0, repr(_mf_g.level)
+ _mf_g.bump()
+ assert _mf_g.level == 1, 'the member did not run on the value the factory made'
+ """.trimIndent(),
+ )
+ }
+}
diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactPythonImportTest.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactPythonImportTest.kt
similarity index 98%
rename from ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactPythonImportTest.kt
rename to python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactPythonImportTest.kt
index ae8d02744..e7bc5d65d 100644
--- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactPythonImportTest.kt
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactPythonImportTest.kt
@@ -137,7 +137,7 @@ class WalkedArtifactPythonImportTest {
/**
* `fixture.valueclass.sumMeters`: both parameters and the return are a public
- * `@JvmInline value class` (`Meters`), from `:ksp-fixtures:artifact-valueclass` -- a jar built
+ * `@JvmInline value class` (`Meters`), from `:python-multiplatform-gradle-plugin-fixtures:artifact-valueclass` -- a jar built
* for exactly this proof, since `kotlin.time.Duration` (the real-world case) has an `internal`
* constructor and so can never round-trip. `12.0` is not producible except by unwrapping two
* `Meters` arguments, adding them, and wrapping the sum back up.
diff --git a/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactSamParameterTest.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactSamParameterTest.kt
new file mode 100644
index 000000000..5265764cc
--- /dev/null
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactSamParameterTest.kt
@@ -0,0 +1,76 @@
+package fixture.artifact
+
+import python.multiplatform.ffi.Python3
+import python.multiplatform.ffi.pythonx.PythonCallables
+import python.multiplatform.ffi.pythonx.PythonxAdapter
+import python.multiplatform.ffi.upcall.PythonProxySource
+import python.multiplatform.ffi.upcall.UpcallBootstrap
+import python.multiplatform.generated.FunctionTable
+import python.multiplatform.generated.artifacts.ArtifactTable
+import python.multiplatform.reflection.UpcallTable
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+/**
+ * Issue #216, end to end: a Python callable fills a `java.lang.Runnable` and a Kotlin `fun interface`
+ * parameter of a walked declaration (`fixture.valueclass.sam`), and Kotlin runs it.
+ *
+ * Red before #216: `run_runnable` and `notify_listener` do not exist, because the walker declined both.
+ */
+class WalkedArtifactSamParameterTest {
+
+ @BeforeTest
+ fun installBothProducers() {
+ Python3.initialize(silent = true)
+ UpcallTable.clear()
+ UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)
+ check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" }
+ PythonProxySource.install()
+ PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)
+ }
+
+ @AfterTest
+ fun cleanup() {
+ UpcallTable.clear()
+ }
+
+ @Test
+ fun aPythonCallableRunsAsARunnable() {
+ PythonCallables.withScope(PythonCallables.newScope()) {
+ Python3.exec(
+ """
+ from fixture.valueclass.sam import run_runnable
+ _sam_ran = []
+ run_runnable(lambda: _sam_ran.append('ran'))
+ assert _sam_ran == ['ran'], 'the Runnable did not run the Python callable: ' + repr(_sam_ran)
+ """.trimIndent(),
+ )
+ }
+ }
+
+ @Test
+ fun aPythonCallableRunsAsAFunInterfaceWithItsArguments() {
+ PythonCallables.withScope(PythonCallables.newScope()) {
+ Python3.exec(
+ """
+ from fixture.valueclass.sam import notify_listener
+ _sam_events = []
+ notify_listener(lambda name, count: _sam_events.append((name, count)), 'hello')
+ assert _sam_events == [('hello', 5)], repr(_sam_events)
+ """.trimIndent(),
+ )
+ }
+ }
+
+ @Test
+ fun aKotlinImplementationStillFillsTheSameSlot() {
+ Python3.exec(
+ """
+ from fixture.valueclass.sam import notify_listener, logging_listener, last_heard
+ notify_listener(logging_listener(), 'abc')
+ assert last_heard() == 'abc:3', 'a Kotlin Listener handle did not reach the slot: ' + repr(last_heard())
+ """.trimIndent(),
+ )
+ }
+}
diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt
similarity index 76%
rename from ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt
rename to python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt
index 4ed7735b2..ee91ab5e8 100644
--- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt
@@ -7,6 +7,7 @@ import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
@@ -86,16 +87,17 @@ class WalkedArtifactStubTest {
/**
* The binder never exports a Kotlin namespace under another name (AGENTS.md section 12 rule 1).
- * `androidx.compose.foundation.layout` is stubbed under that very path, with Kotlin names and
- * Kotlin parameter names; making it Pythonic is the `pythonx-compose` package's job.
+ * `androidx.compose.foundation.layout` is stubbed under that very path. Inside it (issue #131)
+ * each declaration keeps its Kotlin `def` and gains its Pythonic alias as an assignment, and a
+ * parameter is written under the keyword `inspect.signature` shows.
*
- * Pinned over the whole generated tree: nothing under `pythonx/`, no snake_cased member, and none
- * of the Pythonic product's side files (`py.typed`, `_pm_dispatch.json`). The `Protocol` classes
- * that type an extension as a method of its receiver's class are typing machinery, not a
- * renamed product: they are private (`_Receiver_name`) and carry the Kotlin name unchanged.
+ * Pinned over the whole generated tree: nothing under `pythonx/`, and none of the old Pythonic
+ * product's side files (`py.typed`, `_pm_dispatch.json`). The `Protocol` classes that type an
+ * extension as a method of its receiver's class are typing machinery: they are private
+ * (`_Receiver_name`) and named after the Kotlin member.
*/
@Test
- fun theStubsUseKotlinNamesAndNothingIsExportedUnderPythonx() {
+ fun theStubsKeepKotlinNamesAddPythonicAliasesAndExportNothingUnderPythonx() {
val paths = generatedPaths()
assertEquals(emptyList(), paths.filter { it.startsWith("pythonx/") }, paths.toString())
assertTrue(paths.none { it.endsWith("py.typed") || it.endsWith("_pm_dispatch.json") }, paths.toString())
@@ -106,16 +108,25 @@ class WalkedArtifactStubTest {
"-> androidx.compose.ui.Modifier:" in layout,
layout.take(3000),
)
- assertTrue("fill_max_width" !in layout, "a Kotlin name was exported under another spelling")
- assertTrue("alignmentLine: " in layout, "a Kotlin parameter name was renamed: ${layout.take(3000)}")
+ assertTrue(
+ Regex("^fill_max_width = fillMaxWidth${'$'}", RegexOption.MULTILINE).containsMatchIn(layout),
+ "the Pythonic alias of fillMaxWidth is missing",
+ )
+ assertTrue(Regex("^def fill_max_width\\(", RegexOption.MULTILINE).find(layout) == null, "the Kotlin def must stay the Kotlin name")
+ assertTrue("alignment_line: " in layout, "a parameter is not under its Pythonic keyword: ${layout.take(3000)}")
paths.filter { it.endsWith(".pyi") }.forEach { path ->
val text = stub(path)
assertTrue("pythonx" !in text, "$path mentions pythonx")
- assertTrue(
- Regex("""class _(?!\w+_\w+\(_t\.Protocol\))""").find(text) == null,
- "$path carries a private class that is not a receiver-method protocol",
- )
+ // A private class is a receiver-method protocol, or (issue #217) the stub-private class of a type
+ // whose name a function of its module takes, which says so in its docstring.
+ Regex("""class (_\w+)[^\n]*:\n([^\n]*)""").findAll(text).forEach { match ->
+ if (Regex("""^class _\w+_\w+\(_t\.Protocol\)""").containsMatchIn(match.value.trimStart())) return@forEach
+ assertTrue(
+ "(stubbed as ${match.groupValues[1]}:" in match.groupValues[2],
+ "$path carries a private class that is neither a receiver-method protocol nor a taken name's class: ${match.value}",
+ )
+ }
}
}
@@ -224,11 +235,39 @@ class WalkedArtifactStubTest {
.toSet()
assertEquals(emptySet(), constructors - installed, "a constructor stub names no table key")
+ // A fifth shape (issue #215): a member function is a method of its receiver's stub class -- or,
+ // where the class has no stub, a comment saying so -- and either way it carries its table key
+ // after `Kotlin member: `. Matched by key, like a property.
+ val members = generatedPaths()
+ .filter { it.endsWith(".pyi") }
+ .flatMap { path ->
+ Regex("""Kotlin member: ([^\s"]+)""").findAll(stub(path)).map { it.groupValues[1] }.toList()
+ }
+ .toSet()
+ val memberKeys = UpcallTable.entries().filter { it.kind.name == "METHOD" }.map { it.name }.toSet()
+ assertTrue("fixture.valueclass.member.Tally.reset" in memberKeys, "no member function was bound: $memberKeys")
+ assertEquals(emptySet(), members - memberKeys, "a member stub names no member key")
+ assertEquals(emptySet(), memberKeys - members, "a member key has no stub")
+
// The base name of an overload set (`padding` for `padding__Dp`, ...) is served by the binding
// layer's dispatcher and is stubbed as `@overload`s; it is not itself a table key.
val overloadBases = installed.map { it.substringBeforeLast('.') + "." + it.substringAfterLast('.').substringBefore("__") }
.filter { it !in installed }.toSet()
assertEquals(emptySet(), stubbed - installed - overloadBases, "stubbed but not callable")
- assertEquals(emptySet(), installed - propertyKeys - stubbed - constructors, "callable but not stubbed")
+ assertEquals(emptySet(), installed - propertyKeys - memberKeys - stubbed - constructors, "callable but not stubbed")
+ }
+
+ /**
+ * Issue #217: `fixture.valueclass.member.Gauge` is an interface and a function. The stub's `Gauge` is
+ * the function, as at run time, and the type is `_Gauge`, with its property and its member.
+ */
+ @Test
+ fun aTypeWhoseNameAFactoryTakesIsStubbedUnderAPrivateName() {
+ val stub = stub("fixture/valueclass/member/__init__.pyi")
+ assertTrue("def Gauge() -> _Gauge:" in stub, stub)
+ assertTrue("class _Gauge:" in stub, stub)
+ assertTrue(" def level(self) -> int:" in stub, stub)
+ assertTrue(" def bump(self) -> None:" in stub, stub)
+ assertFalse("is not stubbed" in stub, stub)
}
}
diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactTableTest.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactTableTest.kt
similarity index 81%
rename from ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactTableTest.kt
rename to python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactTableTest.kt
index 569fa47bf..586578061 100644
--- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactTableTest.kt
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactTableTest.kt
@@ -32,7 +32,7 @@ class WalkedArtifactTableTest {
/**
* The wiring, pinned against the two artefacts this fixture fully controls.
*
- * `junit:junit:4.13.2` and `:ksp-fixtures:artifact-valueclass` are both version-pinned by this
+ * `junit:junit:4.13.2` and `:python-multiplatform-gradle-plugin-fixtures:artifact-valueclass` are both version-pinned by this
* build, so their contribution is a fixed list and `ArtifactScannerTest
* .theWholeJUnitJarYieldsExactlyTheseUnsuffixedDeclarations` pins the same seven names from the
* other end -- that test walks the jar directly in a plugin unit test, this one reads what the
@@ -53,12 +53,34 @@ class WalkedArtifactTableTest {
assertEquals(
listOf(
- // `:ksp-fixtures:artifact-valueclass`, built for exactly one proof: see that
+ // `:python-multiplatform-gradle-plugin-fixtures:artifact-valueclass`, built for exactly one proof: see that
// module's `build.gradle.kts`. `Meters` is the value class's own constructor,
// bound since `e2d75100` -- this list predates that and was never updated, which
- // nothing noticed because `ksp-fixtures:artifact` is not in the usual verification
+ // nothing noticed because `python-multiplatform-gradle-plugin-fixtures:artifact` is not in the usual verification
// set. `ArtifactScannerTest` pins the same constructor from the plugin side.
"fixture.valueclass.Meters",
+ // `fixture.valueclass.member` (issue #215): member functions as `METHOD` entries, beside
+ // the class's constructor and its properties. `WalkedArtifactMemberFunctionTest` calls them.
+ "fixture.valueclass.member.Gauge",
+ "fixture.valueclass.member.Gauge.bump",
+ "fixture.valueclass.member.Gauge.level",
+ "fixture.valueclass.member.Switch.on",
+ "fixture.valueclass.member.Switch.turnOff",
+ "fixture.valueclass.member.Tally",
+ "fixture.valueclass.member.Tally.add__Int",
+ "fixture.valueclass.member.Tally.add__Int_String",
+ "fixture.valueclass.member.Tally.reset",
+ "fixture.valueclass.member.Tally.total",
+ "fixture.valueclass.member.Tally.total=",
+ "fixture.valueclass.member.Tally.twin",
+ "fixture.valueclass.member.newSwitch",
+ // `fixture.valueclass.sam` (issue #216): functional-interface parameters, filled with a Python
+ // callable. `WalkedArtifactSamParameterTest` calls them.
+ "fixture.valueclass.sam.Listener.onEvent",
+ "fixture.valueclass.sam.lastHeard",
+ "fixture.valueclass.sam.loggingListener",
+ "fixture.valueclass.sam.notifyListener",
+ "fixture.valueclass.sam.runRunnable",
"fixture.valueclass.sumMeters",
"junit.runner.BaseTestRunner.getFilteredTrace",
"junit.runner.BaseTestRunner.getPreference__String",
diff --git a/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactValueClassBoxTest.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactValueClassBoxTest.kt
new file mode 100644
index 000000000..f2c9ae87e
--- /dev/null
+++ b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactValueClassBoxTest.kt
@@ -0,0 +1,71 @@
+package fixture.artifact
+
+import python.multiplatform.ffi.Python3
+import python.multiplatform.ffi.upcall.PythonProxySource
+import python.multiplatform.ffi.upcall.UpcallBootstrap
+import python.multiplatform.generated.FunctionTable
+import python.multiplatform.generated.artifacts.ArtifactTable
+import python.multiplatform.reflection.UpcallTable
+import python.multiplatform.ffi.pythonx.PythonxAdapter
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+/**
+ * Issue #168: a value class parameter takes the boxed value of its own class, which is what Kotlin
+ * itself would pass, and still refuses a raw number unless the type is on the raw-primitive allowlist.
+ *
+ * `TextUnitType` is a value class over `Long` with an internal property, so `TextUnitType.Sp` reaches
+ * Python only as a boxed handle. `TextUnit(30, TextUnitType.Sp)` used to fail with
+ * `expected a number for TextUnitType`.
+ */
+class WalkedArtifactValueClassBoxTest {
+
+ @BeforeTest
+ fun install() {
+ Python3.initialize(silent = true)
+ UpcallTable.clear()
+ UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)
+ check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" }
+ PythonProxySource.install()
+ PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)
+ }
+
+ @AfterTest
+ fun cleanup() {
+ UpcallTable.clear()
+ }
+
+ @Test
+ fun aBoxedValueClassFillsItsOwnParameter() {
+ Python3.exec(
+ """
+ from androidx.compose.ui.unit import TextUnit, TextUnitType
+ from fixture.artifact import equalsSp, describeValue
+
+ _sp = TextUnit(30, TextUnitType.Sp)
+ assert equalsSp(_sp._pm_handle, 30.0), 'TextUnit(30, TextUnitType.Sp) is not 30.sp: ' + describeValue(_sp._pm_handle) + ' / ' + describeValue(TextUnitType.Sp._pm_handle)
+ assert not equalsSp(_sp._pm_handle, 31.0), '30.sp matched 31.sp'
+ """.trimIndent(),
+ )
+ }
+
+ @Test
+ fun aRawNumberStillDoesNotFillAValueClassOffTheAllowlist() {
+ Python3.exec(
+ """
+ from androidx.compose.ui.unit import TextUnit
+
+ # The slot is an object slot now, so a bare number is read as a handle and Kotlin refuses it
+ # (no live handle, or a handle that is not a TextUnitType): it is never reinterpreted as
+ # the packed Long, which would be a TextUnitType of the wrong unit and no error.
+ try:
+ _result = TextUnit(30, 4294967296)
+ except Exception:
+ pass
+ else:
+ raise AssertionError('a raw number was accepted for TextUnitType: ' + repr(_result))
+ """.trimIndent(),
+ )
+ }
+}
diff --git a/python-multiplatform-gradle-plugin/fixtures/klib-artifact/build.gradle.kts b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/build.gradle.kts
new file mode 100644
index 000000000..7e8a8f00a
--- /dev/null
+++ b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/build.gradle.kts
@@ -0,0 +1,121 @@
+/**
+ * The artefact walker's klib half, end to end: a real third-party `.klib` the build resolves,
+ * walked at build time by `KlibScanner`, installed into the same `UpcallTable` KSP's own fragments
+ * go into.
+ *
+ * `:python-multiplatform-gradle-plugin-fixtures:artifact` is `docs/design/ecosystem.md` §5b's second producer over a **jar** -- this is
+ * the same producer over a **klib**. Kotlin/Native targets rather than `desktop`: a jar is what a JVM
+ * target resolves, a klib is what a Kotlin/Native target resolves, and `PythonArtifactBindingsTask
+ * .artifactConfiguration`/`.artifactSourceSet` are single-valued (ROADMAP §16f #1) -- one module can
+ * wire one walked configuration, so this fixture is the klib producer's own module rather than a
+ * second target bolted onto `:artifact`. Since issue #234 it builds `androidNativeArm64` and
+ * `iosSimulatorArm64`: the androidNativeArm64 klib is walked, the table is generated into the shared
+ * `nativeMain`, and the suite in `nativeTest` runs through Gradle on the simulator.
+ *
+ * `kotlinx-coroutines-core` is the klib walked, for the same reason JUnit is `:artifact`'s: a real,
+ * independently-versioned third-party artefact nothing here controls or cooperates with.
+ *
+ * ### On androidNativeArm64 the suite is linked by the build and run by hand (`iosSimulatorArm64Test` runs it)
+ *
+ * KGP gives `androidNativeArm64` a `linkDebugTestAndroidNativeArm64` and **no run task** -- it
+ * registers one only where it knows how to reach a host, and an Android device is neither the build
+ * machine nor `simctl`. `:python-multiplatform` solved that for itself with a hand-written
+ * `androidNativeArm64Test` task (device selection, `adb push --sync` of a 60 MB stdlib, a
+ * TeamCity-to-JUnit-XML parser) that is project-local. Until that is generalised, this module's
+ * suite runs like this, reusing the payload that task already stages on the device:
+ *
+ * ./gradlew :python-multiplatform:androidNativeArm64Test # once: stages lib/ and the stdlib
+ * ./gradlew :python-multiplatform-gradle-plugin-fixtures:klib-artifact:linkDebugTestAndroidNativeArm64
+ * D=/data/local/tmp/pmp-nativetest-arm64-v8a
+ * adb push python-multiplatform-gradle-plugin/fixtures/klib-artifact/build/bin/androidNativeArm64/debugTest/test.kexe \
+ * $D/klibtest.kexe
+ * adb shell "chmod 755 $D/klibtest.kexe && cd $D && \
+ * LD_LIBRARY_PATH=$D/lib PYTHONHOME=$D ./klibtest.kexe --ktest_logger=TEAMCITY"
+ *
+ * **A green ordinary build therefore says these tests compile and link, not that they pass.**
+ */
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ id("io.github.thisisthepy.python.multiplatform.bindings")
+}
+
+val pythonDownloadDir = project(":python-multiplatform").layout.buildDirectory.dir("python-standalone").get().asFile
+val fixturePythonVersion = project.findProperty("pythonVersion")?.toString() ?: project.rootProject.version.toString()
+val fixturePythonLibVersion = fixturePythonVersion.split('.').subList(0, 2).joinToString(".")
+
+kotlin {
+ // Applied now rather than after evaluation, so `nativeMain`/`nativeTest` exist for the blocks below
+ // and for `pythonBindings.artifactSourceSet` (issue #234).
+ applyDefaultHierarchyTemplate()
+
+ androidNativeArm64 {
+ // Mirrors `:python-multiplatform-ksp:fixtures:app`'s own androidNativeArm64 linker config: the test binary needs
+ // the same CPython symbols resolved at final link time, since `python-multiplatform`'s klib
+ // only declares them via cinterop and does not itself embed a static libpython.
+ val targetExtractDir = "$pythonDownloadDir/extracted/$fixturePythonVersion/android-aarch64/prefix"
+ binaries.getTest(org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.DEBUG).linkerOpts.addAll(
+ listOf("-L$targetExtractDir/lib/", "-lpython$fixturePythonLibVersion", "-Wl,--allow-shlib-undefined"),
+ )
+ }
+ // Issue #234: the simulator target, so the suite runs through Gradle (`iosSimulatorArm64Test`)
+ // rather than only being linked. Linked the way `:python-multiplatform`'s own simulator test
+ // binary is: against the xcframework's simulator slice, with an explicit `-rpath`, because
+ // `Python.framework` has an @rpath-relative install name and is not copied beside the binary.
+ iosSimulatorArm64 {
+ val slice = "$pythonDownloadDir/extracted/$fixturePythonVersion/ios/Python.xcframework/ios-arm64_x86_64-simulator"
+ binaries.getTest(org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.DEBUG).linkerOpts.addAll(
+ listOf("-framework", "Python", "-F$slice", "-rpath", slice),
+ )
+ }
+
+ sourceSets {
+ val commonMain by getting {
+ dependencies {
+ implementation(projects.pythonMultiplatform)
+ }
+ }
+ // Both native targets share the walked table and the suite (issue #234): the table is
+ // generated into `nativeMain`, and the tests live in `nativeTest`.
+ val nativeMain by getting {
+ dependencies {
+ // A real, separately-published klib. Nothing about it knows it is going to be
+ // walked -- KlibScanner reads its ABI the same way it would read anyone else's.
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
+ }
+ }
+ val nativeTest by getting {
+ dependencies {
+ implementation(libs.kotlin.test)
+ }
+ }
+ }
+}
+
+// The iOS `Python.framework` carries no standard library; `:python-multiplatform` unpacks the
+// simulator's, and the test binary is pointed at it the way that module points its own.
+tasks.withType().configureEach {
+ dependsOn(":python-multiplatform:extractIosSimulatorStdlib")
+ val pythonHome = project(":python-multiplatform").layout.buildDirectory.dir("python-stdlib/ios-simulator").get().asFile.absolutePath
+ // simctl only forwards variables into the spawned process when they carry this prefix.
+ environment("SIMCTL_CHILD_PYTHONHOME", pythonHome)
+ environment("PYTHONHOME", pythonHome)
+}
+
+pythonBindings {
+ role.set("app")
+ processor.set(projects.pythonMultiplatformKsp)
+
+ // The androidNativeArm64 klib is walked (what `KlibScannerTest` walks too); the table lands in
+ // `nativeMain`, so the simulator target compiles the same calls against its own klib.
+ artifactConfiguration.set("androidNativeArm64CompileKlibraries")
+ artifactSourceSet.set("nativeMain")
+ // Broad on purpose, unlike `:artifact`'s narrow `junit.runner`/`junit.framework`: coroutines'
+ // public surface is almost entirely `CoroutineScope`/`Job`/`Flow`-typed extensions, plus a couple
+ // of `@PublishedApi internal` declarations that are binary-visible but not source-visible outside
+ // the library that owns them. Those stay declined; since issue #234 the factories whose types are
+ // the klib's own classes bind (`Job`, `Mutex`, ...), and `WalkedKlibArtifactTableTest` pins the list.
+ // An earlier version bound the `@PublishedApi internal` one and failed to compile the first time
+ // its output was actually built rather than only unit-tested -- `KlibScanWorkAction`'s KDoc has
+ // the story. See that file's own KDoc for what the walker declines and why.
+ artifactIncludePackages.set(listOf("kotlinx.coroutines"))
+}
diff --git a/ksp-fixtures/klib-artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt
similarity index 88%
rename from ksp-fixtures/klib-artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt
rename to python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt
index 363edd21e..276ce3031 100644
--- a/ksp-fixtures/klib-artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt
+++ b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt
@@ -7,7 +7,7 @@ package fixture.artifact
* `ArtifactTable`.
*
* **Kept, not deleted with the rest of the copied-from-`:artifact` fixture.** Everything else this
- * module inherited from `:ksp-fixtures:artifact` was a *jar*-path duplicate and went, but this file
+ * module inherited from `:python-multiplatform-gradle-plugin-fixtures:artifact` was a *jar*-path duplicate and went, but this file
* is not about jars: `pythonBindings.role.set("app")` makes this module the aggregator, and an
* aggregator with no source declarations of its own emits an **empty** `FunctionTable`. That
* compiles and passes, and it quietly stops the fixture from demonstrating the one property
diff --git a/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactHandleTest.kt b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactHandleTest.kt
new file mode 100644
index 000000000..4d5b9271e
--- /dev/null
+++ b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactHandleTest.kt
@@ -0,0 +1,84 @@
+package fixture.artifact
+
+import python.multiplatform.ffi.Python3
+import python.multiplatform.ffi.upcall.PythonProxySource
+import python.multiplatform.generated.FunctionTable
+import python.multiplatform.generated.artifacts.ArtifactTable
+import python.multiplatform.reflection.HandleTable
+import python.multiplatform.reflection.ObjectReference
+import python.multiplatform.reflection.UpcallTable
+import python.native.ffi.UpcallEntry
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+/**
+ * Issue #234 on Kotlin/Native: a Kotlin object a walked klib declaration returns crosses as a handle,
+ * goes back into Kotlin as that object, and follows the jar side's lifetime rules -- the common
+ * trampoline's and `HandleTable`'s, which the klib boundary uses unchanged.
+ *
+ * - A handle Python holds is one `HandleTable` slot; passing it back into Kotlin (`Job(parent)`)
+ * neither releases it nor takes a second slot.
+ * - Dropping every Python reference releases each slot exactly once: the live count returns to its
+ * baseline, the raw handle no longer resolves, and a second release is refused (no double release).
+ *
+ * Red before #234: `Job` is not importable, because the walker declined every `kotlinx.coroutines`
+ * declaration.
+ */
+class WalkedKlibArtifactHandleTest {
+
+ @BeforeTest
+ fun installTheWalkersTableIntoAnInterpreter() {
+ Python3.initialize(silent = true)
+ UpcallTable.clear()
+ UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)
+ val mainGlobals = Python3.import("__main__").getAttr("__dict__")
+ check(UpcallEntry.publish(mainGlobals.pointer)) { "the upcall bootstrap could not be published" }
+ PythonProxySource.install()
+ }
+
+ @AfterTest
+ fun cleanup() {
+ UpcallTable.clear()
+ }
+
+ @Test
+ fun aHandleGoesBackIntoKotlinAndIsReleasedExactlyOnce() {
+ Python3.exec("import gc\ngc.collect()")
+ val baseline = HandleTable.liveCount
+ Python3.exec(
+ """
+ from kotlinx.coroutines import Job
+ _kh_parent = Job(None)
+ _kh_raw = _kh_parent._pm_handle
+ _kh_children = [Job(_kh_parent) for _ in range(3)]
+ assert _kh_parent._pm_handle == _kh_raw, 'passing the handle into Kotlin changed it'
+ """.trimIndent(),
+ )
+ // One slot for the parent and one per child: passing the parent in took no slot of its own.
+ assertEquals(baseline + 4, HandleTable.liveCount)
+ val raw = Python3.import("__main__").getAttr("_kh_raw").toString().toLong()
+ assertNotNull(HandleTable.resolveRaw(raw), "the parent's handle should still resolve while Python holds it")
+
+ Python3.exec("del _kh_parent, _kh_children\nimport gc\ngc.collect()")
+ assertEquals(baseline, HandleTable.liveCount, "every slot is released exactly once")
+ assertNull(HandleTable.resolveRaw(raw), "a released handle must not resolve")
+ assertFalse(HandleTable.release(ObjectReference(raw)), "a second release must be refused")
+ }
+
+ @Test
+ fun aNullableHandleParameterTakesNone() {
+ Python3.exec(
+ """
+ from kotlinx.coroutines import Job
+ _kh_job = Job(None)
+ assert _kh_job is not None
+ del _kh_job
+ """.trimIndent(),
+ )
+ }
+}
diff --git a/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactMemberFunctionTest.kt b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactMemberFunctionTest.kt
new file mode 100644
index 000000000..b3c1ee771
--- /dev/null
+++ b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactMemberFunctionTest.kt
@@ -0,0 +1,73 @@
+package fixture.artifact
+
+import python.multiplatform.ffi.Python3
+import python.multiplatform.ffi.pythonx.PythonxAdapter
+import python.multiplatform.ffi.upcall.PythonProxySource
+import python.multiplatform.generated.FunctionTable
+import python.multiplatform.generated.artifacts.ArtifactTable
+import python.multiplatform.reflection.UpcallTable
+import python.native.ffi.UpcallEntry
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+/**
+ * Issue #237 on Kotlin/Native: member functions of the walked klib's interfaces, called from Python on
+ * the handles its factories return, through the generated table and the binding layer -- #215's path,
+ * from a klib.
+ *
+ * - `Job(None)` is a `CompletableJob`: `complete()` is `True` the first time and `False` after, and
+ * `start()`, declared on its supertype `Job`, is reached through the ancestry the table carries.
+ * - A `Semaphore` of two permits gives two and refuses a third until one is released.
+ *
+ * Red before #237: `complete` and `try_acquire` are `AttributeError`s, because no member was bound.
+ */
+class WalkedKlibArtifactMemberFunctionTest {
+
+ @BeforeTest
+ fun installBothLayers() {
+ Python3.initialize(silent = true)
+ UpcallTable.clear()
+ UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)
+ val mainGlobals = Python3.import("__main__").getAttr("__dict__")
+ check(UpcallEntry.publish(mainGlobals.pointer)) { "the upcall bootstrap could not be published" }
+ PythonProxySource.install()
+ PythonxAdapter.install(emptySet())
+ }
+
+ @AfterTest
+ fun cleanup() {
+ UpcallTable.clear()
+ }
+
+ @Test
+ fun aJobsMembersRunOnTheKotlinJob() {
+ Python3.exec(
+ """
+ from kotlinx.coroutines import Job
+ _km_job = Job(None)
+ assert _km_job.complete() is True, 'complete() did not complete the job'
+ assert _km_job.complete() is False, 'a completed job completed twice'
+ _km_lazy = Job(None)
+ assert _km_lazy.start() is False, 'Job() is already active, so start() is False'
+ del _km_job, _km_lazy
+ """.trimIndent(),
+ )
+ }
+
+ @Test
+ fun aSemaphoresPermitsAreCountedByKotlin() {
+ Python3.exec(
+ """
+ from kotlinx.coroutines.sync import Semaphore
+ _km_s = Semaphore(2, 0)
+ assert _km_s.try_acquire() is True
+ assert _km_s.try_acquire() is True
+ assert _km_s.try_acquire() is False, 'a third permit out of two'
+ _km_s.release()
+ assert _km_s.try_acquire() is True, 'a released permit was not given back'
+ del _km_s
+ """.trimIndent(),
+ )
+ }
+}
diff --git a/ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactPythonImportTest.kt b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactPythonImportTest.kt
similarity index 97%
rename from ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactPythonImportTest.kt
rename to python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactPythonImportTest.kt
index 388e20a0f..a9e748d4c 100644
--- a/ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactPythonImportTest.kt
+++ b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactPythonImportTest.kt
@@ -14,7 +14,7 @@ import kotlin.test.Test
* The whole klib path in one interpreter, on a **Kotlin/Native** target: a `.klib` the build
* resolved -> `KlibScanner` -> an (empty) `ArtifactTable` -> `UpcallTable` -> `PythonProxySource`.
*
- * `:ksp-fixtures:artifact`'s `WalkedArtifactPythonImportTest` made a *positive* version of this claim
+ * `:python-multiplatform-gradle-plugin-fixtures:artifact`'s `WalkedArtifactPythonImportTest` made a *positive* version of this claim
* for a **jar** on **desktop**: a bound declaration reaches Python and is callable. This class makes
* the negative half instead -- see `WalkedKlibArtifactTableTest`'s KDoc for why `kotlinx-coroutines
* -core`'s klib half of the shared namespace is correctly *empty*, not merely small. Both matter: a
diff --git a/ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactTableTest.kt b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactTableTest.kt
similarity index 64%
rename from ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactTableTest.kt
rename to python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactTableTest.kt
index 77c635a40..eb4325e4a 100644
--- a/ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactTableTest.kt
+++ b/python-multiplatform-gradle-plugin/fixtures/klib-artifact/src/nativeTest/kotlin/fixture/artifact/WalkedKlibArtifactTableTest.kt
@@ -21,7 +21,7 @@ import kotlin.test.assertTrue
* among them) that `KlibScanner` **used to** bind before this suite ever ran on a device: it is
* binary-visible so an inline call site inside `kotlinx-coroutines-core` itself can resolve it, but
* not source-visible from a generated fragment in a *different* module, and
- * `:ksp-fixtures:klib-artifact:compileKotlinAndroidNativeArm64` failing with "it is internal in file"
+ * `:python-multiplatform-gradle-plugin-fixtures:klib-artifact:compileKotlinAndroidNativeArm64` failing with "it is internal in file"
* -- the first time this walker's generated Kotlin was ever actually compiled rather than only
* unit-tested -- is what caught it. `KlibScanWorkAction`'s KDoc has the full story; `KlibScannerTest
* .aPublishedApiInternalDeclarationIsDeclinedWithItsOwnReason` pins the fix on the JVM side, and this
@@ -55,30 +55,43 @@ class WalkedKlibArtifactTableTest {
}
/**
- * The fixed text: everything `KlibScanner` bound out of the whole `kotlinx-coroutines-core`
- * klib under the `kotlinx.coroutines` namespace -- nothing, correctly. This class's own KDoc has
- * the reason; `KlibScannerTest.aRealThirdPartyKlibIsScannedWithoutCrashingAndDeclinesEveryDeclaration`
- * pins the same fact walking the klib directly rather than through the build. If the two ever
- * disagree, the wiring between `KlibScanner` and this generated table is what broke.
- *
- * A generated fragment file is only ever written when a walked artefact contributes at least one
- * entry (`PythonArtifactBindingsTask.generate`'s `if (entries.isEmpty()) return@forEach`), so an
- * empty klib means `ArtifactTable.fragments` itself has zero elements -- not one fragment with an
- * empty `entries()`.
+ * The fixed text: everything `KlibScanner` bound out of the whole `kotlinx-coroutines-core` klib
+ * under the `kotlinx.coroutines` namespace. Zero before issue #234, when no Kotlin object could
+ * cross the klib boundary; since then the factories whose types are the klib's own classes, and
+ * since #237 the members of its interfaces and final classes.
+ * `KlibScannerTest.aRealThirdPartyKlibIsScannedWithoutCrashingAndBindsItsHandleFactoriesAndMembers` pins the
+ * same list walking the klib directly; if the two disagree, the wiring between `KlibScanner` and
+ * this generated table is what broke.
*/
@Test
- fun theWalkerCorrectlyDeclinesEveryDeclarationInTheKlib() {
- assertEquals(emptyList(), ArtifactTable.fragments)
+ fun theWalkerBindsExactlyTheKlibsHandleFactoriesAndMembers() {
ArtifactTable.registerInto()
- assertEquals(emptyList(), UpcallTable.entries())
+ assertEquals(
+ listOf(
+ "kotlinx.coroutines.ChildJob.parentCancelled",
+ "kotlinx.coroutines.CompletableJob.complete",
+ "kotlinx.coroutines.DisposableHandle.dispose",
+ "kotlinx.coroutines.Job",
+ "kotlinx.coroutines.Job.attachChild",
+ "kotlinx.coroutines.Job.start",
+ "kotlinx.coroutines.MainScope",
+ "kotlinx.coroutines.Runnable.run",
+ "kotlinx.coroutines.SupervisorJob",
+ "kotlinx.coroutines.TimeoutCancellationException.createCopy",
+ "kotlinx.coroutines.newFixedThreadPoolContext",
+ "kotlinx.coroutines.newSingleThreadContext",
+ "kotlinx.coroutines.sync.Mutex",
+ "kotlinx.coroutines.sync.Semaphore",
+ "kotlinx.coroutines.sync.Semaphore.release",
+ "kotlinx.coroutines.sync.Semaphore.tryAcquire",
+ ),
+ UpcallTable.entries().map { it.name }.filter { it.startsWith("kotlinx.") }.sorted(),
+ )
}
- /**
- * `registerInto` on an empty `ArtifactTable` must not fail, and must not resolve a name that was
- * never bound -- the two failure modes a table with zero fragments could otherwise hide.
- */
+ /** `registerInto` must not resolve a name that was never bound: `checkIndexOverflow` stays declined. */
@Test
- fun registeringAnEmptyTableIsHarmlessAndResolvesNothing() {
+ fun aDeclinedNameStillResolvesToNothing() {
ArtifactTable.registerInto()
val entry = UpcallTable.resolve("kotlinx.coroutines.flow.internal.checkIndexOverflow")
assertTrue(!entry.isValid, "checkIndexOverflow must not resolve: it is @PublishedApi internal and must stay declined")
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosPythonHome.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosPythonHome.kt
index dce8d74a4..5fef66032 100644
--- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosPythonHome.kt
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosPythonHome.kt
@@ -33,7 +33,7 @@ import java.nio.file.StandardCopyOption
*
* `build/utils.sh`'s `install_stdlib` merges the shared tree with one `lib-$ARCHS` tree into the app;
* this does the same merge at Gradle time, into a directory per slice, so the Xcode phase
- * (`tools/xcode/install-python.sh`) copies a finished prefix and needs no path into the archive.
+ * (`python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh`) copies a finished prefix and needs no path into the archive.
* Pure functions are separated out so the layout is pinned by tests without downloading anything.
*/
object IosPythonHomeLayout {
@@ -100,7 +100,7 @@ object IosPythonHomeLayout {
val platform = effectivePlatformName?.trim()?.removePrefix("-")
require(!platform.isNullOrEmpty()) {
"EFFECTIVE_PLATFORM_NAME is not set. This task picks the iOS slice from the settings Xcode " +
- "exports to a Run Script phase; run it from one (tools/xcode/install-python.sh does)."
+ "exports to a Run Script phase; run it from one (python-multiplatform-gradle-plugin/src/main/resources/xcode/install-python.sh does)."
}
val archList = archs?.trim()?.split(Regex("\\s+"))?.filter { it.isNotEmpty() }.orEmpty()
require(archList.isNotEmpty()) { "ARCHS is not set; run this from an Xcode Run Script phase." }
@@ -219,7 +219,7 @@ abstract class StageIosPythonHomeTask : DefaultTask() {
val home = File(root, PREFIX_DIRECTORY)
// Whole directory replaced: a file dropped from the plan (an exclusion added, an upstream
// module removed) must not survive from the previous run into the next app.
- home.deleteRecursively()
+ deleteWithoutFollowingLinks(home)
for ((relative, source) in plan) {
val target = File(home, relative)
target.parentFile.mkdirs()
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/NoFollowDelete.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/NoFollowDelete.kt
new file mode 100644
index 000000000..88d3ab44e
--- /dev/null
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/NoFollowDelete.kt
@@ -0,0 +1,32 @@
+package python.multiplatform.gradle
+
+import java.io.File
+import java.io.IOException
+import java.nio.file.FileVisitResult
+import java.nio.file.Files
+import java.nio.file.SimpleFileVisitor
+import java.nio.file.attribute.BasicFileAttributes
+
+/**
+ * Deletes [path] and everything under it without following symbolic links (issue #227).
+ *
+ * Kotlin's `File.deleteRecursively` descends into a directory symlink and deletes what it points
+ * at. `Files.walkFileTree` without `FOLLOW_LINKS` reports a link as a file-like entry, so the link
+ * itself is removed and its target is left alone. A missing path is not an error.
+ */
+internal fun deleteWithoutFollowingLinks(path: File) {
+ val start = path.toPath()
+ if (!Files.exists(start, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
+ Files.walkFileTree(start, object : SimpleFileVisitor() {
+ override fun visitFile(file: java.nio.file.Path, attrs: BasicFileAttributes): FileVisitResult {
+ Files.delete(file)
+ return FileVisitResult.CONTINUE
+ }
+
+ override fun postVisitDirectory(dir: java.nio.file.Path, exc: IOException?): FileVisitResult {
+ if (exc != null) throw exc
+ Files.delete(dir)
+ return FileVisitResult.CONTINUE
+ }
+ })
+}
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt
index 01aa4a30c..38bcb9fe1 100644
--- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt
@@ -12,7 +12,7 @@ import python.multiplatform.gradle.stubs.PythonStubsTask
* KSP option names. Copied rather than imported: these are the processor's
* `python.multiplatform.ksp.Constants`, and this plugin ships as a separate build so that a
* consumer resolves it from the plugin portal without also resolving the processor's compile
- * classpath. Changing one without the other shows up in `ksp-fixtures` as an unwired module.
+ * classpath. Changing one without the other shows up in the `fixtures/` modules as an unwired module.
*/
private const val OPTION_ROLE = "python.multiplatform.role"
private const val OPTION_MODULE_NAME = "python.multiplatform.moduleName"
@@ -53,7 +53,7 @@ private const val TYPEDPYTHON_STUBS_CONFIGURATION = "typedpythonStubs"
* and a second `FunctionTable` -- into the test compilation, where they shadowed the real ones
* from `main`, because a compilation's own generated sources win over its classpath. The symptom
* is a table holding the test classes and nothing the module actually exposes.
- * `ksp-fixtures/android` is what surfaced this; no module without an Android plugin can.
+ * `python-multiplatform-ksp/fixtures/android` is what surfaced this; no module without an Android plugin can.
*
* `Test` is therefore matched as a camel-case *word* rather than as a suffix: it must start a word
* and end one. A target or product flavour genuinely named `testing` is not a test compilation and
@@ -96,7 +96,7 @@ internal fun deriveModuleName(group: String, path: String, name: String): String
* package, so two of them collide. Gradle has no general notion of "the module that ships", and
* a Kotlin Multiplatform module that builds a framework or a native executable carries no plugin
* that distinguishes it -- hence the explicit `role` override on the extension, which is what
- * `ksp-fixtures/app` uses.
+ * `python-multiplatform-ksp/fixtures/app` uses.
*/
internal fun inferRole(hasApplicationPlugin: Boolean, hasAndroidApplicationPlugin: Boolean): String =
if (hasApplicationPlugin || hasAndroidApplicationPlugin) ROLE_APP else ROLE_LIBRARY
@@ -121,7 +121,7 @@ interface PythonBindingsExtension {
/**
* Whether to stage a CPython prefix for this machine and point `PYTHONHOME` at it on every
- * `JavaExec` and `Test` task. Defaults to true — see [PythonHomeStaging] for why the library
+ * `JavaExec` and `Test` task. Defaults to true, see [PythonHomeStaging] for why the library
* cannot supply one at runtime, and ROADMAP §15e item 4 for the gap it closes.
*
* It is already a no-op whenever `PYTHONHOME` is set in the environment, so setting this to
@@ -145,6 +145,14 @@ interface PythonBindingsExtension {
*/
val packagePythonHome: Property
+ /**
+ * SPEC L-9 (issue #246): the directory a GraalVM native image of this app is written to. When it
+ * is set, `stageNativeImagePythonHome` copies the same pruned prefix Compose packaging gets into
+ * `/python-multiplatform-home`, where the library finds it at run time beside the
+ * executable. Unset, the task copies nothing.
+ */
+ val nativeImageDirectory: org.gradle.api.file.DirectoryProperty
+
/** CPython version to stage. Defaults to the one this library was built against. */
val pythonVersion: Property
@@ -186,7 +194,7 @@ interface PythonBindingsExtension {
* Named explicitly rather than derived, because this plugin carries no Kotlin Gradle Plugin
* types (see [TEST_WORD]) and so cannot ask a target what its compile classpath is called. One
* configuration and one source set is also the honest scope of what has been verified: see
- * `ksp-fixtures/artifact`, and ROADMAP §16 for the generalisation.
+ * `python-multiplatform-gradle-plugin/fixtures/artifact`, and ROADMAP §16 for the generalisation.
*/
val artifactConfiguration: Property
@@ -362,6 +370,7 @@ class PythonBindingsPlugin : Plugin {
},
)
includePrefixes.set(includes)
+ excludePackages.set(extension.excludePackages.getOrElse(emptyList()))
this.klibReaderClasspath.from(klibReader)
outputDirectory.set(
project.layout.buildDirectory.dir("generated/pythonArtifactBindings/$sourceSetName"),
@@ -508,6 +517,7 @@ class PythonBindingsPlugin : Plugin {
description = "Emits .pyi stubs for the declarations the artefact walker bound."
artifacts.from(resolved.artifactFiles)
includePrefixes.set(includes)
+ excludePackages.set(extension.excludePackages.getOrElse(emptyList()))
klibReaderClasspath.from(klibReader)
outputDirectory.set(project.layout.buildDirectory.dir("generated/pythonStubs/$sourceSetName"))
}
@@ -571,7 +581,7 @@ class PythonBindingsPlugin : Plugin {
* The prefix path is computed here rather than carried as a `Provider`, and it can be: it is a
* pure function of the version, the release, the host and the Gradle user home, none of which
* need the task to have run. That keeps `environment(...)` a plain string set at configuration
- * time — the value is in the child process's environment before its JVM starts, which is the
+ * time, the value is in the child process's environment before its JVM starts, which is the
* only way CPython's `getenv(3)` and [python.multiplatform.env.PythonHomeCheck]'s
* `System.getenv` can be guaranteed to read the same thing.
*
@@ -682,6 +692,21 @@ class PythonBindingsPlugin : Plugin {
requireNoEmptyLibraries(destinationDir.resolve(PACKAGED_HOME_DIRECTORY))
}
}
+
+ // SPEC L-9 (issue #246): the same prefix beside a native image, wherever the app writes one.
+ val besideImage = project.provider { extension.nativeImageDirectory.isPresent }
+ project.tasks.register("stageNativeImagePythonHome", org.gradle.api.tasks.Sync::class.java) {
+ group = "python"
+ description = "Copies the CPython prefix beside a GraalVM native image (pythonBindings.nativeImageDirectory)."
+ dependsOn(java.util.concurrent.Callable { if (besideImage.get()) listOf(stage) else emptyList() })
+ from(java.util.concurrent.Callable { if (besideImage.get()) stagedPrefix else emptyList() }) {
+ include(includes)
+ exclude(PACKAGED_PREFIX_EXCLUDES)
+ }
+ into(extension.nativeImageDirectory.dir(PACKAGED_HOME_DIRECTORY).orElse(project.layout.buildDirectory.dir("tmp/stageNativeImagePythonHome-unset")))
+ onlyIf("pythonBindings.nativeImageDirectory is set") { besideImage.get() }
+ doLast { requireNoEmptyLibraries(destinationDir) }
+ }
}
/**
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonHomeStaging.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonHomeStaging.kt
index ce08d41c5..a952d6f1d 100644
--- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonHomeStaging.kt
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonHomeStaging.kt
@@ -15,14 +15,14 @@ import java.security.MessageDigest
import javax.inject.Inject
/**
- * Staging a CPython prefix for a consumer's build — ROADMAP §15e item 4.
+ * Staging a CPython prefix for a consumer's build, ROADMAP §15e item 4.
*
* ### The gap this closes
*
* §15c published the library to `mavenLocal()` and built a genuine external JVM consumer against
* it. Everything worked *except* start-up: `desktopJar` carries `libpython` for four host
* platforms under `lib//`, and `manager.loadLibPython` finds it there with no wiring at
- * all — but it carries **no standard library**, and `Py_Initialize()` dies with
+ * all, but it carries **no standard library**, and `Py_Initialize()` dies with
* `Fatal Python error: Failed to import encodings module` until `PYTHONHOME` names a prefix that
* has one. Verified rather than read: the published jar's `lib/` tree is 14 entries, every one of
* them a shared library.
@@ -45,17 +45,17 @@ import javax.inject.Inject
* instead of being multiplied into an artifact every consumer downloads. That matters here more
* than usual: `desktopJar` is already 87.7 MB for four platforms' `libpython`, and the four
* platforms' stdlibs measure a further 43.5 MB compressed (macos-aarch64 8.1, macos-x86_64 7.1,
- * linux-x86_64 8.2, windows-x86_64 20.1) — for payload of which any one consumer uses exactly
+ * linux-x86_64 8.2, windows-x86_64 20.1), for payload of which any one consumer uses exactly
* one quarter. Putting per-platform payload into the artifact every platform resolves is the
* defect §15d diagnosed when `allMetadataJar` reached 87.4 MB, and it was fixed by *narrowing*
* scope, not by accepting the size.
* - **`PYTHONHOME` cannot be set from inside a running JVM.** CPython reads it with `getenv(3)`,
* from the native process environment. Android's helper calls `Os.setenv`; the JVM has no
- * equivalent — `System.getenv` is an immutable snapshot taken at start-up, and changing it (by
+ * equivalent, `System.getenv` is an immutable snapshot taken at start-up, and changing it (by
* reflection or otherwise) does not touch the native environment CPython actually reads. A
* runtime helper would therefore have to reach libc's `setenv` through Panama, which is a
* different symbol on Windows (`_putenv_s`), and would then be setting a value that
- * [python.multiplatform.env.PythonHomeCheck] — which reads `System.getenv` — could no longer
+ * [python.multiplatform.env.PythonHomeCheck], which reads `System.getenv`, could no longer
* see. Setting it when the *process is launched*, which is what Gradle's `environment(...)`
* does, has neither problem: CPython and `PythonHomeCheck` read the same value from the same
* place, exactly as they already do for this repository's own `desktopTest`.
@@ -95,7 +95,7 @@ internal object PythonHomeStaging {
* `linux-aarch64` is included even though `desktopJar` carries no `libpython` for it, and that is
* not an oversight: `manager.loadFromSidecar` falls back to loading the interpreter *out of
* `PYTHONHOME`* whenever the classpath has no library for the running platform, so a staged prefix
- * is the whole of what that host needs. Staging is the only reason it can work at all — this is
+ * is the whole of what that host needs. Staging is the only reason it can work at all, this is
* not verified on such a host, see this file's own note in ROADMAP §15g.
*/
internal fun hostDesktopPlatform(osName: String, osArch: String): String? {
@@ -172,8 +172,8 @@ internal fun sha256Of(sha256sums: String, assetName: String): String? =
/**
* A file that must be readable under a prefix for it to be usable, relative to the prefix.
*
- * This is exactly what `PythonHomeCheck.diagnose` probes — `lib/python/os.py`, or `Lib/os.py`
- * on the layout CPython's Windows build produces — so a prefix this staging accepts is one
+ * This is exactly what `PythonHomeCheck.diagnose` probes, `lib/python/os.py`, or `Lib/os.py`
+ * on the layout CPython's Windows build produces, so a prefix this staging accepts is one
* `Python3.initialize()` accepts. Asserting a named file rather than "the directory is not empty"
* is §15f's lesson: the root build's own extraction step skips when `extractDir.list()` is
* non-empty, which is true of a tree whose extraction was interrupted.
@@ -190,7 +190,7 @@ internal fun stdlibMarkerRelativePath(pythonVersion: String, freeThreaded: Boole
* Identifies precisely which distribution was extracted.
*
* Written after the last byte and deleted before a rewrite begins, so it cannot be true of a
- * partial tree — §15f found three hand-written Android copies that each probed a single entry of
+ * partial tree, §15f found three hand-written Android copies that each probed a single entry of
* the result and so accepted a tree whose copy had been interrupted.
*
* It names the upstream release as well as the Python version because astral republishes the same
@@ -397,8 +397,8 @@ internal fun isComposeAppResourcesTask(name: String): Boolean =
/**
* Whether the plugin should stage a prefix and set `PYTHONHOME`.
*
- * A consumer who has already set `PYTHONHOME` — at a system CPython, at a conda prefix, at a build
- * they compiled themselves — keeps it. Replacing it would turn a working setup into a
+ * A consumer who has already set `PYTHONHOME`, at a system CPython, at a conda prefix, at a build
+ * they compiled themselves, keeps it. Replacing it would turn a working setup into a
* differently-configured one on a library upgrade, and §15c's whole finding is that setting it by
* hand is what consumers have had to do until now.
*/
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmBrowserRuntimeStaging.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmBrowserRuntimeStaging.kt
index b4d83da2f..8ba46eca6 100644
--- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmBrowserRuntimeStaging.kt
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmBrowserRuntimeStaging.kt
@@ -25,7 +25,7 @@ import javax.inject.Inject
* exists only inside this repository's checkout. An external consumer has no such directory and no
* way to get one -- the CPython build it names is produced by hand-patching CPython's own source
* (`Lib/sysconfig`, the generated `Makefile`) against a specific Emscripten SDK version, which is
- * not something any upstream publishes yet. See `tools/wasm/build-cpython.sh`'s own header for why: PEP
+ * not something any upstream publishes yet. See `python-multiplatform/scripts/wasm/build-cpython.sh`'s own header for why: PEP
* 783's `pyemscripten_2026_0` tag is real and accepted, but nothing ships a `python.wasm` whose
* `EXPORTED_RUNTIME_METHODS` include `wasmExports`/`wasmMemory` -- that addition exists only so this
* library's `@WasmImport` declarations have something to bind to, and no distributor has a reason to
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmProxyExportsRendering.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmProxyExportsRendering.kt
index b64e9f0a9..bf7653a52 100644
--- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmProxyExportsRendering.kt
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmProxyExportsRendering.kt
@@ -29,7 +29,7 @@ internal const val WASM_PROXY_EXPORTS_FILE_NAME = "WasmProxyTypeExports"
* ### Why this must be generated into the executable, not the library
*
* `@WasmExport` is honoured only in the compilation that produces the `.wasm` binary. A library
- * klib linked in does not carry the annotation into the binary's export section — measured, not
+ * klib linked in does not carry the annotation into the binary's export section, measured, not
* assumed: the identical annotation on the identical function exported from `wasmJsTest` and did
* not export from `wasmJsMain`. So the three lines cannot live in the library; they must be
* emitted into each executable compilation that embeds it.
@@ -45,9 +45,9 @@ internal const val WASM_PROXY_EXPORTS_FILE_NAME = "WasmProxyTypeExports"
* exactly match the C slot signature it fills raises `RuntimeError: null function or function
* signature mismatch` at runtime. The three C signatures are:
*
- * int tp_traverse(PyObject *self, visitproc visit, void *arg) — (i32,i32,i32) -> i32
- * int tp_clear (PyObject *self) — (i32) -> i32
- * void tp_dealloc (PyObject *self) — (i32) -> ()
+ * int tp_traverse(PyObject *self, visitproc visit, void *arg) , (i32,i32,i32) -> i32
+ * int tp_clear (PyObject *self) , (i32) -> i32
+ * void tp_dealloc (PyObject *self) , (i32) -> ()
*
* [packageName] is the package the generated file is placed in, matching the executable module's
* choice. It is the only variable part of the output.
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt
index 23dfffb47..427dbcbc7 100644
--- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt
@@ -4,7 +4,9 @@ import kotlin.metadata.ClassKind
import kotlin.metadata.KmClassifier
import kotlin.metadata.KmType
import kotlin.metadata.KmVariance
+import kotlin.metadata.MemberKind
import kotlin.metadata.Visibility
+import kotlin.metadata.isFunInterface
import kotlin.metadata.isNullable
import kotlin.metadata.isSuspend
import kotlin.metadata.isValue
@@ -42,7 +44,7 @@ import java.util.jar.JarFile
*
* | PyREPL's filter | here | why |
* |---|---|---|
- * | not `private`/`protected` | **not enough** — must be `public`, and Kotlin-`public` rather than only JVM-`public` | PyREPL keeps package-private members, which is harmless in a stub; `internal` is JVM-public but not a name generated Kotlin may call |
+ * | not `private`/`protected` | **not enough**, must be `public`, and Kotlin-`public` rather than only JVM-`public` | PyREPL keeps package-private members, which is harmless in a stub; `internal` is JVM-public but not a name generated Kotlin may call |
* | drop `` | kept | a constructor needs a `ReflectedClass` and a receiver handle, which is the next step |
* | drop `Companion` | subsumed | only statics are bound, and `Companion` is an instance field |
* | drop names containing `-` | **not kept** | see below |
@@ -196,9 +198,16 @@ internal object ArtifactScanner {
* test fixtures, false for `androidx.compose.foundation.layout`, whose `Dp` lives in
* `androidx.compose.ui.unit`. [PythonArtifactBindingsTask] passes the whole resolved
* configuration.
+ * @param excludePackages packages to leave out, on top of every `internal` package (SPEC B-11):
+ * an entry covers the package it names and the ones below it.
*/
- fun scanJar(target: File, includePrefixes: List = emptyList(), classpath: List = listOf(target)): List =
- scan(target, includePrefixes, classpath).mapNotNull { it.callable }.sortedBy { it.name }
+ fun scanJar(
+ target: File,
+ includePrefixes: List = emptyList(),
+ classpath: List = listOf(target),
+ excludePackages: List = emptyList(),
+ ): List =
+ scan(target, includePrefixes, classpath, excludePackages).mapNotNull { it.callable }.sortedBy { it.name }
/**
* The same walk, with the half `docs/design/pyi-generation-design.md` §2.2 asks for kept: one
@@ -214,11 +223,17 @@ internal object ArtifactScanner {
target: File,
includePrefixes: List = emptyList(),
classpath: List = listOf(target),
- ): List = scan(target, includePrefixes, classpath)
+ excludePackages: List = emptyList(),
+ ): List = scan(target, includePrefixes, classpath, excludePackages)
.map { it.declaration }
.sortedWith(compareBy({ it.owner }, { it.simpleName }, { it.bindingName ?: "" }))
- private fun scan(target: File, includePrefixes: List, classpath: List): List {
+ private fun scan(
+ target: File,
+ includePrefixes: List,
+ classpath: List,
+ excludePackages: List,
+ ): List {
val artifactClasspath = ArtifactClasspath(classpath)
val entries = mutableListOf()
forEachClassEntry(target) { relativePath, bytes ->
@@ -228,6 +243,7 @@ internal object ArtifactScanner {
if ('$' in relativePath) return@forEachClassEntry
val qualified = binaryNameToQualified(relativePath.removeSuffix(".class"))
if (!matchesInclude(qualified, includePrefixes)) return@forEachClassEntry
+ if (isExcludedPackage(packageNameOf(relativePath.removeSuffix(".class")), excludePackages)) return@forEachClassEntry
val node = readClassNodeOrNull(bytes) ?: return@forEachClassEntry
entries += scanClassNode(node, artifactClasspath)
}
@@ -246,9 +262,18 @@ internal object ArtifactScanner {
// neither claims a constructor's namespace (`TextStyle.color` must not cost `TextStyle(...)`
// its binding) nor joins an overload set. [settlePropertyNames] gives them the one check
// they do need, against the finished names of everything else.
- val (properties, functions) = entries.partition { it.declaration.kind in PROPERTY_KINDS }
+ //
+ // Member functions (issue #215) take no part in them either, for the property's reason: a
+ // `METHOD` is served on its receiver's proxy, so `Counter.add` must not cost `Counter(...)` its
+ // constructor (the prefix rule of [dropCollidingConstructors]), and a member is never an
+ // overload of a top-level function of the same name. Inside one class they are an overload set
+ // like any other, so [disambiguateOverloads] still names them; [settleMethodNames] is their one
+ // check against everything else.
+ val (properties, rest) = entries.partition { it.declaration.kind in PROPERTY_KINDS }
+ val (methods, functions) = rest.partition { it.declaration.kind == METHOD_KIND }
val settledFunctions = assignThunkIndices(disambiguateOverloads(applyDefaultOmission(dropCollidingConstructors(functions))))
- return (settledFunctions + settlePropertyNames(properties, settledFunctions))
+ val settledMethods = settleMethodNames(disambiguateOverloads(methods), settledFunctions)
+ return (settledFunctions + settledMethods + settlePropertyNames(properties, settledFunctions + settledMethods))
// The ancestry the binding carries is the model's too (issue #31): copied at the end, from the
// callable that was actually bound, so the two cannot be read differently.
.map { candidate ->
@@ -259,8 +284,9 @@ internal object ArtifactScanner {
// unwritable name, an ambiguous sibling): the binding then requires every argument
// while the stub would still offer `= ...`. One view, taken from the binding.
val omittable = omittableSlotsOf(callable)
- // A property's receiver is not one of its slots (`CallableKind.GETTER.hasReceiver`).
- val receiverIndex = if (callable.receiverTypeName != null && callable.kind !in PROPERTY_KINDS) 1 else 0
+ // A property's or a member's receiver is not one of its slots (`CallableKind.hasReceiver`);
+ // only an extension's is.
+ val receiverIndex = if (callable.receiverTypeName != null && callable.kind == "FUNCTION") 1 else 0
candidate.copy(
declaration = candidate.declaration.copy(
returnSupertypes = callable.returnSupertypes,
@@ -380,6 +406,15 @@ internal object ArtifactScanner {
private fun packageNameOf(binaryName: String): String =
binaryNameToQualified(binaryName.substringBeforeLast('/', missingDelimiterValue = ""))
+ /**
+ * SPEC B-11: a package is out when one of its segments is exactly `internal` (`*.internal`,
+ * `*.internal.*`, the Kotlin `.api` dump convention) or when it equals, or lies below, an
+ * `excludePackages` entry (the KSP side's namespace rule). Shared with [KlibScanner].
+ */
+ internal fun isExcludedPackage(packageName: String, excludePackages: List): Boolean =
+ packageName.split('.').any { it == "internal" } ||
+ excludePackages.any { packageName == it || packageName.startsWith("$it.") }
+
/** A namespace match, not a text one: `junit.run` is a prefix of the string `junit.runner` and
* of no package or class. */
private fun matchesInclude(qualifiedName: String, includePrefixes: List): Boolean =
@@ -410,7 +445,8 @@ internal object ArtifactScanner {
bindObjectMembers = metadata.kmClass.visibility == Visibility.PUBLIC,
) + constructorCandidates(metadata.kmClass, node, classpath) +
objectConstantCandidates(metadata.kmClass, node, classpath) +
- memberPropertyCandidates(metadata.kmClass, node, classpath)
+ memberPropertyCandidates(metadata.kmClass, node, classpath) +
+ memberFunctionCandidates(metadata.kmClass, node, classpath)
is KotlinClassMetadata.FileFacade -> {
val owner = kotlinPackageNameOverrideOf(node) ?: packageNameOf(node.name)
kotlinCandidates(
@@ -541,7 +577,8 @@ internal object ArtifactScanner {
* group.
*/
private fun ArtifactCallable.overloadSuffix(includeReceiver: Boolean, qualified: Boolean): String {
- val skipReceiver = if (receiverTypeName != null && !includeReceiver) 1 else 0
+ // A member's receiver (issue #215) is not in [paramTypeNames] at all, so there is nothing to skip.
+ val skipReceiver = if (receiverTypeName != null && kind == "FUNCTION" && !includeReceiver) 1 else 0
// A composable's synthetic slots are the same for every member of its overload set --
// `Composer_Int_Int_Int` on all of them -- so they separate nothing and would only make the
// Python name unreadable. Dropped, which leaves exactly the declared parameters the two
@@ -551,7 +588,9 @@ internal object ArtifactScanner {
// A zero-parameter member of a group -- there can be at most one, so this only ever has to
// be distinct from the others, not descriptive.
if (parts.isEmpty()) return "0"
- return parts.joinToString("_") { name ->
+ return parts.joinToString("_") { slotName ->
+ // A functional-interface slot (issue #216) is named by its interface, not by the callable form.
+ val name = slotName.substringBefore(SAM_SEPARATOR)
val chosen = if (qualified) name else name.substringAfterLast('.')
chosen.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("")
}
@@ -821,6 +860,121 @@ internal object ArtifactScanner {
// -------------------------------------------------------------------------- properties (#38)
+ /** What a member function's candidate needs of the class it is read on (issue #215). */
+ private class MemberReceiver(
+ /** The class's qualified Kotlin name: the entry's `receiverTypeName` and the key's owner. */
+ val owner: String,
+ /** The receiver as generated source reads it out of `args[0]`. */
+ val read: String,
+ private val model: KotlinTypeModel,
+ ) {
+ /** [declaration] as a `METHOD`: read off a receiver of [owner], every argument passed. */
+ fun restate(declaration: DeclarationModel): DeclarationModel = declaration.copy(
+ kind = METHOD_KIND,
+ receiver = model,
+ receiverBoundaryTag = OBJECT_TAG,
+ parameters = declaration.parameters.map { it.copy(declaresDefault = false) },
+ )
+ }
+
+ /**
+ * A public class's or interface's own **member functions**, as `METHOD` entries under
+ * `Owner.name` (issue #215).
+ *
+ * `kotlinx.coroutines.Job.cancel()` and `CoroutineDispatcher.dispatch(...)` are members: until this
+ * producer, a handle Python held to one could have its properties read and nothing called on it.
+ * `CallableKind.METHOD` already says the shape -- `args[0]` is the receiver, resolved from the
+ * proxy's handle and not counted in `arity` -- and KSP already emits it for its own classes, so
+ * nothing about the boundary is new. The call is source, `(args[0] as Owner).name(...)`; `kotlinc`
+ * resolves and mangles it.
+ *
+ * ### What is bound
+ *
+ * The class rules are [memberPropertyCandidates]': Kotlin-public, a `class`, `interface` or `enum
+ * class` (an `object`'s functions are #53's statics), crossing as an object handle, every supertype
+ * on the walk's classpath. A function is bound when it is public, declared rather than synthesized
+ * (a data class's `copy`/`componentN`, U-4), not an extension (a member extension needs two
+ * receivers), not one of `Any`'s members, and backed by a public, non-static, non-synthetic JVM
+ * method whose parameter count is the declared one. Declined, each with its reason: `suspend`, a
+ * reified or bounded type parameter, `@Composable`, a member of a generic class (its receiver would
+ * be cast with arguments a type parameter of the function may need -- not yet), and a member that
+ * shares its name with a property of the class, because Python has one attribute for both and the
+ * property (#38) keeps it.
+ */
+ private fun memberFunctionCandidates(
+ kmClass: kotlin.metadata.KmClass,
+ ownerNode: ClassNode,
+ classpath: ArtifactClasspath,
+ ): List {
+ if (kmClass.visibility != Visibility.PUBLIC) return emptyList()
+ if (kmClass.kind !in PROPERTY_OWNER_KINDS) return emptyList()
+ val synthesized = kmClass.functions.filter { it.kind == MemberKind.SYNTHESIZED }.mapTo(HashSet()) { it.name }
+ val functions = functionsOf(kmClass).filter { !it.isExtension && it.kotlinName !in synthesized }
+ if (functions.isEmpty()) return emptyList()
+ val owner = binaryNameToQualified(ownerNode.name)
+ val ownerType = KmType().apply { classifier = KmClassifier.Class(ownerNode.name) }
+ val receiverBoundary = resolveKotlinBoundary(ownerType, classpath, BoundaryDirection.PARAMETER) ?: return emptyList()
+ if (receiverBoundary.boundary.tag != OBJECT_TAG) return emptyList()
+ val receiverModel = kotlinTypeModelOf(ownerType, classpath) ?: return emptyList()
+ val member = MemberReceiver(owner, receiverBoundary.boundary.read("args[0]"), receiverModel)
+ val receiverDecline = classpath.unreachableSupertypeOf(ownerNode.name)
+ ?.let { missing -> unreachableReceiverReason(missing, owner) }
+ val propertyNames = kmClass.properties.mapTo(HashSet()) { it.name }
+ val methods = ownerNode.methods.associateBy { it.name to it.desc }
+
+ return functions.mapNotNull { function ->
+ val signature = function.jvmSignature.name to function.jvmSignature.descriptor
+ if (signature in ANY_MEMBER_SIGNATURES) return@mapNotNull null
+ val method = methods[signature]
+ val composable = method != null && isComposable(method)
+ fun declined(reason: String) =
+ declinedCandidate(owner, true, function, classpath, reason, composable)
+ ?.let { it.copy(declaration = member.restate(it.declaration)) }
+ when {
+ function.isSuspend -> return@mapNotNull declined("suspend")
+ function.hasReifiedTypeParameter ->
+ return@mapNotNull declined("a reified type parameter: its type argument is read at run time, so kotlin.Any? cannot stand for it")
+ method == null || !method.access.hasFlag(Opcodes.ACC_PUBLIC) -> return@mapNotNull null
+ method.access.hasFlag(Opcodes.ACC_STATIC) || method.access.hasFlag(Opcodes.ACC_SYNTHETIC) ||
+ method.access.hasFlag(Opcodes.ACC_BRIDGE) -> return@mapNotNull null
+ composable -> return@mapNotNull declined("a @Composable member function: not bound on a proxy")
+ kmClass.typeParameters.isNotEmpty() -> return@mapNotNull declined("a member of a generic class: not bound yet")
+ function.kotlinName in propertyNames ->
+ return@mapNotNull declined("shares its name with a property of the class, which keeps the attribute")
+ receiverDecline != null -> return@mapNotNull declined(receiverDecline)
+ }
+ val (paramDescriptors, _) = splitMethodDescriptor(method!!.desc)
+ if (function.allParameterTypes.size != paramDescriptors.size) {
+ return@mapNotNull declined(
+ "the JVM signature carries ${paramDescriptors.size - function.allParameterTypes.size} " +
+ "parameter(s) metadata does not declare",
+ )
+ }
+ candidateFromFunction(owner, true, function, classpath, paramDescriptors, isComposable = false, member = member)
+ }
+ }
+
+ /**
+ * Gives a member function (issue #215) a key no other binding holds. Keys are `Owner.name`, the
+ * namespace of a property's (`Owner.prop`) and of an object's constants and functions; a property
+ * of the same name is already excluded where the member is built, so what is left here is a nested
+ * object's or a companion's binding under the same dotted name. The other binding keeps it.
+ */
+ private fun settleMethodNames(methods: List, settled: List): List {
+ val taken = settled.mapNotNullTo(HashSet()) { it.callable?.name }
+ return methods.map { candidate ->
+ val name = candidate.callable?.name ?: return@map candidate
+ if (name !in taken) return@map candidate
+ candidate.copy(
+ callable = null,
+ declaration = candidate.declaration.copy(
+ bindingName = null,
+ declineReason = "member key $name collides with another binding",
+ ),
+ )
+ }
+ }
+
/**
* A public class's or interface's own **member** properties, as `GETTER` entries -- and, for a
* `var` whose setter is public, `SETTER` entries -- under `Owner.prop` and `Owner.prop=`.
@@ -1180,6 +1334,7 @@ internal object ArtifactScanner {
"so generated Kotlin cannot read a member of it"
internal const val GETTER_KIND = "GETTER"
+ internal const val METHOD_KIND = "METHOD"
internal const val SETTER_KIND = "SETTER"
/** The two kinds [scan] keeps out of the function passes; see [settlePropertyNames]. */
@@ -1607,6 +1762,9 @@ internal object ArtifactScanner {
val typeName: String? = null,
val boundary: BoundaryType? = null,
val declineReason: String? = null,
+ /** The type a stub shows for the slot when it is not the declared one: a functional
+ * interface's `kotlin.FunctionN` (issue #216). */
+ val modelType: KotlinTypeModel? = null,
)
/**
@@ -1642,7 +1800,7 @@ internal object ArtifactScanner {
* *different* limit from the one this method opens, which is why it is named rather than left
* to fail as a compile error in somebody's generated fragment.
* - **`@Composable`, inside a declaration that is not.** `Modifier.composed(factory:)` is the
- * case. The fragment is compiled without the Compose plugin (`:ksp-fixtures:artifact`'s
+ * case. The fragment is compiled without the Compose plugin (`:python-multiplatform-gradle-plugin-fixtures:artifact`'s
* `build.gradle.kts` says so, and it is the whole shape of that fixture's claim), so
* `@Composable Modifier.() -> Modifier` written there is an ordinary `Function1` while the slot
* is a lowered `Function3`. **This one does not fail as a compile error**, which is why it is
@@ -1699,6 +1857,142 @@ internal object ArtifactScanner {
return FunctionSlot(typeName = name, boundary = BoundaryType("OBJECT", "(%s as $rendered)", "(%s)"))
}
+ /**
+ * A parameter typed as a **functional interface** (issue #216): a Java interface with one abstract
+ * method (`java.lang.Runnable`) or a Kotlin `fun interface`, whose method returns `Unit` and which is
+ * not generic. `null` for anything else, which leaves the parameter exactly as it was -- an
+ * interface on the classpath still crosses as a handle (`Alignment` is a `fun interface` whose
+ * method returns an `IntOffset`, and its slots must keep taking `Alignment.Center`).
+ *
+ * The slot takes **both** a Python callable and a Kotlin handle. Its type name is the interface's,
+ * then [SAM_SEPARATOR], then the `kotlin.FunctionN` it is equivalent to
+ * (`java.lang.Runnable|kotlin.Function0()->kotlin.Unit`): the binding layer fills it from a callable
+ * through the wrapper path a function type has (B-5, `pythonx._make_function`) and checks a handle
+ * against the interface. The generated read converts a wrapper with a SAM lambda and casts anything
+ * else -- `((%s).let { v -> if (v is kotlin.Function0<*>) java.lang.Runnable { ... } else v as
+ * java.lang.Runnable })` -- which `kotlinc` compiles; nothing is looked up at run time (AGENTS.md §12).
+ *
+ * The interface is read off the consumer's classpath, or, for a `java.`/`javax.` one, off the JDK
+ * this build runs on ([jdkClassNode]): the walk's classpath is the consumer's jars and never held
+ * `java.lang.Runnable`, which is why `runRunnable(block: Runnable)` was declined.
+ */
+ private fun functionalInterfaceSlotOrNull(type: KmType, classpath: ArtifactClasspath): FunctionSlot? {
+ val classifier = type.classifier as? KmClassifier.Class ?: return null
+ val internal = classifier.name
+ if (internal.startsWith(KOTLIN_FUNCTION_INTERNAL_PREFIX)) return null
+ if (type.arguments.isNotEmpty()) return null
+ val binary = internal.replace('.', '$')
+ val node = classpath.classNode(binary) ?: jdkClassNode(binary) ?: return null
+ if (!node.access.hasFlag(Opcodes.ACC_INTERFACE) || !node.access.hasFlag(Opcodes.ACC_PUBLIC)) return null
+ if (node.signature?.startsWith("<") == true) return null
+ val kmClass = when (val metadata = kotlinClassMetadataOf(node)) {
+ null -> null
+ is KotlinClassMetadata.Class -> metadata.kmClass.takeIf { it.isFunInterface } ?: return null
+ else -> return null
+ }
+ val method = abstractMethodsOf(node, classpath)?.singleOrNull() ?: return null
+ val (descriptors, returns) = splitMethodDescriptor(method.desc)
+ if (returns != "V") return null
+ // A Kotlin `fun interface` says each parameter's nullability; a Java one does not, so an object
+ // parameter is taken as nullable there -- Java may pass `null`, and a non-null type would throw.
+ val parameterTypes: List> = if (kmClass != null) {
+ val declared = kmClass.functions.singleOrNull { it.name == method.name } ?: return null
+ if (declared.valueParameters.size != descriptors.size) return null
+ declared.valueParameters.map { parameter ->
+ val name = kotlinClassifierNameOf(parameter.type) ?: return null
+ name to (renderKotlinSourceType(parameter.type, classpath, depth = 0) ?: return null)
+ }
+ } else {
+ descriptors.map { descriptor -> javaParameterTypeOf(descriptor, classpath) ?: return null }
+ }
+ val qualified = internal.replace('/', '.')
+ val arity = parameterTypes.size
+ val functionType = "kotlin.Function$arity<" + (parameterTypes.map { it.second } + UNIT_TYPE_NAME).joinToString(", ") + ">"
+ val star = "kotlin.Function$arity<" + List(arity + 1) { "*" }.joinToString(", ") + ">"
+ val names = (0 until arity).map { "p$it" }
+ val arrow = if (arity == 0) "" else names.joinToString(", ", postfix = " -> ")
+ val convert = "v -> if (v is $star) $qualified { $arrow(v as $functionType)(${names.joinToString(", ")}) } else v as $qualified"
+ val read = if (type.isNullable) "((%s)?.let { $convert })" else "((%s).let { $convert })"
+ return FunctionSlot(
+ typeName = qualified + SAM_SEPARATOR + KOTLIN_FUNCTION_PREFIX + arity +
+ "(" + parameterTypes.joinToString(",") { it.first } + ")" + FUNCTION_RETURNS + UNIT_TYPE_NAME,
+ boundary = BoundaryType("OBJECT", read, "(%s)"),
+ modelType = KotlinTypeModel(
+ "kotlin.Function$arity",
+ isNullable = type.isNullable,
+ arguments = parameterTypes.map { KotlinTypeModel(it.first) } + KotlinTypeModel(UNIT_TYPE_NAME),
+ ),
+ )
+ }
+
+ /** Separates a functional-interface slot's interface from the function type a callable fills it as
+ * (issue #216). Must match `pythonx`'s `_SAM_SEPARATOR`; not a character a classifier can contain. */
+ internal const val SAM_SEPARATOR: String = "|"
+
+ /** `Object`'s public methods, which an interface may redeclare without becoming non-functional. */
+ private val OBJECT_METHOD_SIGNATURES = setOf(
+ "equals" to "(Ljava/lang/Object;)Z",
+ "hashCode" to "()I",
+ "toString" to "()Ljava/lang/String;",
+ )
+
+ /**
+ * The abstract methods an interface leaves to an implementation, its superinterfaces' included and
+ * minus any a subinterface gives a default body; `null` when a superinterface cannot be read.
+ */
+ private fun abstractMethodsOf(node: ClassNode, classpath: ArtifactClasspath): List? {
+ val abstract = LinkedHashMap, org.objectweb.asm.tree.MethodNode>()
+ val concrete = HashSet>()
+ fun visit(current: ClassNode, depth: Int): Boolean {
+ if (depth > 8) return false
+ current.methods.filter { !it.access.hasFlag(Opcodes.ACC_STATIC) && !it.access.hasFlag(Opcodes.ACC_SYNTHETIC) }.forEach { method ->
+ val key = method.name to method.desc
+ if (key in OBJECT_METHOD_SIGNATURES || method.name.startsWith("<")) return@forEach
+ if (method.access.hasFlag(Opcodes.ACC_ABSTRACT)) abstract.putIfAbsent(key, method) else concrete += key
+ }
+ return current.interfaces.all { parent ->
+ val parentNode = classpath.classNode(parent) ?: jdkClassNode(parent) ?: return false
+ visit(parentNode, depth + 1)
+ }
+ }
+ if (!visit(node, 0)) return null
+ return abstract.filterKeys { it !in concrete }.values.toList()
+ }
+
+ /**
+ * A Java method parameter as `(slot type name, Kotlin source type)`: primitives and `String` by
+ * their Kotlin names, any other class by its name when it is nameable, an object type nullable.
+ */
+ private fun javaParameterTypeOf(descriptor: String, classpath: ArtifactClasspath): Pair? {
+ val primitive = when (descriptor) {
+ "I" -> "kotlin.Int"; "J" -> "kotlin.Long"; "Z" -> "kotlin.Boolean"; "D" -> "kotlin.Double"
+ "F" -> "kotlin.Float"; "S" -> "kotlin.Short"; "B" -> "kotlin.Byte"; "C" -> "kotlin.Char"
+ else -> null
+ }
+ if (primitive != null) return primitive to primitive
+ if (!descriptor.startsWith("L") || !descriptor.endsWith(";")) return null
+ val binary = descriptor.substring(1, descriptor.length - 1)
+ val name = when (binary) {
+ "java/lang/String" -> "kotlin.String"
+ "java/lang/Object" -> "kotlin.Any"
+ else -> if (classpath.isNameablePublicClass(binary)) binary.replace('/', '.').replace('$', '.') else return null
+ }
+ return name to "$name?"
+ }
+
+ /**
+ * A `java.`/`javax.` class file, read from the JDK this build runs on -- the walk's classpath is the
+ * consumer's jars, which never contain the platform. Build time only, and only to recognise a
+ * functional interface ([functionalInterfaceSlotOrNull]); it does not make a JDK type nameable as a
+ * handle anywhere else.
+ */
+ private fun jdkClassNode(binaryName: String): ClassNode? {
+ if (!binaryName.startsWith("java/") && !binaryName.startsWith("javax/")) return null
+ val bytes = ClassLoader.getPlatformClassLoader().getResourceAsStream("$binaryName.class")?.use { it.readBytes() }
+ ?: return null
+ return readClassNodeOrNull(bytes)
+ }
+
/** The Kotlin-source spelling of a function type: `kotlin.Function1`.
* Written as the `FunctionN` classifier rather than as `(Float) -> Unit` because the two are the
* same type and the first needs no bracket rules; assignability to an extension function type
@@ -1949,8 +2243,11 @@ internal object ArtifactScanner {
classpath: ArtifactClasspath,
paramDescriptors: List,
isComposable: Boolean,
+ member: MemberReceiver? = null,
): Candidate? {
- val model = declarationModelOf(owner, ownerIsClass, function, classpath, isComposable) ?: return null
+ val model = declarationModelOf(owner, ownerIsClass, function, classpath, isComposable)
+ ?.let { if (member != null) member.restate(it) else it }
+ ?: return null
val declined = { reason: String -> Candidate(null, model.copy(declineReason = reason)) }
val receiverIndex = if (function.isExtension) 1 else 0
if (function.hasUnsubstitutableTypeParameters) {
@@ -1971,7 +2268,7 @@ internal object ArtifactScanner {
// Read before `resolveKotlinType` is asked anything, because for a function-typed parameter
// it is the one that would answer -- and would answer "no". See [functionSlotOrNull].
val functionSlots = function.allParameterTypes.mapIndexed { index, type ->
- functionSlotOrNull(type, paramDescriptors[index], classpath)
+ functionSlotOrNull(type, paramDescriptors[index], classpath) ?: functionalInterfaceSlotOrNull(type, classpath)
}
functionSlots.firstNotNullOfOrNull { it?.declineReason }?.let { return declined(it) }
@@ -1995,7 +2292,10 @@ internal object ArtifactScanner {
val returnTypeName = kotlinClassifierNameOf(function.returnType)
val qualifiedName = "$owner.${function.kotlinName}"
- val argumentExpressions = resolvedParams.mapIndexed { index, type -> type.read("args[$index]") }
+ // A member's receiver is `args[0]` and is not one of [resolvedParams] (issue #215), so its
+ // declared parameters start at `args[1]`, as in KSP's `METHOD` entries.
+ val firstArgument = if (member != null) 1 else 0
+ val argumentExpressions = resolvedParams.mapIndexed { index, type -> type.read("args[${index + firstArgument}]") }
// Kotlin has no syntax to call an extension function by fully qualifying it the way an
// ordinary top-level function or a Java static can be (`pkg.fn(args)`) -- the receiver can
@@ -2013,7 +2313,9 @@ internal object ArtifactScanner {
/** The call that passes everything, positionally -- byte for byte what this generator
* emitted before defaults existed, and still the `else`-less first branch below. */
- val call = if (function.isExtension) {
+ val call = if (member != null) {
+ "${member.read}.${function.kotlinName}$typeArguments(${argumentExpressions.joinToString(", ")})"
+ } else if (function.isExtension) {
"${argumentExpressions.first()}.$alias$typeArguments(${argumentExpressions.drop(1).joinToString(", ")})"
} else {
"$owner.${function.kotlinName}$typeArguments(${argumentExpressions.joinToString(", ")})"
@@ -2030,10 +2332,11 @@ internal object ArtifactScanner {
arity = arity,
paramTags = resolvedParams.map { it.tag },
returnTag = returnType.tag,
- lambdaBody = if (arity == 0) "{ ${returnType.wrapReturn(call)} }"
+ // A member reads its receiver out of `args[0]` even with no declared parameter.
+ lambdaBody = if (arity == 0 && member == null) "{ ${returnType.wrapReturn(call)} }"
else "{ args -> ${returnType.wrapReturn(call)} }",
imports = imports,
- receiverTypeName = function.receiverType?.let { kotlinClassifierNameOf(it) },
+ receiverTypeName = member?.owner ?: function.receiverType?.let { kotlinClassifierNameOf(it) },
paramNames = function.allParameterNames,
paramTypeNames = paramTypeNames,
returnTypeName = returnTypeName,
@@ -2041,15 +2344,21 @@ internal object ArtifactScanner {
// Deliberately all `false` until [applyDefaultOmission] says otherwise: this column is a
// statement about **this body**, and this body passes everything.
paramHasDefault = List(resolvedParams.size) { false },
+ kind = if (member != null) METHOD_KIND else "FUNCTION",
)
- val omittable = omittableParameterIndices(function, receiverIndex)
+ // A member's defaults are not omittable yet (issue #215): no plan, so Python passes every
+ // argument and the stub says so.
+ val omittable = if (member != null) emptySet() else omittableParameterIndices(function, receiverIndex)
+ val modelParameters = model.parameters.mapIndexed { index, parameter ->
+ functionSlots[index + receiverIndex]?.modelType?.let { parameter.copy(type = it) } ?: parameter
+ }
return Candidate(
callable = callable,
declaration = model.copy(
bindingName = qualifiedName,
returnBoundaryTag = returnType.tag,
- receiverBoundaryTag = if (function.isExtension) resolvedParams.first().tag else null,
- parameters = model.parameters.mapIndexed { index, parameter ->
+ receiverBoundaryTag = if (function.isExtension) resolvedParams.first().tag else model.receiverBoundaryTag,
+ parameters = modelParameters.mapIndexed { index, parameter ->
parameter.copy(boundaryTag = resolvedParams[index + receiverIndex].tag)
},
),
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanWorkAction.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanWorkAction.kt
index 5674f73b4..f7012ed3e 100644
--- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanWorkAction.kt
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanWorkAction.kt
@@ -51,15 +51,17 @@ internal abstract class KlibScanWorkAction : WorkAction
+ val excludePackages: ListProperty
val resultFile: RegularFileProperty
}
override fun execute() {
val klib = parameters.klibFile.get().asFile
val includes = parameters.includePrefixes.get()
+ val excludes = parameters.excludePackages.get()
val result = KlibScanResult(
- callables = KlibScanner.scanKlib(klib, includes),
- declarations = KlibScanner.scanKlibDeclarations(klib, includes),
+ callables = KlibScanner.scanKlib(klib, includes, excludes),
+ declarations = KlibScanner.scanKlibDeclarations(klib, includes, excludes),
)
ObjectOutputStream(parameters.resultFile.get().asFile.outputStream()).use { it.writeObject(result) }
}
@@ -99,7 +101,7 @@ internal data class KlibScanResult(
* list into the worker copied the collision in with it, and
* `:generatePythonArtifactBindings` failed with the same
* `NoSuchMethodError: KotlinLibraryImplKt.createKotlinLibrary$default` the isolation was introduced to
- * fix. In this repository it happened to work: `ksp-fixtures` resolves the plugin from an *included
+ * fix. In this repository it happened to work: the `fixtures/` modules resolve the plugin from an *included
* build*, which does get a scope of its own, so no in-repo fixture can see this.
*
* The worker classpath is therefore built from an explicit coordinate list
@@ -138,6 +140,7 @@ private fun pluginCodeSource(): File {
internal fun WorkerExecutor.scanKlibIsolated(
klib: File,
includes: List,
+ excludes: List,
scratchDirectory: File,
readerClasspath: Collection,
): KlibScanResult {
@@ -153,6 +156,7 @@ internal fun WorkerExecutor.scanKlibIsolated(
queue.submit(KlibScanWorkAction::class.java) {
klibFile.set(klib)
includePrefixes.set(includes)
+ excludePackages.set(excludes)
this.resultFile.set(resultFile)
}
queue.await()
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanner.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanner.kt
index 450c95f28..d12334e21 100644
--- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanner.kt
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanner.kt
@@ -1,8 +1,13 @@
package python.multiplatform.gradle.artifact
+import org.jetbrains.kotlin.library.abi.AbiClass
+import org.jetbrains.kotlin.library.abi.AbiClassKind
import org.jetbrains.kotlin.library.abi.AbiClassifierReference
+import org.jetbrains.kotlin.library.abi.AbiDeclarationContainer
import org.jetbrains.kotlin.library.abi.AbiCompoundName
import org.jetbrains.kotlin.library.abi.AbiFunction
+import org.jetbrains.kotlin.library.abi.AbiModality
+import org.jetbrains.kotlin.library.abi.AbiProperty
import org.jetbrains.kotlin.library.abi.AbiQualifiedName
import org.jetbrains.kotlin.library.abi.AbiType
import org.jetbrains.kotlin.library.abi.AbiTypeArgument
@@ -36,7 +41,7 @@ import java.io.File
* proved it and both attempts at aligning the version that failed before classloader isolation was
* tried. [KlibScanWorkAction] is that isolation: every caller of this object goes through
* [WorkerExecutor.scanKlibIsolated][python.multiplatform.gradle.artifact.scanKlibIsolated] rather than
- * calling [scanKlib]/[scanKlibDeclarations] directly, and `:ksp-fixtures:klib-artifact
+ * calling [scanKlib]/[scanKlibDeclarations] directly, and `:python-multiplatform-gradle-plugin-fixtures:klib-artifact
* :generatePythonArtifactBindings`/`:generatePythonStubs` passing is what confirms the isolation
* actually holds in a consumer build, not only in this file's own unit tests.
*
@@ -163,36 +168,163 @@ internal object KlibScanner {
*/
private val PUBLISHED_API = AbiQualifiedName(AbiCompoundName("kotlin"), AbiCompoundName("PublishedApi"))
+ private val DEPRECATED = AbiQualifiedName(AbiCompoundName("kotlin"), AbiCompoundName("Deprecated"))
+
/** Every binding this klib offers, sorted by name -- the counterpart of [ArtifactScanner.scanJar]. */
- fun scanKlib(klib: File, includePrefixes: List): List =
- scan(klib, includePrefixes).mapNotNull { it.callable }.sortedBy { it.name }
+ fun scanKlib(klib: File, includePrefixes: List, excludePackages: List = emptyList()): List =
+ scan(klib, includePrefixes, excludePackages).mapNotNull { it.callable }.sortedBy { it.name }
/** The same walk with the other half of its result kept -- the counterpart of
* [ArtifactScanner.scanDeclarations], sorted the same way so the two producers' models can be
* concatenated without either one's order depending on which walked first. */
- fun scanKlibDeclarations(klib: File, includePrefixes: List): List =
- scan(klib, includePrefixes)
+ fun scanKlibDeclarations(klib: File, includePrefixes: List, excludePackages: List = emptyList()): List =
+ scan(klib, includePrefixes, excludePackages)
.map { it.declaration }
.sortedWith(compareBy({ it.owner }, { it.simpleName }, { it.bindingName ?: "" }))
- private fun scan(klib: File, includePrefixes: List): List {
+ private fun scan(klib: File, includePrefixes: List, excludePackages: List): List {
val abi = try {
LibraryAbiReader.readAbiInfo(klib)
} catch (e: Exception) {
return emptyList()
}
+ val classes = classesOf(abi.topLevelDeclarations)
val candidates = abi.topLevelDeclarations.declarations
.filterIsInstance()
.filter { matchesInclude(it.qualifiedName, includePrefixes) }
+ // SPEC B-11: `internal` packages and `excludePackages`, as on the JVM producer.
+ .filter { !ArtifactScanner.isExcludedPackage(it.qualifiedName.packageName.toString(), excludePackages) }
// Not a *top-level* shape at all: a constructor belongs to an `AbiClass` and needs a
// receiver handle to be worth anything. Filtered rather than declined for the same reason
// `ArtifactScanner` never builds a model for ``.
.filter { !it.isConstructor }
- .mapNotNull { candidateOrNull(it) }
+ .mapNotNull { candidateOrNull(it, classes) }
+
+ // Issue #237: the member functions of the klib's classes, as #215 binds them from a jar.
+ val members = classes.values
+ .filter { matchesInclude(it.qualifiedName, includePrefixes) }
+ .filter { !ArtifactScanner.isExcludedPackage(it.qualifiedName.packageName.toString(), excludePackages) }
+ .flatMap { memberCandidatesOf(it, classes) }
- // Same rule, same implementation as the JVM producer -- see this object's KDoc.
- return ArtifactScanner.disambiguateOverloads(candidates)
+ // Same rule, same implementation as the JVM producer -- see this object's KDoc. A member's key
+ // is `Owner.name`, so its overload set is its class's alone.
+ return ArtifactScanner.disambiguateOverloads(candidates + members)
+ }
+
+ /**
+ * The member functions of [cls] (issue #237), as `METHOD` entries `Owner.name` -- the shape #215
+ * gives a jar's members and KSP gives its own: `args[0]` is the receiver, not counted in `arity`,
+ * and the generated call is `(args[0] as Owner).name(...)`. The binding layer serves them as
+ * methods of the receiver's proxy, and never as a module attribute.
+ *
+ * The class must be able to cross as a handle ([handleClassOrNull]); a generic one's members are
+ * declined, as on the jar side. A member is declined, with its reason, when it is `suspend`,
+ * `@Deprecated`, `@PublishedApi`, `@Composable`, a member extension or one with context receivers,
+ * named like a property of its class (the property's name is the attribute), or typed by something
+ * without a klib boundary type. Constructors are not read here (stage 3).
+ */
+ private fun memberCandidatesOf(cls: AbiClass, classes: Map): List {
+ val owner = cls.qualifiedName.dotted()
+ val ownerType = KotlinTypeModel(owner)
+ val generic = cls.typeParameters.isNotEmpty()
+ if (!generic && cls.kind != AbiClassKind.OBJECT && handleClassOrNull(ownerType, classes) == null) return emptyList()
+ val propertyNames = cls.declarations.filterIsInstance()
+ .mapTo(HashSet()) { it.qualifiedName.relativeName.toString().substringAfterLast('.') }
+ // The pinned compiler's klib ABI records modality but no visibility, and lists the `protected`
+ // members of an open, abstract or sealed class beside its public ones; source outside a subclass
+ // cannot call a `protected` one. An interface has none, and a final class's are effectively
+ // private, so only those members bind. An `object`'s functions are statics on the jar side (#53),
+ // called without a receiver, which this `METHOD` shape is not.
+ val unbindable = when {
+ cls.kind == AbiClassKind.OBJECT -> "a function of an object: bound as a static on the jar side (#53), not yet from a klib"
+ cls.kind == AbiClassKind.CLASS && cls.modality != AbiModality.FINAL ->
+ "a member of a non-final class: this klib ABI does not say whether it is protected"
+ else -> null
+ }
+ val enum = cls.kind == AbiClassKind.ENUM_CLASS
+ return cls.declarations.filterIsInstance()
+ .filter { !it.isConstructor }
+ // `Any`'s members are not the class's API, as on the jar side; an enum class's
+ // `valueOf`/`values`/`entries` are statics the ABI lists among its members, and no
+ // instance call reaches them.
+ .filter { function -> !isAnyMember(function) && !(enum && function.leafName() in ENUM_STATICS) }
+ .mapNotNull { function -> memberCandidateOrNull(function, owner, ownerType, generic, unbindable, propertyNames, classes) }
+ }
+
+ private fun memberCandidateOrNull(
+ function: AbiFunction,
+ owner: String,
+ ownerType: KotlinTypeModel,
+ generic: Boolean,
+ unbindable: String?,
+ propertyNames: Set,
+ classes: Map,
+ ): Candidate? {
+ val name = function.leafName()
+ val model = declarationModelOf(function)?.copy(
+ simpleName = name,
+ owner = owner,
+ ownerIsClass = true,
+ receiver = ownerType,
+ receiverBoundaryTag = OBJECT_TAG,
+ kind = METHOD_KIND,
+ ) ?: return null
+ val declined = { reason: String -> Candidate(null, model.copy(declineReason = reason)) }
+ when {
+ function.isSuspend -> return declined("suspend")
+ generic -> return declined("a member of a generic class: not bound yet")
+ unbindable != null -> return declined(unbindable)
+ function.hasAnnotation(DEPRECATED) ->
+ return declined("@Deprecated: its level is not in this klib ABI, and a HIDDEN one cannot be called from source")
+ function.hasAnnotation(PUBLISHED_API) ->
+ return declined("@PublishedApi internal -- binary-visible for inlining, not part of the module's public Kotlin API")
+ function.hasAnnotation(COMPOSABLE) -> return declined("a @Composable member function: not bound on a proxy")
+ function.hasExtensionReceiverParameter -> return declined("a member extension: it needs two receivers")
+ function.contextReceiverParametersCount > 0 ->
+ return declined("${function.contextReceiverParametersCount} context receiver(s), whose types this klib ABI does not expose")
+ name in propertyNames -> return declined("shares its name with a property of the class, which keeps the attribute")
+ // A data class's `copy`/`componentN` are synthetics the jar side never binds (U-4), and this
+ // ABI cannot tell them from a hand-written member of the same name.
+ name == "copy" || DATA_COMPONENT.matches(name) ->
+ return declined("a data-class synthetic (copy/componentN), or a member this klib ABI cannot tell from one")
+ }
+ val returnType = klibBoundaryTypeOf(model.returnType, classes)
+ ?: return declined("no boundary type for return ${model.returnType.render()}")
+ val boundaryTypes = model.parameters.map { parameter ->
+ klibBoundaryTypeOf(parameter.type, classes)
+ ?: return declined("no boundary type for parameter ${parameter.type.render()}")
+ }
+ if (boundaryTypes.any { it.isReturnOnly }) return declined("a parameter of a return-only boundary type")
+
+ val key = "$owner.$name"
+ val argumentExpressions = boundaryTypes.mapIndexed { index, type -> type.read("args[${index + 1}]") }
+ val body = returnType.wrapReturn("(args[0] as $owner).$name(${argumentExpressions.joinToString(", ")})")
+ val callable = ArtifactCallable(
+ name = key,
+ arity = boundaryTypes.size,
+ paramTags = boundaryTypes.map { it.tag },
+ returnTag = returnType.tag,
+ // The receiver is read even with no declared parameter, so the body always takes `args`.
+ lambdaBody = "{ args -> $body }",
+ receiverTypeName = owner,
+ paramTypeNames = model.parameters.map { it.type.qualifiedName },
+ returnTypeName = model.returnType.qualifiedName,
+ returnSupertypes = if (returnType.tag == OBJECT_TAG) supertypesOf(model.returnType.qualifiedName, classes) else emptyList(),
+ // Every argument is passed, as for a top-level klib entry (see [candidateOrNull]).
+ paramHasDefault = List(boundaryTypes.size) { false },
+ kind = METHOD_KIND,
+ )
+ return Candidate(
+ callable = callable,
+ declaration = model.copy(
+ bindingName = key,
+ returnBoundaryTag = returnType.tag,
+ parameters = model.parameters.mapIndexed { index, parameter ->
+ parameter.copy(boundaryTag = boundaryTypes[index].tag, declaresDefault = false)
+ },
+ ),
+ )
}
/**
@@ -232,7 +364,27 @@ internal object KlibScanner {
* `scanKlibDeclarations` only ever call `LibraryAbiReader.readAbiInfo` on a real file and have no
* seam to inject one.
*/
- internal fun candidateOrNull(function: AbiFunction): Candidate? {
+ /**
+ * Every class the klib declares, nested ones included, by its dotted Kotlin name -- what
+ * [klibBoundaryTypeOf] asks before it lets a type cross as a handle (issue #234).
+ */
+ private fun classesOf(container: AbiDeclarationContainer): Map {
+ val found = LinkedHashMap()
+ fun walk(current: AbiDeclarationContainer) {
+ current.declarations.filterIsInstance().forEach { cls ->
+ found[cls.qualifiedName.dotted()] = cls
+ walk(cls)
+ }
+ }
+ walk(container)
+ return found
+ }
+
+ /**
+ * @param classes the walked klib's own classes ([classesOf]): a parameter or result whose class
+ * is one of them, and qualifies ([handleClassOrNull]), crosses as an object handle.
+ */
+ internal fun candidateOrNull(function: AbiFunction, classes: Map = emptyMap()): Candidate? {
val model = declarationModelOf(function) ?: return null
val declined = { reason: String -> Candidate(null, model.copy(declineReason = reason)) }
@@ -243,6 +395,12 @@ internal object KlibScanner {
if (function.isSuspend) return declined("suspend")
// See PUBLISHED_API's KDoc: `@PublishedApi internal` is binary-visible but not
// source-visible, and the generated call expression is source in the consumer's own module.
+ // A `@Deprecated(level = HIDDEN)` declaration stays in a klib for binary compatibility
+ // (`kotlinx.coroutines.Job0`) but cannot be called from source, and this ABI exposes the
+ // annotation but not its level (issue #234), so every deprecated declaration is declined.
+ if (function.hasAnnotation(DEPRECATED)) {
+ return declined("@Deprecated: its level is not in this klib ABI, and a HIDDEN one cannot be called from source")
+ }
if (function.hasAnnotation(PUBLISHED_API)) {
return declined("@PublishedApi internal -- binary-visible for inlining, not part of the module's public Kotlin API")
}
@@ -259,10 +417,10 @@ internal object KlibScanner {
return declined("${function.contextReceiverParametersCount} context receiver(s), whose types this klib ABI does not expose")
}
- val returnType = klibBoundaryTypeOf(model.returnType)
+ val returnType = klibBoundaryTypeOf(model.returnType, classes)
?: return declined("no boundary type for return ${model.returnType.render()}")
val boundaryTypes = model.parameters.map { parameter ->
- klibBoundaryTypeOf(parameter.type)
+ klibBoundaryTypeOf(parameter.type, classes)
?: return declined("no boundary type for parameter ${parameter.type.render()}")
}
if (boundaryTypes.any { it.isReturnOnly }) {
@@ -286,6 +444,10 @@ internal object KlibScanner {
// supplied"; `DeclarationModel.parameterNamesKnown` says the same thing to the stub side.
paramTypeNames = model.parameters.map { it.type.qualifiedName },
returnTypeName = model.returnType.qualifiedName,
+ // What a handle result **is a** (issue #234), read off the klib's own supertypes, nearest
+ // first: `_member` and `_is_a` read it, so a `CompletableJob` serves `Job`'s members and fills
+ // a `Job` slot. Empty for a non-handle result, as on the jar side.
+ returnSupertypes = if (returnType.tag == OBJECT_TAG) supertypesOf(model.returnType.qualifiedName, classes) else emptyList(),
// Deliberately all `false`, not `model.parameters.map { it.declaresDefault }`.
// `ArtifactCallable.paramHasDefault`'s contract (see its KDoc) is a statement about
// [lambdaBody] -- exactly the slots the generated call may omit -- and never about the
@@ -403,9 +565,21 @@ internal object KlibScanner {
* an object handle in that case; this walker has no object boundary at all yet, so it declines.
* Type *arguments* are ignored rather than refused, because no type this admits has any.
*/
- private fun klibBoundaryTypeOf(type: KotlinTypeModel): BoundaryType? {
- if (type.isNullable) return null
- return when (type.qualifiedName) {
+ private fun klibBoundaryTypeOf(type: KotlinTypeModel, classes: Map = emptyMap()): BoundaryType? {
+ // Issue #234: a class of the walked klib crosses as an object handle -- the jar producer's
+ // `(%s as Type)` read and an unwrapped result, which the common trampoline turns into a
+ // `HandleTable` handle and back. A nullable one carries the `?`: the handle is already
+ // nullable at the boundary (`UpcallTrampoline.toKotlin` maps Python `None` to `null`).
+ if (!type.isNullable) primitiveBoundaryTypeOf(type.qualifiedName)?.let { return it }
+ if (handleClassOrNull(type, classes) != null) {
+ val cast = type.qualifiedName + if (type.isNullable) "?" else ""
+ return BoundaryType(OBJECT_TAG, "(%s as $cast)", "(%s)")
+ }
+ return null
+ }
+
+ private fun primitiveBoundaryTypeOf(qualifiedName: String): BoundaryType? {
+ return when (qualifiedName) {
"kotlin.Boolean" -> BoundaryType("BOOLEAN", "(%s as Boolean)", "(%s)")
"kotlin.Byte" -> BoundaryType("INT", "(%s as Long).toByte()", "(%s).toLong()")
"kotlin.Short" -> BoundaryType("INT", "(%s as Long).toShort()", "(%s).toLong()")
@@ -419,4 +593,58 @@ internal object KlibScanner {
else -> null
}
}
+
+ private const val OBJECT_TAG = "OBJECT"
+
+ private const val METHOD_KIND = "METHOD"
+
+ private val ENUM_STATICS = setOf("valueOf", "values", "entries")
+
+ private val DATA_COMPONENT = Regex("component[1-9][0-9]*")
+
+ private fun AbiFunction.leafName(): String = qualifiedName.relativeName.toString().substringAfterLast('.')
+
+ /** `toString()`, `hashCode()` and `equals(other)`: what every class has from `Any`. */
+ private fun isAnyMember(function: AbiFunction): Boolean = when (function.leafName()) {
+ "toString", "hashCode" -> function.valueParameters.isEmpty()
+ "equals" -> function.valueParameters.size == 1
+ else -> false
+ }
+
+ /**
+ * The walked klib's class [type] names, when a handle of it can cross (issue #234): a public
+ * (the ABI lists nothing else) `class`, `interface`, `enum class` or `object` that is not a value
+ * class -- a value class's identity is not readable here (this object's KDoc) -- has no type
+ * parameters, is used without type arguments, and is not `@PublishedApi internal`. Anything else,
+ * a type from another klib included, is `null`, and the declaration stays declined.
+ */
+ private fun handleClassOrNull(type: KotlinTypeModel, classes: Map): AbiClass? {
+ if (type.arguments.isNotEmpty()) return null
+ // `kotlin.Int`, `kotlin.String` and the rest of package `kotlin` are classes of the stdlib klib,
+ // and the boundary's own currency, never handles.
+ if (type.qualifiedName.substringBeforeLast('.') == "kotlin") return null
+ val cls = classes[type.qualifiedName] ?: return null
+ if (cls.kind !in HANDLE_CLASS_KINDS || cls.isValue || cls.typeParameters.isNotEmpty()) return null
+ if (cls.hasAnnotation(PUBLISHED_API)) return null
+ return cls
+ }
+
+ private val HANDLE_CLASS_KINDS = setOf(AbiClassKind.CLASS, AbiClassKind.INTERFACE, AbiClassKind.ENUM_CLASS, AbiClassKind.OBJECT)
+
+ /** [qualifiedName]'s supertypes, nearest first and transitive within the klib, `kotlin.Any` left out. */
+ private fun supertypesOf(qualifiedName: String, classes: Map): List {
+ val ordered = LinkedHashSet()
+ var frontier = listOf(qualifiedName)
+ repeat(16) {
+ val next = frontier.flatMap { name ->
+ classes[name]?.superTypes.orEmpty().mapNotNull { superType ->
+ ((superType as? AbiType.Simple)?.classifierReference as? AbiClassifierReference.ClassReference)
+ ?.className?.dotted()
+ }
+ }.filter { it != "kotlin.Any" && it != qualifiedName && ordered.add(it) }
+ if (next.isEmpty()) return ordered.toList()
+ frontier = next
+ }
+ return ordered.toList()
+ }
}
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt
index 961fcf371..ee559b060 100644
--- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt
@@ -273,7 +273,12 @@ internal fun resolveKotlinBoundary(
if (info != null) {
val underlying = resolveKotlinBoundary(info.underlyingType, classpath, direction)
val usable = when (direction) {
- BoundaryDirection.PARAMETER -> info.constructorIsPublic
+ // Issue #168: opened as a raw primitive only when it can be opened **both ways**. A value
+ // class whose property is not public (`TextUnitType`) reaches Python as a boxed handle
+ // when Kotlin returns one (`TextUnitType.Sp`), so a parameter of it must take that handle.
+ // Marshalled as the raw primitive it would read a number and refuse the very value Kotlin
+ // handed out; as an object it is cast (`args[i] as TextUnitType`) and kotlinc unboxes.
+ BoundaryDirection.PARAMETER -> info.constructorIsPublic && info.propertyIsPublic && info.propertyName != null
BoundaryDirection.RETURN -> info.propertyIsPublic && info.propertyName != null
}
// Falls through to the object handle below when the wrapper cannot be opened from outside
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/PythonArtifactBindingsTask.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/PythonArtifactBindingsTask.kt
index abf9edf50..f7b8936e1 100644
--- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/PythonArtifactBindingsTask.kt
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/PythonArtifactBindingsTask.kt
@@ -1,5 +1,6 @@
package python.multiplatform.gradle.artifact
+import python.multiplatform.gradle.deleteWithoutFollowingLinks
import org.gradle.api.DefaultTask
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
@@ -61,6 +62,10 @@ abstract class PythonArtifactBindingsTask : DefaultTask() {
@get:Input
abstract val includePrefixes: ListProperty
+ /** Packages to leave out (`pythonBindings.excludePackages`), on top of every `internal` package; SPEC B-11. */
+ @get:Input
+ abstract val excludePackages: ListProperty
+
/**
* The whole classpath of the isolated worker that runs [KlibScanner], bar this plugin's own jar:
* the klib reader plus what [ArtifactScanner] needs, resolved by the consumer's project. See
@@ -97,14 +102,15 @@ abstract class PythonArtifactBindingsTask : DefaultTask() {
val destination = outputDirectory.get().asFile.resolve(ARTIFACTS_PACKAGE.replace('.', '/'))
// Rebuilt from scratch: a dependency that was removed, or an include prefix that stopped
// matching, must take its fragment file with it or the next compilation still sees it.
- outputDirectory.get().asFile.deleteRecursively()
+ deleteWithoutFollowingLinks(outputDirectory.get().asFile)
destination.mkdirs()
val thunkRoot = thunkDirectory.get().asFile
- thunkRoot.deleteRecursively()
+ deleteWithoutFollowingLinks(thunkRoot)
val thunkDestination = thunkRoot.resolve(THUNK_PACKAGE.replace('.', '/'))
thunkDestination.mkdirs()
val includes = includePrefixes.get()
+ val excludes = excludePackages.get()
val coordinates = coordinatesByFileName.get()
val objectNames = mutableListOf()
@@ -119,7 +125,7 @@ abstract class PythonArtifactBindingsTask : DefaultTask() {
.sortedBy { it.name }
.forEach { file ->
val entries = if (file.name.endsWith(".jar")) {
- ArtifactScanner.scanJar(file, includes, classpath = resolvedJars)
+ ArtifactScanner.scanJar(file, includes, classpath = resolvedJars, excludePackages = excludes)
} else {
// The klib path takes no classpath yet, and whether it needs one is open
// rather than overlooked: the jar path needs other artefacts because an
@@ -128,7 +134,7 @@ abstract class PythonArtifactBindingsTask : DefaultTask() {
//
// Not a direct `KlibScanner.scanKlib` call: see this class's KDoc, "The klib
// branch runs behind a worker classloader boundary".
- workerExecutor.scanKlibIsolated(file, includes, temporaryDir, klibReaderClasspath.files).callables
+ workerExecutor.scanKlibIsolated(file, includes, excludes, temporaryDir, klibReaderClasspath.files).callables
}
// An artefact that contributed nothing gets no fragment: a fragment with no entries
// would still take a `moduleName` in `UpcallTable`'s installed set, which is the one
diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt
index 4cea7f641..93082c4a1 100644
--- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt
+++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt
@@ -10,8 +10,27 @@ import python.multiplatform.gradle.model.ValueClassModel
*
* One `def` per bound declaration, in the module the runtime publishes it onto, under the Kotlin
* names. The binder never exports a Kotlin namespace under another name (AGENTS.md §12 rule 1), so
- * there is no second, renamed product: a `pythonx` module is a real Python package that imports
- * these modules and makes them Pythonic, and its own stubs are its own.
+ * there is no second, renamed product and nothing under `pythonx`: a `pythonx` module is a real
+ * Python package that imports these modules, and its own stubs are its own.
+ *
+ * ### Pythonic names (issue #131)
+ *
+ * The runtime serves every lower-case-first declaration and member under its snake_case alias too,
+ * and takes each keyword by its Kotlin name or its snake_case one ([pythonName], [snakeCase],
+ * [pythonicAliases]; the runtime's rule in `KotlinSurface.kt`). The stub says the same:
+ *
+ * - **parameters** are written under the keyword `inspect.signature` shows -- the snake_case alias
+ * where one is served for that declaration, else the Kotlin name. A stub can name a parameter only
+ * once, and it names it the way the signature does; a Kotlin keyword still works at run time, but a
+ * checker reports it;
+ * - **module-level names** keep their Kotlin `def` (or constant), followed by `alias = kotlinName` for
+ * each alias the runtime serves in that module, so the alias has exactly the Kotlin name's type;
+ * - **members of a receiver's class** get the alias as a second `ClassVar` of the same `Protocol`, or a
+ * second `@property` with the same table key in its docstring;
+ * - members of a callable module (#78) get the alias as a second member.
+ *
+ * Not aliased: a class module folded into its parent for a case-insensitive filesystem (#44) -- its
+ * functions keep their Kotlin names only in the stub, although the runtime serves the alias.
*
* Returns *relative path to file content*, so nothing here touches a disk and every assertion in
* `PyiRenderingTest` and `TypedStubTest` is over text. `PythonStubsTask` is the only thing that writes.
@@ -95,7 +114,15 @@ internal const val PROPERTY_MARKER = "Kotlin property: "
*/
internal const val CONSTRUCTOR_MARKER = "Kotlin constructor: "
+/**
+ * What every member-function stub (issue #215) carries after its table key, in the docstring of the
+ * method or in the comment that says why there is none -- the one place a test can match a key to its
+ * stub, as [PROPERTY_MARKER] is for a property.
+ */
+internal const val METHOD_MARKER = "Kotlin member: "
+
private val PROPERTY_KINDS = setOf("GETTER", "SETTER")
+private const val METHOD_KIND = "METHOD"
/** Python's hard keywords (`keyword.kwlist`): none of these can be a parameter name. */
private val PYTHON_KEYWORDS = setOf(
@@ -173,8 +200,13 @@ private enum class Role { PARAM, RESULT }
private class StubRenderer(declarations: List) {
- /** Everything that is a module attribute; properties are attributes of a proxy instead. */
- private val bound: List = declarations.filter { it.kind !in PROPERTY_KINDS }
+ /** Everything that is a module attribute; properties and member functions are attributes of a proxy instead. */
+ private val bound: List = declarations.filter { it.kind !in PROPERTY_KINDS && it.kind != METHOD_KIND }
+
+ /** receiver type -> its bound member functions (issue #215), each a method of the receiver's stub class. */
+ private val methods: Map> = declarations
+ .filter { it.kind == METHOD_KIND && it.bindingName != null && it.receiver != null }
+ .groupBy { it.receiver!!.qualifiedName }
/** receiver type -> property name -> (getter, setter). A setter never stands without its getter. */
private val properties: Map>> = run {
@@ -337,6 +369,21 @@ private class StubRenderer(declarations: List) {
* (a module and a class cannot share the name, which is why this used to fall back to `Any`).
* The object's own type (`Alignment`) has no such home and stays unresolved here.
*/
+ /**
+ * Where [ref]'s class is declared in its module's stub: its own path, or -- when a function of the
+ * module takes its top name (Kotlin's factory idiom, `Job` and `fun Job()`, issue #217) -- the same
+ * path under the stub-private name `_`. The function keeps the name, as at run time, and the
+ * type keeps a class every use of it can name; nothing at run time is called `_`. `null` when
+ * even that name is taken.
+ */
+ private fun stubPathOf(ref: ClassRef): List? {
+ val taken = takenNames[ref.pkg].orEmpty()
+ if (ref.top !in taken) return ref.path
+ val private = "_" + ref.top
+ if (private in taken) return null
+ return listOf(private) + ref.path.drop(1)
+ }
+
private fun resolveRef(qualifiedName: String): ClassRef? {
val ref = classRefOf(qualifiedName) ?: return null
val objectModule = "${ref.pkg}.${ref.top}"
@@ -346,9 +393,10 @@ private class StubRenderer(declarations: List) {
private fun classReference(qualifiedName: String, valueClass: ValueClassModel?, ctx: ModuleOut): String {
val ref = resolveRef(qualifiedName) ?: return ANY
- if (ref.top in takenNames[ref.pkg].orEmpty() || "${ref.pkg}.${ref.top}" in moduleNames) return ANY
+ if ("${ref.pkg}.${ref.top}" in moduleNames) return ANY
+ val stubPath = stubPathOf(ref) ?: return ANY
if (qualifiedName !in classes || classes[qualifiedName] == null) classes[qualifiedName] = valueClass ?: classes[qualifiedName]
- val path = ref.path.joinToString(".")
+ val path = stubPath.joinToString(".")
if (ref.pkg == ctx.module) return path
ctx.imports += ref.pkg
return "${ref.pkg}.$path"
@@ -364,7 +412,11 @@ private class StubRenderer(declarations: List) {
hasReceiver -> RECEIVER_NAME
else -> null
}
- val writable = parameters.map { d.parameterNamesKnown && isWritableName(it.name, reserved) }
+ // Issue #131: each parameter under the keyword `inspect.signature` shows at run time -- its
+ // snake_case alias where one is served for this declaration, else its Kotlin name. The runtime
+ // accepts both spellings; a stub can name only one.
+ val shown = pythonicParameterNames(d)
+ val writable = parameters.indices.map { d.parameterNamesKnown && isWritableName(shown[it], reserved) }
// Everything up to and including the last unwritable name is positional-only.
val positionalOnlyEnd = writable.indexOfLast { !it }
val lastRequired = parameters.indexOfLast { !it.declaresDefault }
@@ -383,7 +435,7 @@ private class StubRenderer(declarations: List) {
}
parameters.forEachIndexed { index, parameter ->
if (index == keywordOnlyStart) rendered += "*"
- val name = if (writable[index]) parameter.name!! else "__a$index"
+ val name = if (writable[index]) shown[index]!! else "__a$index"
val marked = if (positionalOnlyEnd >= 0) parameter.declaresDefault && index > lastRequired else parameter.declaresDefault
rendered += "$name: " + annotate(parameter.type, parameter.boundaryTag, Role.PARAM, ctx) + if (marked) " = ..." else ""
if (index == positionalOnlyEnd) rendered += "/"
@@ -392,6 +444,20 @@ private class StubRenderer(declarations: List) {
return rendered.joinToString(", ")
}
+ /**
+ * Per parameter, the name `KotlinSurface.signature_of` shows: the snake_case alias the runtime
+ * serves for it (`keyword_slots`: not another parameter's Kotlin name, not shared by two
+ * parameters, not a Python keyword), else the Kotlin name; `null` where the name is unknown.
+ */
+ private fun pythonicParameterNames(d: DeclarationModel): List {
+ val kotlinNames = d.parameters.mapNotNull { it.name }
+ val aliasOf = pythonicAliases(kotlinNames, ::snakeCase).entries.associate { (alias, kotlin) -> kotlin to alias }
+ return d.parameters.map { parameter ->
+ val name = parameter.name ?: return@map null
+ aliasOf[name]?.takeIf { it !in PYTHON_KEYWORDS } ?: name
+ }
+ }
+
private fun result(d: DeclarationModel, ctx: ModuleOut): String =
annotate(d.returnType, d.returnBoundaryTag, Role.RESULT, ctx)
@@ -448,6 +514,9 @@ private class StubRenderer(declarations: List) {
attributes.getOrPut(leaf) { mutableListOf() } += d
if (d.kind != "STATIC_GETTER" && isSuffixed(leaf)) attributes.getOrPut(baseOf(leaf)) { mutableListOf() } += d
}
+ // Issue #131: the module's Pythonic aliases are members too, typed as their Kotlin names.
+ val namespace = attributes.keys + childSegments("${ctx.module}.$name")
+ pythonicAliases(namespace).forEach { (alias, kotlin) -> attributes[kotlin]?.let { attributes[alias] = it } }
attributes.forEach { (attribute, group) ->
if (!isIdentifier(attribute) || attribute == "__call__") {
appendLine(" # '$attribute' cannot be written as an attribute here; reach it with getattr(..., '$attribute')")
@@ -514,15 +583,35 @@ private class StubRenderer(declarations: List) {
}
// The base name of an overload set: the binding layer serves it, so the stub says it.
val unsuffixedLeaves = sorted.filter { !isSuffixed(leafOf(it)) }.map { leafOf(it) }.toSet()
+ val overloadBases = mutableSetOf()
sorted.filter { it.kind != "STATIC_GETTER" && isSuffixed(leafOf(it)) }
.groupBy { baseOf(leafOf(it)) }
.toSortedMap()
.forEach { (base, group) ->
- if (base !in unsuffixedLeaves && base !in companions) ctx.defs += overloadDefs(base, group, ctx)
+ if (base !in unsuffixedLeaves && base !in companions) {
+ ctx.defs += overloadDefs(base, group, ctx)
+ overloadBases += base
+ }
}
+ // Issue #131: the Pythonic alias of every name written above, as an assignment, so a
+ // checker gives it exactly the Kotlin name's type. The namespace is the runtime's
+ // (`_package_aliases`): every bound name of the package and its child packages.
+ val written = sorted.map { leafOf(it) } + constructors.filter { isSuffixed(leafOf(it)) }.map { leafOf(it) } +
+ companions + overloadBases
+ val namespace = entries.flatMap { listOf(leafOf(it), baseOf(leafOf(it))) } + childSegments(module)
+ ctx.defs += aliasAssignments(pythonicAliases(namespace), written.toSet())
}
}
+ /** The segment directly under [module] of every module below it: its child packages and objects. */
+ private fun childSegments(module: String): List =
+ byModule.keys.filter { it.startsWith("$module.") }.map { it.removePrefix("$module.").substringBefore('.') }.distinct()
+
+ /** `alias = kotlinName` for each alias whose Kotlin name the stub module writes, sorted by alias. */
+ private fun aliasAssignments(aliases: Map, written: Set): List =
+ aliases.filter { (alias, kotlin) -> kotlin in written && kotlin !in PYTHON_KEYWORDS && alias !in PYTHON_KEYWORDS }
+ .map { (alias, kotlin) -> "$alias = $kotlin" }
+
/**
* A class module that collides case-insensitively with a sibling path (issue #44): its functions
* become static members of the class of that name in the parent package's module, nested for the
@@ -587,14 +676,21 @@ private class StubRenderer(declarations: List) {
private fun renderClass(qualifiedName: String) {
val ref = resolveRef(qualifiedName) ?: return
val ctx = out(ref.pkg)
- val node = ctx.node(ref.path)
+ val stubPath = stubPathOf(ref) ?: return
+ val node = ctx.node(stubPath)
node.qualifiedName = qualifiedName
val valueClass = classes[qualifiedName]
+ // A class under its stub-private name (issue #217) says so, and why, whatever else it says.
+ val privateNote = if (stubPath.first() != ref.top) {
+ " (stubbed as ${stubPath.joinToString(".")}: '${ref.top}' is the function of that name in its module)"
+ } else {
+ ""
+ }
node.doc = if (valueClass != null) {
- "Kotlin value class $qualifiedName over ${valueClass.underlying.qualifiedName}. As a parameter the raw " +
+ "Kotlin value class $qualifiedName$privateNote over ${valueClass.underlying.qualifiedName}. As a parameter the raw " +
"primitive is also accepted (the binding layer's allowlist decides, at run time); a result is the raw primitive."
} else {
- "Kotlin: $qualifiedName"
+ "Kotlin: $qualifiedName$privateNote"
}
stubBasesOf(qualifiedName, ctx).takeIf { it.isNotEmpty() }?.let { node.bases = it.joinToString(", ") }
@@ -610,12 +706,25 @@ private class StubRenderer(declarations: List) {
}
}
+ // Issue #215: member functions, by explicit name and, for an overload set, by base name -- the two
+ // keys `_index` serves them under. A member wins over an extension of the same name, as at run time.
+ val memberFunctions = sortedMapOf>()
+ methods[qualifiedName].orEmpty().sortedBy { it.bindingName }.forEach { d ->
+ val leaf = leafOf(d)
+ memberFunctions.getOrPut(leaf) { mutableListOf() } += d
+ if (isSuffixed(leaf)) memberFunctions.getOrPut(baseOf(leaf)) { mutableListOf() } += d
+ }
val attributes = sortedMapOf>()
extensionsByReceiver[qualifiedName].orEmpty().sortedBy { it.bindingName }.forEach { d ->
val leaf = leafOf(d)
+ if (leaf in memberFunctions) return@forEach
attributes.getOrPut(leaf) { mutableListOf() } += d
- if (isSuffixed(leaf)) attributes.getOrPut(baseOf(leaf)) { mutableListOf() } += d
+ if (isSuffixed(leaf) && baseOf(leaf) !in memberFunctions) attributes.getOrPut(baseOf(leaf)) { mutableListOf() } += d
}
+ // Issue #131: the proxy also serves each member's Pythonic alias, decided over every member
+ // name the runtime looks up for this type -- its own and every supertype's (`_member_aliases`).
+ val aliases = pythonicAliases(memberNamesOf(qualifiedName))
+ val protocols = mutableMapOf()
attributes.forEach { (attribute, group) ->
if (!isIdentifier(attribute) || attribute in node.children) return@forEach
val protocol = "_" + ref.path.joinToString("_") + "_" + attribute
@@ -628,7 +737,19 @@ private class StubRenderer(declarations: List) {
}
}
node.members += "$attribute: $TYPING.ClassVar[$protocol]"
+ protocols[attribute] = protocol
+ }
+ val stubbedMethods = mutableMapOf()
+ memberFunctions.forEach { (name, group) ->
+ if (!isIdentifier(name) || name in node.children) {
+ group.filter { leafOf(it) == name }.forEach { node.members += methodNotStubbed(it, "'$name' cannot be written as an attribute here") }
+ return@forEach
+ }
+ val rendered = renderMethods(name, group, ctx)
+ node.members += rendered
+ stubbedMethods[name] = name
}
+ val stubbedProperties = mutableMapOf>()
properties[qualifiedName].orEmpty().forEach { (name, accessors) ->
val (getter, setter) = accessors
if (!isIdentifier(name) || name in node.children || name in attributes) {
@@ -636,9 +757,43 @@ private class StubRenderer(declarations: List) {
return@forEach
}
node.members += renderProperty(name, getter, setter, ctx)
+ stubbedProperties[name] = accessors
+ }
+ aliases.forEach { (alias, kotlin) ->
+ if (!isIdentifier(alias) || alias in node.children) return@forEach
+ protocols[kotlin]?.let { node.members += "$alias: $TYPING.ClassVar[$it]" }
+ stubbedProperties[kotlin]?.let { (getter, setter) -> node.members += renderProperty(alias, getter, setter, ctx) }
+ if (kotlin in stubbedMethods && alias !in memberFunctions) node.members += renderMethods(alias, memberFunctions.getValue(kotlin), ctx)
}
}
+ /**
+ * One member function, or an overload set under its base name, as methods named [name]. A single
+ * declaration's method carries its table key after [METHOD_MARKER]; an overload set's `@overload`s
+ * do not, because each member is also stubbed under its own explicit name, which does.
+ */
+ private fun renderMethods(name: String, group: List, ctx: ModuleOut): String =
+ if (group.size == 1) {
+ val d = group.single()
+ "def $name(${parameters(d, ctx, method = true)}) -> ${result(d, ctx)}:\n" +
+ " \"\"\"$METHOD_MARKER${d.bindingName}\"\"\"\n" +
+ " ..."
+ } else {
+ group.mapIndexed { index, d ->
+ "@$TYPING.overload\ndef $name(${parameters(d, ctx, method = true)}) -> ${result(d, ctx)}: ..." + shadowed(index)
+ }.joinToString("\n")
+ }
+
+ private fun methodNotStubbed(d: DeclarationModel, why: String): String = "# $METHOD_MARKER${d.bindingName} is not stubbed: $why"
+
+ /** Every member name the binding layer serves on a proxy of [qualifiedName]: `_member_names`. */
+ private fun memberNamesOf(qualifiedName: String): Set =
+ (listOf(qualifiedName) + supertypes[qualifiedName].orEmpty()).flatMap { type ->
+ extensionsByReceiver[type].orEmpty().flatMap { listOf(leafOf(it), baseOf(leafOf(it))) } +
+ methods[type].orEmpty().flatMap { listOf(leafOf(it), baseOf(leafOf(it))) } +
+ properties[type].orEmpty().keys
+ }.toSet()
+
private fun renderProperty(name: String, getter: DeclarationModel, setter: DeclarationModel?, ctx: ModuleOut): String = buildString {
appendLine("@property")
appendLine("def $name(self) -> ${result(getter, ctx)}:")
@@ -667,6 +822,21 @@ private class StubRenderer(declarations: List) {
* a value of it can still reach Python (a `kotlin.Any?` result, a supertype's slot). One whose
* class cannot be stubbed gets the marker comment instead; see this file's KDoc.
*/
+ /**
+ * Every receiver that has member functions gets its stub class, as one with properties does; one
+ * whose class cannot be stubbed gets a marker comment per member in its package's module instead.
+ */
+ private fun registerMethodOwners() {
+ methods.forEach { (receiver, declarations) ->
+ val ref = classRefOf(receiver)
+ val ctx = out(ref?.pkg ?: receiver.substringBeforeLast('.', ""))
+ if (ref != null && classReference(receiver, null, ctx) != ANY) return@forEach
+ declarations.forEach { d ->
+ ctx.defs += methodNotStubbed(d, "${receiver.substringAfterLast('.')} has no stub class (its name is taken in its module)")
+ }
+ }
+ }
+
private fun registerPropertyOwners() {
properties.forEach { (receiver, byName) ->
val ref = classRefOf(receiver)
@@ -704,6 +874,7 @@ private class StubRenderer(declarations: List) {
fun render(): Map