From 9b8fdb34d6c464df0597e69fe47886eff5521541 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:51:57 +0900 Subject: [PATCH] Docs: Remove every em-dash and the last pip example (#139) --- AGENTS.md | 64 ++--- PROJECT.md | 20 +- README.md | 34 +-- benchmarks/cost_table.py | 30 +-- benchmarks/rn-benchmark/COMPARISON.md | 186 ++++++------- benchmarks/rn-benchmark/README.md | 36 +-- benchmarks/rn-benchmark/RNBench/README.md | 4 +- benchmarks/typedpython/run.py | 4 +- docs/INTENT.md | 32 +-- docs/SPEC.md | 152 +++++------ docs/archive/README.md | 2 +- docs/archive/android-ffm-design.md | 8 +- ...downcall-design-android-first-diagnosis.md | 6 +- .../downcall-design-benchmark-drift.md | 78 +++--- .../android-unregistered-surface.md | 8 +- .../gc-scheduling-investigation.md | 98 +++---- .../gil-parking-investigation.md | 58 ++-- .../jni-call-convention-audit.md | 28 +- .../kotlin-extensions-in-python-pyi.md | 8 +- docs/archive/pyi-generation-pythonic-stubs.md | 72 ++--- docs/archive/pythonx-adapter-design.md | 254 +++++++++--------- docs/archive/threading-and-abi-3.15t-gate.md | 8 +- docs/archive/wasm-design-experiment-log.md | 230 ++++++++-------- docs/design/architecture.md | 26 +- docs/design/binding-policy.md | 24 +- docs/design/downcall-design.md | 138 +++++----- docs/design/ecosystem.md | 246 ++++++++--------- docs/design/kotlin-extensions-in-python.md | 144 +++++----- docs/design/marshalling-design.md | 24 +- docs/design/object-lifetime.md | 60 ++--- docs/design/pyi-generation-design.md | 106 ++++---- docs/design/threading-and-abi.md | 12 +- docs/design/typedpython.md | 68 ++--- docs/design/upcall.md | 184 ++++++------- docs/guide/assets/style.css | 2 +- docs/guide/concepts.html | 2 +- docs/guide/guide-kotlin-to-python.html | 2 +- docs/guide/index.html | 4 +- .../android-unregistered-surface.md | 2 +- docs/investigations/cost-table.md | 78 +++--- .../gc-scheduling-investigation.md | 20 +- .../jni-call-convention-audit.md | 16 +- docs/locale/README_ko.md | 34 +-- docs/platforms/desktop-packaged-app.md | 8 +- .../graal-native-image-verification.md | 22 +- docs/platforms/ios-app-bundle.md | 10 +- docs/platforms/python-version-acquisition.md | 10 +- docs/platforms/wasm-design.md | 20 +- docs/roadmap/ROADMAP.md | 130 ++++----- .../fixture/compose/PythonPullRefresh.kt | 12 +- .../fixture/compose/M3ProofRenderTest.kt | 44 +-- .../fixture/compose/PullRefreshRenderTest.kt | 10 +- .../gradle/PythonBindingsPlugin.kt | 4 +- .../multiplatform/gradle/PythonHomeStaging.kt | 22 +- .../gradle/WasmProxyExportsRendering.kt | 8 +- .../gradle/artifact/ArtifactScanner.kt | 2 +- .../gradle/WasmProxyExportsRenderingTest.kt | 8 +- .../src/main/python/typedpython/__init__.py | 2 +- .../src/main/python/typedpython/__main__.py | 2 +- .../src/main/python/typedpython/cgen.py | 30 +-- .../src/main/python/typedpython/frontend.py | 4 +- .../main/python/typedpython/incremental.py | 2 +- .../src/main/python/typedpython/ir.py | 58 ++-- .../src/main/python/typedpython/pipeline.py | 2 +- .../main/python/typedpython/runtime/API.md | 4 +- .../src/main/python/typedpython/verify.py | 2 +- .../src/test/python/test_cgen.py | 4 +- .../test/python/test_cgen_class_changes.py | 8 +- .../src/test/python/test_cgen_classes.py | 4 +- .../src/test/python/test_verify.py | 6 +- python-multiplatform/build.gradle.kts | 8 +- .../PythonInstrumentationRunner.kt | 8 +- .../assembled/AssembledApiTest.kt | 4 +- .../env/PythonPayloadStagingTest.kt | 2 +- .../python/native/ffi/JniOverheadBenchmark.kt | 2 +- .../kotlin/python/native/ffi/JniWiringTest.kt | 6 +- .../python/native/ffi/PythonOnDevice.kt | 4 +- .../src/androidMain/README.md | 56 ++-- .../env/PythonPayload.android.kt | 2 +- .../ref/PyAutoCloseable.android.kt | 6 +- .../python/native/ffi/EmbedAPI.android.kt | 4 +- .../python/native/ffi/UpcallEntry.android.kt | 6 +- python-multiplatform/src/artMain/README.md | 4 +- .../multiplatform/env/PythonPayload.art.kt | 2 +- .../python/multiplatform/ffi/JniExport.kt | 2 +- python-multiplatform/src/commonMain/README.md | 42 +-- .../kotlin/python/multiplatform/Versions.kt | 4 +- .../python/multiplatform/env/PythonPath.kt | 6 +- .../python/multiplatform/env/PythonPayload.kt | 24 +- .../python/multiplatform/ffi/GILScope.kt | 2 +- .../python/multiplatform/ffi/Python3.kt | 80 +++--- .../kotlin/python/native/ffi/EmbedAPI.kt | 16 +- .../python/multiplatform/VersionsTest.kt | 2 +- .../env/InterpreterAvailabilityTest.kt | 2 +- .../multiplatform/ffi/PythonTestFixture.kt | 2 +- .../python/multiplatform/ffi/RunAppTest.kt | 2 +- .../python/multiplatform/ffi/RunMainTest.kt | 10 +- .../multiplatform/ref/DoubleReleaseTest.kt | 8 +- .../multiplatform/ref/EvalCheckpointTest.kt | 8 +- .../python/native/ffi/EmbedApiLowLevelTest.kt | 4 +- .../src/desktopMain/README.md | 32 +-- .../env/PythonPayload.desktop.kt | 22 +- .../kotlin/python/native/ffi/Panama.kt | 4 +- .../kotlin/python/native/ffi/bindings.kt | 2 +- .../multiplatform/env/PythonPayloadTest.kt | 2 +- python-multiplatform/src/iosMain/README.md | 10 +- .../ref/PhantomCleanerRegistry.kt | 12 +- .../kotlin/python/native/ffi/EmbedAPI.jvm.kt | 2 +- .../ref/PhantomCleanerRegistryTest.kt | 14 +- .../arm64-v8a/python3.13/idlelib/help.html | 10 +- .../arm64-v8a/python3.13/pydoc_data/topics.py | 34 +-- .../python3.13/test/test_zoneinfo/_support.py | 4 +- .../test/test_zoneinfo/test_zoneinfo.py | 2 +- .../test_zoneinfo/test_zoneinfo_property.py | 2 +- .../x86_64/python3.13/idlelib/help.html | 10 +- .../x86_64/python3.13/pydoc_data/topics.py | 34 +-- .../python3.13/test/test_zoneinfo/_support.py | 4 +- .../test/test_zoneinfo/test_zoneinfo.py | 2 +- .../test_zoneinfo/test_zoneinfo_property.py | 2 +- python-multiplatform/src/nativeMain/README.md | 10 +- python-multiplatform/src/wasmJsMain/README.md | 110 ++++---- .../multiplatform/env/PythonPayload.wasmJs.kt | 4 +- .../emscripten/WasmSharedMemoryGrowthTest.kt | 2 +- sample/build.gradle.kts | 2 +- .../python/multiplatform/demo/MainActivity.kt | 4 +- .../python/multiplatform/demo/ui/App.kt | 16 +- 126 files changed, 1865 insertions(+), 1865 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b4caeaffa..0b0cd50de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,8 @@ overrides any default your tooling has. ## 2. Nothing is created outside this repository -Everything your work produces — worktrees, agent prompts, logs, measurements, experiments, scratch -files — lives **inside this repository's root directory.** +Everything your work produces, worktrees, agent prompts, logs, measurements, experiments, scratch +files, lives **inside this repository's root directory.** | What | Where | |---|---| @@ -26,7 +26,7 @@ files — lives **inside this repository's root directory.** Before writing a file, check that its absolute path starts with this repository's root. If it does not, stop. The only exceptions are a path the user names explicitly, and caches that build tools -manage themselves. **Re-pointing a shared cache or a home-directory symlink reaches other projects — +manage themselves. **Re-pointing a shared cache or a home-directory symlink reaches other projects, ask first.** Writing to *another* repository is not an exception either. Do it only when told to work there. @@ -35,7 +35,7 @@ Writing to *another* repository is not an exception either. Do it only when told **Never add a new directory (or a new file) at the repository root on your own.** The root layout is the maintainer's: source modules, `docs/`, `gradle/`, `.github/` and the files that tools require -there. Work belongs inside an existing module or directory — sources under `src//`, +there. Work belongs inside an existing module or directory, sources under `src//`, CI-only scripts under `.github/scripts/`, temporary files under the git-ignored `.tmp/`. If you think a new top-level entry is needed, propose it (what, why, which alternatives inside existing directories you ruled out) and wait for approval. This was added after unapproved root folders @@ -49,7 +49,7 @@ build caches, model weights, `node_modules`) into every worktree is how 86 workt - Create worktrees under `.worktrees/`. - **Symlink** large untracked directories from the main checkout instead of copying or rebuilding - them. If `tools/worktree-add.sh` exists, use it — it does the linking. + them. If `tools/worktree-add.sh` exists, use it, it does the linking. - Delete a worktree once its branch is merged: `git worktree remove .worktrees/`. - Periodically delete `build/` directories inside worktrees; they only grow. @@ -78,7 +78,7 @@ Every new feature goes through an issue and a pull request: 1. Before starting, search the repository's issues (`gh issue list --state all --search ""`). 2. If no issue covers the work, open one (`gh issue create`) stating what and why, and the - completion criterion — which tests must pass. + completion criterion, which tests must pass. 3. Work on a `feat/` branch, push every commit, and open a pull request into `develop` whose body contains `Closes #`. 4. Merge into `develop` through that pull request (`gh pr merge`), not by a local merge, so the @@ -102,18 +102,18 @@ If a request conflicts with `docs/INTENT.md`, say so instead of implementing it. ## 6. User-authored files are specification -Files the user wrote by hand — notebooks, example build files, sample apps — are the specification. +Files the user wrote by hand, notebooks, example build files, sample apps, are the specification. Read them **first**. Never delete, rewrite, or `git add` them without being told to. Generated documentation (roadmaps, design notes) is a record of work, not a requirement; when the two disagree, the user's file wins. ## 7. Show a conclusion before acting on it -Anything beyond the immediate request — another repository, a public API signature, deleting -files, killing processes, force-pushing, changing branch protection — state what you would do and +Anything beyond the immediate request, another repository, a public API signature, deleting +files, killing processes, force-pushing, changing branch protection, state what you would do and why, and wait. Investigating, measuring, and reporting are always fine. -**Push every commit right away.** After you commit — on a work branch or on `develop` — push it to +**Push every commit right away.** After you commit, on a work branch or on `develop`, push it to the remote immediately; no confirmation is needed. Never push to `main` or `release` by hand, and never force-push without the user's explicit approval. @@ -162,7 +162,7 @@ to implement", say what you counted against. - Give every agent prompt the absolute paths it may write to, and repeat rule 2 in it. - **Subagents do not run heavy local builds.** Subagents write code, design, investigate, review and document. Gradle builds, cargo builds, the test gate and model runs are done by the session - itself — one at a time on this machine — or by CI (GitHub Actions) on a pushed branch. Several + itself, one at a time on this machine, or by CI (GitHub Actions) on a pushed branch. Several sessions share one machine; parallel local builds slow every one of them. --- @@ -201,13 +201,13 @@ the default hierarchy template is not applied. ### Layers -- **FFI** — `python/native/ffi/EmbedAPI.kt` declares the CPython Stable ABI as `expect` functions +- **FFI**: `python/native/ffi/EmbedAPI.kt` declares the CPython Stable ABI as `expect` functions (about 330). Each platform supplies `EmbedAPI..kt` + `bindings.kt` as `actual`s. `NativePointer` (a value class) unifies the platform pointer representation. -- **Object model** — `python/multiplatform/ffi/` holds the Kotlin wrapper types rooted at `PyObject`. -- **Upcall runtime** — `python/multiplatform/reflection/` and `python/multiplatform/ffi/upcall/`. +- **Object model**: `python/multiplatform/ffi/` holds the Kotlin wrapper types rooted at `PyObject`. +- **Upcall runtime**: `python/multiplatform/reflection/` and `python/multiplatform/ffi/upcall/`. -## 12. Decisions already made — do not reverse them +## 12. Decisions already made, do not reverse them These come from the maintainer or were settled with evidence. Raise a concern instead of working around one. @@ -231,7 +231,7 @@ around one. GraalVM native image is closed-world. Upcalls go through a table generated at build time. 4. **Never look up JVM methods by name.** The artifact walker generates Kotlin source and `kotlinc` compiles it; the compiler does the name mangling. (Value-class mangling hashes only the - signature, so `padding`, `size`, `width` and `height` all share one suffix — name lookup is + signature, so `padding`, `size`, `width` and `height` all share one suffix, name lookup is impossible in principle.) 5. **`.pyi` generation belongs to the Gradle plugin** (the PyREPL approach). 6. **`JClass` / `KClass` / `ObjcClass` work only where the platform really has them.** Do not stub @@ -244,7 +244,7 @@ around one. ## 13. Upcalls must work in a GraalVM native image An upcall (Python → Kotlin) is not done when it passes on the JVM. It must also work in a **GraalVM -native image** — that is the reason the design uses a build-time table. Only building the image +native image**, that is the reason the design uses a build-time table. Only building the image proves the choice holds. - Toolchain: **Liberica Native Image Kit**. @@ -277,7 +277,7 @@ Rule 5 applies. In addition: - Compile errors are lines starting with `e: `. Most `w: ` warnings (inlining in particular) are pre-existing; ignore them. -- In a **background** run, do not end the script with `echo` — the tool then reports `echo`'s exit +- In a **background** run, do not end the script with `echo`, the tool then reports `echo`'s exit code (always 0). Read the `EXIT=` line from the log, or make Gradle the last command. - Android tasks need `ANDROID_HOME` set to the Android SDK. - Before counting results, delete `build/test-results//` **and** pass `--rerun` (or @@ -317,7 +317,7 @@ Running the three in one invocation once hid 24 failures caused by an ordering d - `git merge` **silently refuses** when there are uncommitted changes. Check the result directly (`git merge --no-edit develop && echo OK`), not by grepping for `CONFLICT`; otherwise you verify against the old baseline. -- **Rebuild after every merge.** Branches that pass separately can break together — two agents once +- **Rebuild after every merge.** Branches that pass separately can break together, two agents once migrated the same function independently and cinterop rejected the duplicate C wrapper. Before adding JNI wrappers, check for duplicates: @@ -327,7 +327,7 @@ Running the three in one invocation once hid 24 failures caused by an ordering d ### Inspecting an agent's work - To check that a test is red before a fix, use `git stash push -- ` and `git stash pop`. - **Never `git checkout -- `** on uncommitted agent work — it cannot be recovered (nine + **Never `git checkout -- `** on uncommitted agent work, it cannot be recovered (nine files were lost that way once). - Agents have broken every kind of instruction here: reverting others' work with `git checkout`, editing `commonMain` and the source-set structure of `build.gradle.kts` outside their scope, @@ -345,7 +345,7 @@ them: `python-multiplatform/src//README.md`. **Read it before touchin | `desktopMain` | **Never `MethodHandle.invoke`; always `invokeExact`.** Pointers are `JAVA_LONG`, not `ADDRESS` | | `androidMain` | Bind through `RegisterNatives`; choose the calling convention per API level and per function; only primitive types cross the boundary | | `artMain` | JNI lives here only (`nativeMain` is shared with iOS); this is where composed functions go | -| `nativeMain` | Shared with iOS — no Android-only code. There is no boundary, so no composition is needed | +| `nativeMain` | Shared with iOS, no Android-only code. There is no boundary, so no composition is needed | | `iosMain` | The framework has no stdlib → `PYTHONHOME` is required; pass env vars to simulator tests with the `SIMCTL_CHILD_` prefix | | `wasmJsMain` | Experimental; see its README and `docs/platforms/wasm-design.md` | @@ -380,22 +380,22 @@ below exists because that happened here. check explicitly that it is compatible with the structure the user described. If you think your design is better, **say so instead of building it.** *(An adapter that synthesised `pythonx.*` set `__path__ = []` on every module, which made the user-required on-disk `pythonx` package - impossible to load — and then its code was deleted as "unnecessary".)* + impossible to load, and then its code was deleted as "unnecessary".)* 3. **Test count is not progress.** Report by category (rule 9). Before writing "nothing left to - implement", state what you counted against — closing every roadmap item left the gaps in + implement", state what you counted against, closing every roadmap item left the gaps in `docs/design/ecosystem.md` §5 untouched. *(71 tests in one repository all asserted absences and added no capability.)* 4. **Your criterion can decide the answer.** When writing an audit or investigation prompt, write one - line on **what this criterion cannot find**. Ask by capability — "what now provides what this - file used to provide?" — not by identifier. When an agent's conclusion matches your hypothesis, + line on **what this criterion cannot find**. Ask by capability, "what now provides what this + file used to provide?", not by identifier. When an agent's conclusion matches your hypothesis, be more suspicious, not less. *(An audit required identical identifiers to count code as migrated; Python code reimplemented as a Kotlin scanner could never match, so "nothing migrated" was reported as fact.)* 5. **A check that cannot fail is not a check.** Run modules separately; never `git add -A` and stop when the changed-file count differs from the report; when you add a public path, disable it and confirm something breaks. *(21 fixtures used raw addresses instead of the new public API, so - emptying the API still passed. A 22nd changed file — a production file replaced by a tamper stub - — was committed by `git add -A`.)* + emptying the API still passed. A 22nd changed file, a production file replaced by a tamper stub + , was committed by `git add -A`.)* 6. **Backward compatibility ranks below rules.** If "to avoid breaking callers we must keep X" comes to mind, check whether X is the forbidden thing. List the callers that break and fix them. 7. **Show conclusions before executing them.** Deleting files, killing processes, changing APIs @@ -417,7 +417,7 @@ user's visibility for interrupt resistance without asking. ### Model tiers -Pass the model explicitly — the `Agent` tool otherwise inherits the coordinator's model. The test is +Pass the model explicitly, the `Agent` tool otherwise inherits the coordinator's model. The test is not "how hard is it" but **"would the coordinator notice if it were wrong?"** Work a test catches can use a cheaper tier; interpreting measurements, deciding what stays open, and safety arguments cannot. @@ -441,7 +441,7 @@ more than the tier. to a backgrounded command, one of them an `xcodebuild`.) - "Do not run `git commit`, `add`, `checkout`, `restore` or `stash`." The only exception is `git merge --no-edit develop`, and if it is refused, report and stop. -- "Do not touch files outside your assigned directories" — in particular the source-set structure in +- "Do not touch files outside your assigned directories", in particular the source-set structure in `build.gradle.kts` and `commonMain`, unless told to. - "Do not report what you did not observe. Count numbers for real. If you do not know, say so; if you could not do it, say so and say where it stopped." @@ -459,7 +459,7 @@ agy -p "" --model --print-timeout 40m \ - `--dangerously-skip-permissions` is required headless: without it the first tool needing the `command` permission is auto-denied and agy exits having done nothing, with no failing exit code. - `--effort` is accepted only by model names without a built-in level (e.g. `gemini-3.6-flash`). - Claude models and names like `gemini-3.6-flash-high` reject it and exit immediately — which looks + Claude models and names like `gemini-3.6-flash-high` reject it and exit immediately, which looks like success in a background run. - After launching, read the head of the log **and** the worktree's `git status --short`; an exit code does not prove the agent did anything. @@ -475,7 +475,7 @@ agy -p "" --model --print-timeout 40m \ - Isolate TDD and large work in a worktree (rule 3). An agent in its red phase breaks the build for every other agent sharing the tree; splitting file ownership does not prevent that. - The limit is the machine (8 cores, 16 GB RAM), not git: **3–4** agents that build and test, plus - 3–4 that only read or write docs. **Android/iOS device tests: one at a time** — there are two + 3–4 that only read or write docs. **Android/iOS device tests: one at a time**, there are two emulators and the same package name collides on install. - Split work by directory; agents sharing a file overwrite each other. Agents sharing one Gradle daemon serialise their builds. @@ -493,7 +493,7 @@ agy -p "" --model --print-timeout 40m \ find .worktrees -maxdepth 3 -type d -name build -not -path '*/build/*' -print0 | xargs -0 -n 20 rm -rf - First make sure no build is running — a Gradle/Kotlin daemon holding files leaves a half-deleted + First make sure no build is running, a Gradle/Kotlin daemon holding files leaves a half-deleted tree. A live process is not necessarily a running build (an `aapt2` once sat for four days); check `etime`. Check each worktree for uncommitted changes before deleting anything. - Deleting the worktree itself is the next step and is safe once its branch is merged. diff --git a/PROJECT.md b/PROJECT.md index 7602db037..267eefc8d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,4 +1,4 @@ -# python-multiplatform — 프로젝트 요점 +# python-multiplatform, 프로젝트 요점 Kotlin Multiplatform 에 CPython 을 임베딩해 **Kotlin 과 Python 이 서로의 라이브러리를 양방향으로** 쓰게 한다. 모든 지원 플랫폼에서 같은 방식으로 동작하고, 경계를 넘는 비용을 측정하며 줄인다. @@ -35,7 +35,7 @@ Kotlin Multiplatform 에 CPython 을 임베딩해 **Kotlin 과 Python 이 서로 | 플랫폼 | Kotlin 실행 환경 | 다운콜 | 업콜 진입 | 상태 | |---|---|---|---|---| | Desktop (macOS) | JVM | Panama FFM (`invokeExact`) | FFM upcall stub | 전체 스위트 · GraalVM 네이티브 이미지 업콜 검증 | -| Desktop (Linux/Windows) | JVM | 동상 | 동상 | 배선만 — 실행 기록 없음 | +| Desktop (Linux/Windows) | JVM | 동상 | 동상 | 배선만, 실행 기록 없음 | | Android | ART | JNI `RegisterNatives` | JNI | 기기 테스트 | | iOS | Kotlin/Native | cinterop | `@CName` | 시뮬레이터에서 공통 스위트 | | androidNative | Kotlin/Native | cinterop | `@CName` | 기기에서 공통 스위트 | @@ -45,7 +45,7 @@ Kotlin Multiplatform 에 CPython 을 임베딩해 **Kotlin 과 Python 이 서로 | 경로 | 내용 | |---|---| -| `python-multiplatform/` | 라이브러리 — FFI 계층(`python/native/ffi/`), 객체 모델(`python/multiplatform/ffi/`), 업콜 런타임 | +| `python-multiplatform/` | 라이브러리, FFI 계층(`python/native/ffi/`), 객체 모델(`python/multiplatform/ffi/`), 업콜 런타임 | | `python-multiplatform-ksp/` | 업콜 테이블을 생성하는 KSP 프로세서 | | `python-multiplatform-gradle-plugin/` | `stagePythonHome`, 아티팩트 워커(빌드된 jar/klib 바인딩), `.pyi` 스텁 생성 | | `ksp-fixtures/` | 생성기 산출물을 실제로 쓰는 소비자 모듈 (`app`, `compose`, `artifact`, …) | @@ -67,7 +67,7 @@ Kotlin Multiplatform 에 CPython 을 임베딩해 **Kotlin 과 Python 이 서로 ``` - 컴파일 확인은 세 타깃: `compileKotlinAndroidNativeArm64`(기본 루프), `compileKotlinIosSimulatorArm64`, - `compileKotlinDesktop`. **androidNative 를 빼먹지 않는다** — `nativeMain` 을 iOS 와 공유한다. + `compileKotlinDesktop`. **androidNative 를 빼먹지 않는다**, `nativeMain` 을 iOS 와 공유한다. - 생성기 소비자 검증은 **모듈마다 따로**: `:ksp-fixtures:app:desktopTest`, `:ksp-fixtures:compose:desktopTest`, `:ksp-fixtures:artifact:desktopTest`. - 결과는 `build/test-results//*.xml` 을 지우고 `--rerun` 으로 다시 돌려 센다. @@ -84,15 +84,15 @@ Kotlin Multiplatform 에 CPython 을 임베딩해 **Kotlin 과 Python 이 서로 KSP 업콜 테이블과 프록시(클래스·프로퍼티·companion·`suspend`·취소), jar 아티팩트 워커, Python 에서 Compose 렌더(데스크톱), Android 부트스트랩, GraalVM 네이티브 이미지 업콜(데스크톱, 수동 검증). - **부분**: iOS·androidNative·wasm 의 업콜과 수명 검증, klib 워커(스캐너만), `.pyi` 스텁, GIL 해제, - Linux/Windows, free-threaded 는 desktop 한정·옵트인(3.14t, `-PpythonFreeThreaded=true`, 236 테스트 0 실패 — ROADMAP §9). + Linux/Windows, free-threaded 는 desktop 한정·옵트인(3.14t, `-PpythonFreeThreaded=true`, 236 테스트 0 실패, ROADMAP §9). - **계획**: Android/iOS 의 free-threaded(프리빌트 없음), Android/iOS/wasm 의 Compose, 네이티브 이미지 검증 자동화. ## 6. 큰 결정들 | 결정 | 근거 | |---|---| -| Python→Kotlin 은 **런타임 리플렉션 아님** — 빌드 타임 테이블 | Kotlin/Native 에 리플렉션이 사실상 없고, GraalVM 네이티브 이미지는 closed-world 다 | -| **JVM 메서드를 이름으로 찾지 않는다** — Kotlin 소스를 생성해 `kotlinc` 가 컴파일 | 값 클래스 맹글링 접미사가 겹쳐서 이름 조회는 원리적으로 불가능하다 | +| Python→Kotlin 은 **런타임 리플렉션 아님**, 빌드 타임 테이블 | Kotlin/Native 에 리플렉션이 사실상 없고, GraalVM 네이티브 이미지는 closed-world 다 | +| **JVM 메서드를 이름으로 찾지 않는다**, Kotlin 소스를 생성해 `kotlinc` 가 컴파일 | 값 클래스 맹글링 접미사가 겹쳐서 이름 조회는 원리적으로 불가능하다 | | 바인더는 **Kotlin 네임스페이스를 다른 이름으로 내보내지 않는다** | 사용자 규정. `androidx.*` 는 원본 Kotlin, `pythonx.*` 는 pythonx-compose 의 실제 패키지 | | 노출은 **블랙리스트** (`@PythonInternal`) | 화이트리스트는 붙이기를 잊으면 조용히 사라진다 | | 병렬성은 **free-threading**, 멀티 인터프리터 아님 | 인터프리터별 GIL 은 C 확장이 `Py_mod_multiple_interpreters` 를 선언해야 import 된다 | @@ -103,11 +103,11 @@ Kotlin Multiplatform 에 CPython 을 임베딩해 **Kotlin 과 Python 이 서로 ## 7. 열린 질문 -`docs/SPEC.md` 끝의 "Outside intent — needs a decision" 이 원본이다. 요지: +`docs/SPEC.md` 끝의 "Outside intent, needs a decision" 이 원본이다. 요지: -1. `.pyi` 스텁 생성기가 매니페스트 없이도 `androidx.*` 를 `pythonx.*` 로 바꿔 쓴다 — 네임스페이스 +1. `.pyi` 스텁 생성기가 매니페스트 없이도 `androidx.*` 를 `pythonx.*` 로 바꿔 쓴다, 네임스페이스 개명 금지 규정과 충돌하는가. -2. 라이브러리가 `sys.modules` 에 합성 `pythonx` 모듈(`__path__ = []`)을 넣는다 — 실제 `pythonx` +2. 라이브러리가 `sys.modules` 에 합성 `pythonx` 모듈(`__path__ = []`)을 넣는다, 실제 `pythonx` 패키지를 가리는가. 3. 멤버 이름의 snake_case 변환은 의도된 것인가. 4. 범용 `pythonx` 어댑터가 이 저장소에 있어야 하는가, pythonx-compose 에 있어야 하는가. diff --git a/README.md b/README.md index 9597ee2df..500f6086e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 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: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Kotlin Multiplatform](https://img.shields.io/badge/Kotlin-Multiplatform-7F52FF.svg?logo=kotlin&logoColor=white)](https://kotlinlang.org/docs/multiplatform.html) @@ -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) 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/run.py b/benchmarks/typedpython/run.py index 7ff2457ec..c7fced567 100644 --- a/benchmarks/typedpython/run.py +++ b/benchmarks/typedpython/run.py @@ -201,7 +201,7 @@ def measure(args: argparse.Namespace) -> int: 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, "—" if c == NOSLOTS else NOT_BUILT)) + 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) + " |") @@ -270,7 +270,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 " diff --git a/docs/INTENT.md b/docs/INTENT.md index 052864626..50fe642dc 100644 --- a/docs/INTENT.md +++ b/docs/INTENT.md @@ -13,7 +13,7 @@ the user-authored `python_for_kotlin_binding.mermaid` and `sample/` app, and the ## 1. Purpose > "CPython 3.13 을 Kotlin Multiplatform 에 임베딩하여 Kotlin ↔ Python 상호운용" -> — *Embed CPython in Kotlin Multiplatform and provide Kotlin ↔ Python interoperability.* +>, *Embed CPython in Kotlin Multiplatform and provide Kotlin ↔ Python interoperability.* python-multiplatform is the **language boundary** of the thisisthepy ecosystem. It puts a real CPython interpreter inside a Kotlin Multiplatform application so that: @@ -29,12 +29,12 @@ CPython interpreter inside a Kotlin Multiplatform application so that: 4. **The project's own Python is statically typed by default** (TypedPython). The maintainer's goal: > "파이썬을 컴파일 언어 및 타입 안정화, 정적 타입 검사, 컴파일 타임 타입검사를 지원할 수 있도록" - > — *Support Python as a compiled language with type stability, static type checking and + >, *Support Python as a compiled language with type stability, static type checking and > compile-time type checking*, so that Python stays usable as AI-friendly languages such as Rust > and TypeScript gain ground. > "TypedPython을 별도 레포가 아니라 PythonMultiplatform의 기본 동작이 될거야" - > — *TypedPython is not a separate repository; it will be PythonMultiplatform's default behaviour.* + >, *TypedPython is not a separate repository; it will be PythonMultiplatform's default behaviour.* The maintainer chose the scope "검사만 기본, 컴파일은 opt-in" (2026-10-02): every user Python source is **checked** by default; **compiling** it to native code is opt-in. The compiled code still runs on @@ -45,14 +45,14 @@ CPython interpreter inside a Kotlin Multiplatform application so that: > a commitment; it is recorded in the design note only. The CPython version moved on from 3.13 (the build pins 3.14.x today and offers opt-in free-threaded 3.14t builds -on desktop only); the intent — embed **CPython itself**, not a re-implementation — is unchanged. +on desktop only); the intent, embed **CPython itself**, not a re-implementation, is unchanged. ## 2. Principles ### 2.1 Upcalls must survive a closed world -> "업콜은 GraalVM 네이티브 이미지에서도 동작해야 한다 — JVM 에서만 통과하는 업콜은 완료가 아니다." -> — *Upcalls must also work in a GraalVM native image. An upcall that passes only on the JVM is not +> "업콜은 GraalVM 네이티브 이미지에서도 동작해야 한다, JVM 에서만 통과하는 업콜은 완료가 아니다." +>, *Upcalls must also work in a GraalVM native image. An upcall that passes only on the JVM is not > complete.* Kotlin/Native has no useful runtime reflection and GraalVM native image is closed-world. Therefore @@ -62,7 +62,7 @@ runtime reflection or name lookup. ### 2.2 Kotlin names are Kotlin names > "바인더는 어떠한 경우에도 코틀린 쪽 네임스페이스를 다른 이름으로 바꿔서 내보내서는 안된다." -> — *The binder must never, in any case, export a Kotlin namespace under a different name.* +>, *The binder must never, in any case, export a Kotlin namespace under a different name.* A Kotlin package is importable from Python under its own fully-qualified name and nothing else. `androidx.compose.ui` in Python means the original Kotlin `androidx.compose.ui`. @@ -71,12 +71,12 @@ A Kotlin package is importable from Python under its own fully-qualified name an > "pythonx 에는 실제 패키지가 존재해야 하고, pythonx 의 코드는 androidx 모듈을 가져와서 pythonic 하게 > 사용 가능하도록 구조를 수정" -> — *pythonx must exist as a real package, and its code imports the androidx modules and makes them +>, *pythonx must exist as a real package, and its code imports the androidx modules and makes them > usable in a Pythonic way.* `pythonx` lives in the separate **pythonx-compose** repository. This repository supplies what such -a package needs — importable Kotlin packages, Kotlin extension functions reachable as Python methods, -type stubs — and must not stand in for it or prevent its files from loading. +a package needs, importable Kotlin packages, Kotlin extension functions reachable as Python methods, +type stubs, and must not stand in for it or prevent its files from loading. ### 2.4 Kotlin idioms appear as Python idioms @@ -94,7 +94,7 @@ reference-count round trips, string marshalling, collection conversion). Overhea Behind the per-platform entry point, every platform uses the same generated function table. There is no per-platform Python → Kotlin binding code to maintain. -> Inferred — confirm with the maintainer: this "one mechanism everywhere" property is a goal, not +> Inferred, confirm with the maintainer: this "one mechanism everywhere" property is a goal, not > only an implementation detail. It is stated as a decision in `PROJECT.md` and `docs/design/upcall.md`. ## 3. What it deliberately is NOT @@ -102,8 +102,8 @@ no per-platform Python → Kotlin binding code to maintain. | Not this | Why | |---|---| | A binder that renames namespaces (`androidx` → `pythonx`, or any other) | §2.2 | -| A replacement for the `pythonx` packages | §2.3 — they are real packages in their own repository | -| Runtime-reflection or name-lookup based binding | §2.1 — impossible under Kotlin/Native and native image | +| A replacement for the `pythonx` packages | §2.3, they are real packages in their own repository | +| Runtime-reflection or name-lookup based binding | §2.1, impossible under Kotlin/Native and native image | | A hand-written wrapper per Kotlin function or class | Generic adaptation plus generated tables; per-declaration wrappers were tried in 2024 and rotted (`docs/archive/pythonx-adapter-design.md`) | | A Python re-implementation, transpiler, or JVM-hosted Python | It embeds CPython itself, so C extensions keep working. TypedPython (§1.4) does not change this: checked code is ordinary Python, and compiled code is a CPython extension module, not a translation to another runtime | | A build tool, dependency resolver or packager | That is pypackpack (the work) and toolchain (the Gradle vocabulary) | @@ -111,7 +111,7 @@ no per-platform Python → Kotlin binding code to maintain. | A vendored interpreter | The build downloads and verifies CPython per platform | | Stubbed-out uniformity (`JClass` / `KClass` / `ObjcClass` faked where the platform lacks them) | Each works only where the platform really has it | -> Inferred — confirm with the maintainer: the build-tool and packager row is taken from +> Inferred, confirm with the maintainer: the build-tool and packager row is taken from > `docs/design/ecosystem.md` ("ppp owns the work, toolchain owns the Gradle vocabulary, this > repository owns the language boundary"), which states the intended division of labour rather than > quoting the maintainer. @@ -119,10 +119,10 @@ no per-platform Python → Kotlin binding code to maintain. ## 4. Who it is for - Kotlin Multiplatform developers who want to use Python libraries from Kotlin on every target. -- Python developers who want to drive Kotlin libraries — Compose UI in particular — from Python, +- Python developers who want to drive Kotlin libraries, Compose UI in particular, from Python, through pythonx-compose. - The sibling thisisthepy projects (pythonx-compose, toolchain, pypackpack, torchnative, Gemstone) that build on this boundary. -> Inferred — confirm with the maintainer: the audience list is derived from the sample app, the +> Inferred, confirm with the maintainer: the audience list is derived from the sample app, the > README and `docs/design/ecosystem.md`; it was not stated as a list. diff --git a/docs/SPEC.md b/docs/SPEC.md index 95e176b45..df77b2529 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -11,7 +11,7 @@ The behavioural contract of python-multiplatform. It must stay inside [`INTENT.m | `planned` | Intended, not asserted by any test. | Status here was assigned from the test sources at the time of writing (2026-10). "Implemented" means -"a test asserts it", not "a test was observed passing in this revision" — this document was written +"a test asserts it", not "a test was observed passing in this revision", this document was written without running Gradle. Tests that only print `SKIP` when no interpreter is available are not counted as evidence. Paths are relative to the repository root; `PM` = `python-multiplatform/src`, `GP` = `python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle`. @@ -35,7 +35,7 @@ fail, then implement. iosSimulatorArm64, androidNativeArm64 and wasmJs (each has a test source set). `partial` for `iosArm64`, `iosX64`, `androidNativeX64` (compile-wired, no tests run there). - **P-2** Linux and Windows desktop: the build downloads and links the right CPython archive. - `Status: partial` — the suite has never been observed running on either. + `Status: partial`, the suite has never been observed running on either. - **P-3** The interpreter is not vendored: Gradle downloads, verifies and extracts CPython per platform; the version is set in `gradle.properties`. `Status: implemented` (build logic; `GP/PythonHomeStagingTest.kt` covers the naming, URL, checksum and stamp decisions). @@ -46,10 +46,10 @@ fail, then implement. `Python3.exec(source)` runs statements, `Python3.eval(expr, mode, globals, locals)` returns a `PyObject`, `Python3.import(name)` returns a `PyModule`. Python errors surface as `PyException` carrying the real Python exception (not a printed-and-cleared one). - `Status: implemented` — `PM/commonTest/.../ffi/Python3Test.kt`, `SmokeTest.kt`, `VersionsTest.kt`. + `Status: implemented`, `PM/commonTest/.../ffi/Python3Test.kt`, `SmokeTest.kt`, `VersionsTest.kt`. - **L-2** `Python3.runMain` / `runApp` run a module or `-c` source with argv, map `sys.exit` status to a return value, restore argv, and do not finalize the shared interpreter. - `Status: implemented` — `PM/commonTest/.../RunMainTest.kt`, `RunAppTest.kt`. + `Status: implemented`, `PM/commonTest/.../RunMainTest.kt`, `RunAppTest.kt`. - **L-3** Before `Py_Initialize`, a `PYTHONHOME` pre-flight check turns a missing or unusable stdlib into a catchable `IllegalStateException` naming the path and version, instead of CPython's process abort. `Status: implemented` on desktop (`PM/desktopTest/.../env/PythonHomeCheckTest.kt`); @@ -58,20 +58,20 @@ fail, then implement. registers `stagePythonHome`, which downloads the matching CPython, verifies it against the release `SHA256SUMS`, caches it machine-wide and sets `PYTHONHOME` on `run`/`test`. A user-set `PYTHONHOME` is never overridden; `pythonBindings { stagePythonHome.set(false) }` disables it. - `Status: partial` — decisions are unit-tested (`GP/PythonHomeStagingTest.kt`); no end-to-end test + `Status: partial`, decisions are unit-tested (`GP/PythonHomeStagingTest.kt`); no end-to-end test of the task. - **L-5** Android: `PythonBootstrap.initialize(context)` unpacks the stdlib shipped in the APK assets to app-private storage once (a stamp written last decides re-unpacking, including after APK - upgrades), sets `PYTHONHOME` and starts the interpreter. `Status: implemented` (needs a device) — + upgrades), sets `PYTHONHOME` and starts the interpreter. `Status: implemented` (needs a device), `PM/androidInstrumentedTest/.../env/PythonBootstrapTest.kt`, `PythonPayloadStagingTest.kt`. - **L-6** A consumer's Python payload (jar resource on desktop, APK asset on Android) is extracted once and put on `sys.path` before the first import. `Status: implemented` on desktop (`PM/desktopTest/.../env/PythonPayloadTest.kt`) and Android (`PythonPayloadStagingTest.kt`). - **L-7** iOS: the framework carries no stdlib, so `PYTHONHOME` must point at one; the test build - extracts it. `Status: partial` — exercised by the shared suite on the simulator; no iOS-specific + extracts it. `Status: partial`, exercised by the shared suite on the simulator; no iOS-specific lifecycle test beyond `PM/iosSimulatorArm64Test/.../AsyncioAvailabilityProbeTest.kt`. An installed app carries its own stdlib: L-11. -- **L-8** Initialisation sets `builtins.compiled` to an identity decorator (an existing one is kept), so `@compiled` needs no import on any platform (N-7, issue #42). `Status: implemented` — `PM/commonTest/.../ffi/BuiltinCompiledTest.kt`. +- **L-8** Initialisation sets `builtins.compiled` to an identity decorator (an existing one is kept), so `@compiled` needs no import on any platform (N-7, issue #42). `Status: implemented`, `PM/commonTest/.../ffi/BuiltinCompiledTest.kt`. - **L-9** Desktop: a packaged application starts with no `PYTHONHOME` in its environment (issue #60, ROADMAP §15.4). The Gradle plugin copies the prefix `stagePythonHome` stages into Compose Desktop's application resources (every `prepare*AppResources` task, so `createDistributable` and the @@ -84,11 +84,11 @@ fail, then implement. that directory exists. A prefix taken from a property goes through L-3's check (a catchable `IllegalStateException` naming the path and where it came from), is handed to CPython with `Py_SetPythonHome`, and is where `libpython` is loaded from when it contains one. - `Status: partial` — `GP/PackagedPythonHomeTest.kt` (what is copied, which tasks, the switch); + `Status: partial`, `GP/PackagedPythonHomeTest.kt` (what is copied, which tasks, the switch); `PM/desktopTest/.../env/PackagedPythonHomeTest.kt` (resolution order, `wchar_t` encoding, library lookup) and `PackagedPythonHomeLaunchTest.kt` (a child JVM with `PYTHONHOME` removed imports `json` from the prefix a property names, or from a Compose resources directory). The - `createDistributable` run itself is a manual check — + `createDistributable` run itself is a manual check, `docs/platforms/desktop-packaged-app.md`. - **L-9a** (issue #74) Desktop packaging and loading of `libpython`: every `lib/**` entry of the desktop jar is a real file (never 0 bytes -- Gradle extracts the python-build-standalone symlink `libpython3.14.so` as an empty file, so @@ -110,9 +110,9 @@ fail, then implement. native memory is reached through `java.lang.foreign`, never `sun.misc.Unsafe` (module `jdk.unsupported`): the proxy heap type (`ProxyTypeFactory.createProxyType`, `_pm_proxy_base`) is built, and its handle slot read and cleared on deallocation, on a runtime limited to `java.base`. - `Status: implemented` — `PM/desktopTest/.../ffi/JlinkedRuntimeProxyTypeTest.kt` (a child JVM with + `Status: implemented`, `PM/desktopTest/.../ffi/JlinkedRuntimeProxyTypeTest.kt` (a child JVM with `--limit-modules java.base` installs the proxy base, and a dropped subclass instance releases its - handle). The packaged sample's own log is a manual check — + handle). The packaged sample's own log is a manual check, `docs/platforms/desktop-packaged-app.md`. - **L-11** iOS: an installed app starts with no `PYTHONHOME` in its environment, and `Python.xcframework` carries no standard library (issue #59, ROADMAP §13.1). The library build @@ -131,72 +131,72 @@ fail, then implement. `/python-multiplatform-home` when that directory exists. A bundled prefix goes through L-3's check (a catchable `IllegalStateException` naming the path and where it came from) and is handed to CPython with `Py_SetPythonHome`. With neither, nothing is set - and behaviour is as before. `Status: partial` — `GP/IosPythonHomeLayoutTest.kt` (slice choice, + and behaviour is as before. `Status: partial`, `GP/IosPythonHomeLayoutTest.kt` (slice choice, source directories, file selection, a staged fake tree); `PM/iosSimulatorArm64Test/.../env/IosPythonHomeTest.kt` (resolution order; the simulator test task's `PYTHONHOME` still wins). The installed-app run on the - simulator is a manual check — `docs/platforms/ios-app-bundle.md`. + simulator is a manual check, `docs/platforms/ios-app-bundle.md`. ## 2. Low-level C API (downcall surface) - **C-1** The CPython Stable ABI is declared once as `expect` functions in `EmbedAPI.kt` (≈330) with an - `actual` on every platform; no CPython-deprecated symbol is declared. `Status: implemented` — + `actual` on every platform; no CPython-deprecated symbol is declared. `Status: implemented`, `PM/desktopTest/.../ffi/EmbedApiSurfaceTest.kt` (source scan), `PM/commonTest/.../ffi/EmbedApiLowLevelTest.kt` (round trips, borrowed vs. new references, error indicator). - **C-2** Desktop calls go through `MethodHandle.invokeExact`, pointers as `JAVA_LONG`; every `FunctionDescriptor` linked is declared in the native-image reachability metadata. - `Status: implemented` — `PM/desktopTest/.../ffi/ReachabilityMetadataTest.kt`. + `Status: implemented`, `PM/desktopTest/.../ffi/ReachabilityMetadataTest.kt`. - **C-3** Android binds through `RegisterNatives` with the calling convention chosen per function; - re-entrant functions are never `@CriticalNative`. `Status: implemented` — + re-entrant functions are never `@CriticalNative`. `Status: implemented`, `PM/desktopTest/.../ffi/JniCallConventionClassificationTest.kt`, `PM/androidInstrumentedTest/.../JniWiringTest.kt`. -- **C-4** Every C API call holds the GIL. `Status: partial` — enforced by convention and README rules; +- **C-4** Every C API call holds the GIL. `Status: partial`, enforced by convention and README rules; the only direct test (`PM/commonTest/.../GilParkingTest.kt`) asserts only "does not crash". Releasing the GIL after initialisation is not enabled. - **C-5** A thread CPython creates can upcall into ART: it is attached once per thread and detached when - it dies. `Status: implemented` on Android — `PM/androidInstrumentedTest/.../UpcallThreadAttachTest.kt`. + it dies. `Status: implemented` on Android, `PM/androidInstrumentedTest/.../UpcallThreadAttachTest.kt`. - **C-6** wasm: `Py_ssize_t` boundaries and the `-1` error return are handled at the 32-bit ABI. - `Status: implemented` — `PM/wasmJsTest/.../WasmPySsizeTBoundaryTest.kt`. + `Status: implemented`, `PM/wasmJsTest/.../WasmPySsizeTBoundaryTest.kt`. ## 3. Object model (Kotlin uses Python) All in `PM/commonTest`, so they run wherever the interpreter loads. - **O-1** `PyObject`: `getAttr`, `setAttr`, `delAttr`, `getAttrOrNull`, call (`invoke`), `equals` / - `hashCode` / `toString` delegate to Python. `Status: implemented` — `PyObjectTest.kt`. + `hashCode` / `toString` delegate to Python. `Status: implemented`, `PyObjectTest.kt`. - **O-2** `PyType`: name, bases, MRO, `isSubtypeOf`, `isInstance`, construct by call, `cast` refuses an - incompatible object. `Status: implemented` — `PyTypeTest.kt`. + incompatible object. `Status: implemented`, `PyTypeTest.kt`. - **O-3** `PyException` is a Kotlin `Throwable` carrying the Python exception type and message. - `Status: implemented` — `PyExceptionTest.kt`. + `Status: implemented`, `PyExceptionTest.kt`. - **O-4** Basic types `PyInt`, `PyFloat`, `PyBool`, `PyString`, `PyNone`, bytes round-trip with Kotlin - values. `Status: implemented` — `PyBasicTypesTest.kt`, `PyValueBytesConversionTest.kt`. + values. `Status: implemented`, `PyBasicTypesTest.kt`, `PyValueBytesConversionTest.kt`. - **O-5** Collections implement the Kotlin collection interfaces: `PyList` (`MutableList`, `fromList`, views), `PyDict` (`MutableMap`), `PySet` (`MutableSet`, set algebra, frozenset), `PyTuple` (`List`), - plus iterators. `Status: implemented` — `PyListTest.kt`, `PyDictTest.kt`, `PySetTest.kt`, + plus iterators. `Status: implemented`, `PyListTest.kt`, `PyDictTest.kt`, `PySetTest.kt`, `PyTupleTest.kt`, `PyIteratorTest.kt`. - **O-6** Modules, builtins, callables (bound methods, class/static methods), utilities (range, slice, - ellipsis), import and GC helpers. `Status: implemented` — `PyModuleTest.kt`, `BuiltinsTest.kt`, + ellipsis), import and GC helpers. `Status: implemented`, `PyModuleTest.kt`, `BuiltinsTest.kt`, `PyCallablesTest.kt`, `PyUtilitiesTest.kt`, `PyImportTest.kt`, `PyGCTest.kt`. - **O-7** Conversion: `PyValue` with selectable conversion strategies (`withContext`), lazily cached snapshots invalidated on demand; buffer-view types and types with no Kotlin counterpart are refused. - `Status: implemented` — `ConversionTest.kt`, `PyValueLazyConversionTest.kt`. + `Status: implemented`, `ConversionTest.kt`, `PyValueLazyConversionTest.kt`. ## 4. Object lifetime - **M-1** A Kotlin wrapper owns one Python reference and releases it when the wrapper becomes - unreachable — no `close()` needed; explicit release is idempotent and runs exactly once. - `Status: implemented` — `PM/commonTest/.../ref/GCLeakTest.kt`, `RefCountTest.kt`, + unreachable, no `close()` needed; explicit release is idempotent and runs exactly once. + `Status: implemented`, `PM/commonTest/.../ref/GCLeakTest.kt`, `RefCountTest.kt`, `OwnershipLeakTest.kt`, `DoubleReleaseTest.kt`, `EvalCheckpointTest.kt`. - **M-2** On Android below API 33 (no `java.lang.ref.Cleaner`), a `PhantomReference` path does the same. - `Status: implemented` — `PM/jvmTest/.../PhantomCleanerRegistryTest.kt`, + `Status: implemented`, `PM/jvmTest/.../PhantomCleanerRegistryTest.kt`, `PM/androidInstrumentedTest/.../AndroidCleanerPathTest.kt`. - **M-3** Reference cycles that cross the boundary (Python → Kotlin proxy → Python) are collected by Python's cyclic GC, including on threads CPython created and for Python subclasses of proxies. `Status: implemented` on desktop (`PM/desktopTest/.../ref/CycleCollectionTest.kt`, - `ksp-fixtures/app/.../RefHolderCycleCollectionTest.kt`); `partial` elsewhere — tests exist for + `ksp-fixtures/app/.../RefHolderCycleCollectionTest.kt`); `partial` elsewhere, tests exist for native, Android and wasm (`PM/nativeTest/.../CycleCollectionTest.kt`, `PM/androidInstrumentedTest/.../CycleCollectionTest.kt`, `PM/wasmJsTest/.../WasmCycleCollectionTest.kt`) but `docs/roadmap/ROADMAP.md` §7 records remaining per-target gaps. - **M-4** Handles given to Python never alias a reused slot; owned results release when Python drops - them. `Status: implemented` — `PM/commonTest/.../reflection/HandleTableTest.kt`, + them. `Status: implemented`, `PM/commonTest/.../reflection/HandleTableTest.kt`, `ProxyHandleLifetimeTest.kt`, `OwnedResultLifetimeTest.kt`. ## 5. Upcalls (Python uses Kotlin) @@ -204,33 +204,33 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. - **U-1** A KSP processor generates, at build time, a function table for every `public` Kotlin declaration in a module; `@PythonInternal` excludes a class or member. Fragments from several modules aggregate; a name claimed twice is an error. No runtime reflection is used. - `Status: implemented` — `PM/commonTest/.../reflection/UpcallTableTest.kt`, + `Status: implemented`, `PM/commonTest/.../reflection/UpcallTableTest.kt`, `ksp-fixtures/app/src/desktopTest/.../GeneratedTableTest.kt`, `python-multiplatform-ksp/src/test/.../SourceRenderingTest.kt`. - **U-2** One entry point per platform marshals arguments (int, float, bool, str, bytes with NUL, objects), turns a Kotlin exception into a Python exception and a `Unit` return into `None`. - `Status: implemented` — `PM/commonTest/.../UpcallEntryTest.kt`, `UpcallTrampolineTest.kt`, + `Status: implemented`, `PM/commonTest/.../UpcallEntryTest.kt`, `UpcallTrampolineTest.kt`, `PM/nativeTest/.../UpcallRawEntryPointTest.kt`. - **U-3** Generated Python proxies let Python write ordinary Python against Kotlin: construct a class, call methods, get/set properties (a `private set` is read-only), read companion / static members. - A Kotlin package is importable under its own name. `Status: implemented` on desktop — + A Kotlin package is importable under its own name. `Status: implemented` on desktop, `PM/commonTest/.../upcall/PythonProxyInstallTest.kt`, `ksp-fixtures/app/.../GeneratedDeclarationKindsTest.kt`, - `GeneratedStaticPropertyProxyTest.kt`; `partial` elsewhere — only `ksp-fixtures/app/.../NativeSmokeTest.kt` + `GeneratedStaticPropertyProxyTest.kt`; `partial` elsewhere, only `ksp-fixtures/app/.../NativeSmokeTest.kt` (androidNative) and `ksp-fixtures/android/.../GeneratedAndroidTableTest.kt` run outside desktop. - **U-4** Declaration kinds: objects, companions, interfaces (not constructible), enum entries as statics, abstract classes (no constructor), nested classes. Not exposed: annotation classes, - generic declarations, data-class synthetics. `Status: implemented` on desktop — + generic declarations, data-class synthetics. `Status: implemented` on desktop, `GeneratedDeclarationKindsTest.kt`. - **U-5** `suspend` functions are awaitable from Python (`await g.greetNow(1)`); a function that never suspends completes without a Future; cancelling the Python future cancels the Kotlin coroutine. - `Status: implemented` on desktop and Kotlin/Native — `PM/desktopTest/.../upcall/AsyncUpcallDeliveryTest.kt`, + `Status: implemented` on desktop and Kotlin/Native, `PM/desktopTest/.../upcall/AsyncUpcallDeliveryTest.kt`, `AsyncUpcallCancellationTest.kt`, `AsyncUpcallEarlyCancellationTest.kt`, `PM/nativeTest/.../AsyncUpcallNative*Test.kt`, `ksp-fixtures/app/.../GeneratedSuspendTest.kt`; `planned` on wasm (no threads). - **U-6** Upcalls work in a GraalVM native image (desktop). `Status: implemented` with manual - verification — procedure and record in `docs/platforms/graal-native-image-verification.md` + verification, procedure and record in `docs/platforms/graal-native-image-verification.md` (`:sample:nativeCompile`, Liberica NIK), guarded automatically by `ReachabilityMetadataTest.kt` (C-2). Not automated as a test of the image itself. - **U-7** A Kotlin extension function is a method on its receiver's proxy, so chains compose - (`Modifier.padding(16).size(24)`). `Status: partial` — asserted against a hand-written, + (`Modifier.padding(16).size(24)`). `Status: partial`, asserted against a hand-written, Compose-shaped table (`PM/commonTest/.../pythonx/PythonxAdapterTest.kt` `anExtensionIsAMethodOnItsReceiverAndTheChainComposes`) and against the real Compose jars through the artifact walker (`ksp-fixtures/artifact/.../WalkedArtifactComposeModifierTest.kt`); not @@ -247,28 +247,28 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. on disk is what `import pythonx` loads. The answer is the same whichever installer (`PythonProxySource`, `PythonxAdapter`) ran first for a table. **A name that is both a function and a module is callable** (#78): Kotlin has `TextRange(2)` (a top-level factory) and `TextRange.Zero` (a - companion constant), so the module `androidx.compose.ui.text.TextRange` is itself callable — calling it - runs the function or overload set of that name in its parent package — while its attributes stay the + companion constant), so the module `androidx.compose.ui.text.TextRange` is itself callable, calling it + runs the function or overload set of that name in its parent package, while its attributes stay the companion's constants and functions. One rule with the class case (#73): a Kotlin name that is a constructor or factory is what calling it does, whatever else lives under that name. `Status: - implemented` on desktop — + implemented` on desktop, `PM/desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, `BinderNamespaceTest.kt`, `ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt`. - **U-9** A Pythonic package can serve extra member names on a Kotlin proxy through one hook, `python_multiplatform.binding.add_member_resolver(fn)`, `fn(kotlin_type_name, requested_name, kotlin_member_names) -> kotlin_name | (kotlin_name, keyword_map) | None`, asked when no Kotlin member - of that name exists — and, for a Kotlin-named member, only when a call passes keywords and only for + of that name exists, and, for a Kotlin-named member, only when a call passes keywords and only for its `{python_kw: kotlinParam}` keyword map (#34). Resolvers are asked before the binder's own U-12 alias, so a resolver still decides a name it answers; with none answering, the U-12 alias is served, and a name that is no alias is an `AttributeError`. Aliases are cached in the registry, not written on the proxy class (`dir()` stays Kotlin-only). Contract in `KotlinSurface.kt`'s - KDoc. `Status: implemented` on desktop — `PM/desktopTest/.../pythonx/MemberResolverTest.kt`, + KDoc. `Status: implemented` on desktop, `PM/desktopTest/.../pythonx/MemberResolverTest.kt`, `ksp-fixtures/compose/.../MemberResolverComposeTest.kt`. A resolver (or anything else) reads a member's declaration rows from the receiver type alone with `python_multiplatform.describe_member(kotlin_type_name, kotlin_member_name)`: `describe()`'s tuple-of-dicts for every extension overload or the property getter/setter that type's proxy serves under that name, supertypes included, invoking nothing; `AttributeError` for a name it does not - serve (#54). `Status: implemented` — `PM/commonTest/.../pythonx/PythonxPropertyTest.kt`. + serve (#54). `Status: implemented`, `PM/commonTest/.../pythonx/PythonxPropertyTest.kt`. - **U-10** A Kotlin property is an attribute of its receiver's proxy (#38): a member `val`/`var` of a public class or interface reads (and, for a public setter, writes) as a Python `property`, an extension property's getter reads the same way on its receiver (`Icons.Default.Add`), and both are @@ -276,21 +276,21 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. attribute and makes no package. `None` written for a slot with no default is Kotlin's `null` for a reference type; a Kotlin proxy written into a `kotlin.Any?` slot is the Kotlin object, and any other Python object is held by Kotlin as itself. A Python **scalar** written into a `kotlin.Any`/`kotlin.Any?` - slot — a function argument or a property write — is boxed into the Kotlin type it means (#69): `bool` + slot, a function argument or a property write, is boxed into the Kotlin type it means (#69): `bool` → `kotlin.Boolean` (checked before `int`); `int` → `kotlin.Int` when it fits in 32 bits, else `kotlin.Long`, and outside 64 bits refused with a reason; `float` → `kotlin.Double`; `str` → `kotlin.String`. `None` is `null` for `Any?`; for a non-null `Any` it is Kotlin's own refusal (the - table does not carry nullability). Reading a `kotlin.Any`/`kotlin.Any?` value back — a property, a - function result, or a callback argument — gives the Python scalar for a Kotlin `Int`/`Long`/`Short`/`Byte` + table does not carry nullability). Reading a `kotlin.Any`/`kotlin.Any?` value back, a property, a + function result, or a callback argument, gives the Python scalar for a Kotlin `Int`/`Long`/`Short`/`Byte` (`int`), `Double`/`Float` (`float`), `Boolean` (`bool`), `String`/`Char` (`str`), and a proxy for any - other Kotlin object. `Status: implemented` — + other Kotlin object. `Status: implemented`, `PM/commonTest/.../pythonx/PythonxPropertyTest.kt`, `ksp-fixtures/compose/.../PythonContentRenderTest.kt`, `AnySlotScalarRenderTest.kt`, `MaterialIconsRenderTest.kt`. - **U-11** A KSP-bound function, method or property whose declared return type is a Kotlin class that has a generated proxy returns an instance of **that class's proxy** (#94), not a generic owner: its methods and properties work and `isinstance(result, TheClass)` holds. The class is found by the declared return type's Kotlin name; a return type with no generated proxy stays a generic owner object. The - result's ownership is the same as for any owned result. `Status: implemented` — + result's ownership is the same as for any owned result. `Status: implemented`, `ksp-fixtures/app/.../KspClassResultProxyTest.kt` (red until #94 lands). - **U-12** Pythonic names (#131). On a Kotlin-named module every declaration, proxy member and keyword parameter is reachable by its Kotlin name **and** by its Pythonic name; the Kotlin name keeps @@ -340,8 +340,8 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. - **B-1** The artifact walker binds declarations from prebuilt **jars** (Kotlin metadata read with ASM, overloads suffixed, multi-file facades reachable) by generating Kotlin source that `kotlinc` - compiles — never by JVM name lookup. KSP and the walker share one Python namespace. - `Status: implemented` on desktop — `GP/artifact/ArtifactScannerTest.kt`, + compiles, never by JVM name lookup. KSP and the walker share one Python namespace. + `Status: implemented` on desktop, `GP/artifact/ArtifactScannerTest.kt`, `ksp-fixtures/artifact/.../WalkedArtifactTableTest.kt`, `WalkedArtifactPythonImportTest.kt`. Beside functions and object constants it binds (#38): generic functions whose type parameters are unbounded and not reified, each read as `kotlin.Any?` and written out at the call @@ -350,35 +350,35 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. for a public setter with every class type parameter unbounded, `SETTER` entries `Owner.prop=`; and top-level extension property getters (`pkg.prop`, receiver in `receiverTypeName`). Properties take no part in overload naming or in a constructor's name check; a property key another binding already - holds is declined, and so is a property or an extension function whose receiver has a supertype missing from the consumer's compile classpath (kotlinc cannot build its member scope; the reason names the missing supertype, #66). `Status: implemented` — `GP/artifact/PropertyBindingTest.kt` (properties and, #66, extension functions). + holds is declined, and so is a property or an extension function whose receiver has a supertype missing from the consumer's compile classpath (kotlinc cannot build its member scope; the reason names the missing supertype, #66). `Status: implemented`, `GP/artifact/PropertyBindingTest.kt` (properties and, #66, extension functions). The public instance functions of a Kotlin-public `object` bind under the object's name like a static (`Arrangement.spacedBy`, #53), except `Any`'s members, ones that also exist as a `@JvmStatic` static, and `@Composable` ones (declined); an `internal` or file-private object is - skipped. `Status: implemented` — `GP/artifact/ObjectMemberBindingTest.kt`. + skipped. `Status: implemented`, `GP/artifact/ObjectMemberBindingTest.kt`. A public constructor of a public, non-abstract, non-generic class binds under the class's own name (`pkg.Class`), including one with a value-class parameter, which `kotlinc` compiles to a private `` behind a public synthetic bridge ending in `DefaultConstructorMarker` (the descriptor `@Metadata` records); a `@Deprecated(level = HIDDEN)` constructor, whose bridge carries that - annotation, is skipped (#73). A declared `kotlin.CharSequence` **result** — of a function, a property - getter or an object constant — crosses as a Python `str` (the generated body returns Kotlin's + annotation, is skipped (#73). A declared `kotlin.CharSequence` **result**, of a function, a property + getter or an object constant, crosses as a Python `str` (the generated body returns Kotlin's `toString()` of it; `null` stays `None` for `CharSequence?`); a `CharSequence` parameter is still - declined (#73). `Status: implemented` — `GP/artifact/TextStateBindingTest.kt`, + declined (#73). `Status: implemented`, `GP/artifact/TextStateBindingTest.kt`, `ksp-fixtures/compose/.../TextFieldStateRenderTest.kt`. -- **B-2** The walker on **klibs** (Kotlin/Native libraries). `Status: partial` — +- **B-2** The walker on **klibs** (Kotlin/Native libraries). `Status: partial`, `GP/artifact/KlibScannerTest.kt` and `ksp-fixtures/klib-artifact` assert that the scanned klib's declarations are declined with reasons; no klib declaration is bound at run time yet. - **B-3** Default arguments can be omitted from Python (a call per subset of defaulted parameters, - ambiguous omissions refused). `Status: implemented` — `GP/artifact/DefaultOmissionTest.kt`, + ambiguous omissions refused). `Status: implemented`, `GP/artifact/DefaultOmissionTest.kt`, `ksp-fixtures/artifact/.../WalkedArtifactDefaultOmissionTest.kt`, `PM/commonTest/.../PythonxDefaultsTest.kt`. - **B-4** Value classes round-trip; only allow-listed ones (e.g. `Dp`) may be written as their raw - primitive. `Status: implemented` — `GP/artifact/ComposableValueClassSlotTest.kt`, + primitive. `Status: implemented`, `GP/artifact/ComposableValueClassSlotTest.kt`, `WalkedArtifactPythonImportTest.kt`. - **B-5** Python callables can fill Kotlin function-typed parameters (arity and callability checked). - `Status: partial` — `ksp-fixtures/artifact/.../WalkedArtifactCallbackTest.kt`; a value-returning slot + `Status: partial`, `ksp-fixtures/artifact/.../WalkedArtifactCallbackTest.kt`; a value-returning slot (`() -> Float`) does not yet accept a Python callable (`M3ProofRenderTest.kt` `aValueReturningFunctionSlotDoesNotYetAcceptAPythonCallable`). - **B-6** `@Composable` functions are callable from Python inside a composition; a Python click reaches - a Kotlin callback and the redraw shows it. `Status: implemented` on desktop — + a Kotlin callback and the redraw shows it. `Status: implemented` on desktop, `ksp-fixtures/compose/src/desktopTest/` (`ComposableRenderTest.kt`, `M3ProofRenderTest.kt`, `CallbackDrivenRenderTest.kt`, pointer/drag render tests), `GP/artifact/ComposableBindingTest.kt`; `planned` on Android, iOS and wasm. @@ -389,7 +389,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. callable. The state is read inside the composition, so a Python write into it replaces the root on the next frame with no host call; Python callables a root passed into composables are released when that root is replaced or the composition is disposed. The entry point names no library. - `Status: implemented` on desktop — `ksp-fixtures/compose/.../PythonContentRenderTest.kt`; the module + `Status: implemented` on desktop, `ksp-fixtures/compose/.../PythonContentRenderTest.kt`; the module compiles for Android, nothing runs there yet. - **B-7** The plugin generates `.pyi` stubs for the Kotlin-named modules only, under Kotlin names plus their U-12 Pythonic aliases (`fill_max_width = fillMaxWidth` beside each module `def`, a second @@ -407,10 +407,10 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`, `.../StubSignatureAgreesWithRuntimeTest.kt`, `GP/stubs/CompanionFactoryStubTest.kt` (#78: a name that is both a function and a Kotlin-named module is stubbed in its parent package as one attribute whose type has `__call__` (the function or - its `@overload`s) and the module's constants and functions as members — never as a bare `def`, which + its `@overload`s) and the module's constants and functions as members, never as a bare `def`, which would hide `TextRange.Zero` from a checker), `tools/stubs/check-stubs.sh` (mypy over the Compose stubs). A class whose name is also a function in - its module is `Any` — except that a class's own bound constructor is that class's `__init__` + its module is `Any`, except that a class's own bound constructor is that class's `__init__` (`@overload`ed for several), so the class keeps its stub, members and properties; a single one's docstring carries its table key after `Kotlin constructor: `; its `__`-suffixed table-key spellings stay module functions; a value class bound as its primitive keeps its constructor as a module function @@ -430,12 +430,12 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. - **B-8** CI generates the stubs over the Compose version the build resolves and publishes them (`.github/workflows/stubs.yml`): workflow artifact `kotlin-stubs` on every push to `develop`, with a README naming the Compose version and the commit, and `kotlin-stubs.zip` on every `v*` tag's release. - `Status: partial` — the workflow could not be run where it was written; its YAML parses and its + `Status: partial`, the workflow could not be run where it was written; its YAML parses and its assemble step was executed locally. - **B-9** When a configuration named `typedpythonStubs` exists (toolchain's plugin creates it), every stubs task's output directory is added to it, with the task dependency travelling with the files, in either plugin application order. Without that configuration nothing happens and none is created. - `Status: implemented` — `GP/TypedPythonStubsWiringTest.kt` (a `ProjectBuilder` project; the toolchain + `Status: implemented`, `GP/TypedPythonStubsWiringTest.kt` (a `ProjectBuilder` project; the toolchain side that consumes the configuration is toolchain#23 and is not exercised here). ## 7. Threading and builds @@ -444,24 +444,24 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. (see C-4). - **T-2** Free-threaded builds selected with `-PpythonFreeThreaded=true` download a different CPython asset. `Status: partial` (desktop only, opt-in; default is `pythonFreeThreaded=false` in `gradle.properties`) - — `GP/PythonHomeStagingTest.kt`, `PM/desktopTest/.../FreeThreadedGCGateTest.kt`, `VersionsTest.kt`. + , `GP/PythonHomeStagingTest.kt`, `PM/desktopTest/.../FreeThreadedGCGateTest.kt`, `VersionsTest.kt`. 3.14t runs the whole desktop suite with `-PpythonFreeThreaded=true` (236 tests, 0 failures; ROADMAP §9). Only desktop has free-threaded prebuilts; Android and iOS do not. `Py_LIMITED_API` is not defined, so `abi3t` is not a blocker. - **T-3** `python-multiplatform` publishes per-target, per-flavour CPython include directories as `Provider` carrying the extraction task (`cpythonIncludeDirectories` extension; see `docs/platforms/python-version-acquisition.md` §7). `Status: partial` (only the configured flavour is - extracted) — `GP/CPythonIncludeDirectoriesTest.kt`. + extracted), `GP/CPythonIncludeDirectoriesTest.kt`. - **T-4** `python-multiplatform` publishes the CPython version it embeds: extension `pythonMultiplatform`, Gradle attributes `org.thisisthepy.python.version` / `org.thisisthepy.python.free-threaded` on every consumable `*Elements` configuration, and the resource `META-INF/python-multiplatform/python.properties` - in the jar and AAR (see `docs/platforms/python-version-acquisition.md` "Published version") — + in the jar and AAR (see `docs/platforms/python-version-acquisition.md` "Published version"), `GP/EmbeddedPythonVersionTest.kt`. - **T-5** Every acquired CPython reports the pinned version: `PY_VERSION` in each target's `patchlevel.h` equals `pythonVersion`, and a `downloadPython_*` task fails the build, naming the target and both versions, when it does not (issue #47: iOS shipped 3.14.6 under `pythonVersion=3.14.7`). The iOS extraction is stamped with its lock key so bumping the pinned archive discards the old tree. - `Status: implemented` — `GP/AcquiredHeaderVersionTest.kt` (the real-tree case needs the trees from + `Status: implemented`, `GP/AcquiredHeaderVersionTest.kt` (the real-tree case needs the trees from `downloadAllPythonBuilds`; it skips when nothing was acquired). ## 8. Measurement @@ -495,13 +495,13 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. display whose inferred element type is a union) as warnings in `checked` mode and errors in `compiled` mode. Nothing inside dependencies is reported. `python -m typedpython check` exits 0 / 1 / 2 for clean / errors / - tool failure. `Status: partial` — the gate is asserted by + tool failure. `Status: partial`, the gate is asserted by `python-multiplatform-ksp/src/test/python/` (run with that module's `.venv`); it is **not yet run by the build** (design §6 step 1b). Rebinding and mixed containers are found by a second Pyrefly pass over a probed copy (design §6.1), which runs only when a file has a name assigned twice or a container display; `for` targets and comprehension variables are not probed. - **N-8** TypedPython compilation to C (INTENT §1.4; design §4.3; #41). `@compiled` (a builtin name, no - import — #42) and a first-line `# typedpython: compiled` mark functions; the compiler lowers them + import, #42) and a first-line `# typedpython: compiled` mark functions; the compiler lowers them through the typed IR (`typedpython/ir.py`) to C against the CPython C API and builds an extension with the platform C compiler. Compiled functions return what CPython returns (IR contract: checked i64 with deopt to the interpreted function, Python floor division/modulo, exact int/float comparison, @@ -538,7 +538,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. field access only when its layout is fixed **in CPython too**: it declares `__slots__` listing exactly its annotated instance fields, has no base but `object`, no metaclass, no decorator but `@compiled`, and (gate, compiled mode) nothing in the project subclasses it or assigns its class attributes. The - compiler never adds `__slots__` itself — that would change what CPython does (`__dict__`, `vars()`, + compiler never adds `__slots__` itself, that would change what CPython does (`__dict__`, `vars()`, adding attributes). What is compiled, keeping CPython's results: - field reads/writes on a value guarded `type(x) is C` go straight to the slot (the member descriptor's offset, captured and checked at module init); an unset slot raises CPython's AttributeError; values @@ -565,7 +565,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. --- -## Outside intent — needs a decision +## Outside intent, needs a decision Findings where current behaviour is not covered by, or appears to conflict with, `INTENT.md`. Unless marked resolved, nothing here was changed; each needs the maintainer's call. 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..cc85a8448 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` 분기가 없으므로 네 플랫폼 동작이 구조적으로 동일하다. ## 타입 래퍼 계층 @@ -101,7 +101,7 @@ Kotlin 제약 때문에 다이어그램대로 갈 수 없던 지점들: 명령 목록과 어떤 소스셋을 덮는지는 AGENTS.md §15 가 기준이다 (`compileKotlinAndroidNativeArm64`, `compileKotlinIosSimulatorArm64`, `compileKotlinDesktop`, 그리고 `ksp-fixtures` 세 모듈을 각각 따로). -테스트는 컴파일 검증만이 아니다 — 데스크톱(`desktopTest`), iOS 시뮬레이터, 안드로이드 기기, wasm(Node) +테스트는 컴파일 검증만이 아니다, 데스크톱(`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 d22d73f82..a3aacfad9 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,7 +143,7 @@ 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 its Pythonic alias `on_click` on the Kotlin-named module, and renames no namespace @@ -173,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 +> **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 > `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 @@ -193,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 @@ -224,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**: @@ -237,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 `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.* - 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 `tools/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 @@ -257,19 +257,19 @@ 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 @@ -278,12 +278,12 @@ special-cased branch `if (configuredPythonVersion == "3.13.0" && !pythonFreeThre `desktopJar` task (`build.gradle.kts:1049`), i.e. 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 @@ -292,7 +292,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 @@ -322,7 +322,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. @@ -334,26 +334,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 @@ -368,7 +368,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 @@ -377,7 +377,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. @@ -416,7 +416,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` @@ -425,7 +425,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 @@ -436,10 +436,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` @@ -476,7 +476,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 @@ -485,21 +485,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" @@ -510,14 +510,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`, @@ -526,7 +526,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 == @@ -538,12 +538,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. --- @@ -575,7 +575,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. @@ -591,13 +591,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**: @@ -605,7 +605,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. @@ -620,8 +620,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 @@ -629,10 +629,10 @@ 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 `FunctionTableFragment`, `UpcallTable` and `PythonProxySource` to @@ -640,7 +640,7 @@ to at runtime with no JVM underneath — is the open question, not AndroidX. 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. Install site: `UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)`. @@ -649,7 +649,7 @@ things in it change what this section says: (`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 @@ -660,17 +660,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 @@ -684,19 +684,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 +*(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. @@ -705,8 +705,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, @@ -716,19 +716,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: @@ -740,64 +740,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 @@ -806,7 +806,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 @@ -814,7 +814,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 @@ -836,13 +836,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. diff --git a/docs/design/kotlin-extensions-in-python.md b/docs/design/kotlin-extensions-in-python.md index 51db072a2..6e78e06f6 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 @@ -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`. @@ -482,7 +482,7 @@ a real loss of fidelity and it is confined to 20 functions. (This file first wro `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 @@ -603,7 +603,7 @@ positional-only first parameter, the declared Kotlin types as stub classes (`Mod sets over a base name in table-key order, with the Kotlin signature in the docstring (issue #31). What 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 +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 @@ -620,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 @@ -633,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 @@ -644,7 +644,7 @@ 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 @@ -664,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. --- @@ -686,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 @@ -696,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. @@ -722,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 12c30efff..0819abec4 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 @@ -49,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 | @@ -61,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. @@ -81,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 @@ -102,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: @@ -115,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. @@ -124,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. @@ -135,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. @@ -148,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 { ... @@ -177,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 @@ -197,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 @@ -216,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 @@ -245,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 @@ -272,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: @@ -285,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:** @@ -294,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 | @@ -311,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 @@ -367,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. @@ -426,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 @@ -478,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. @@ -516,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.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: @@ -556,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. @@ -581,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 @@ -592,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. --- @@ -608,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. @@ -618,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 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..4cabf90eb 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). --- @@ -59,8 +59,8 @@ TypedPython 의 목표는 **새 언어를 만들지 않고, 파이썬 문법 그 | 등급 | 대상 / 표기 (안) | 보장 | 출력 | |---|---|---|---| -| 0 | 서드파티 패키지 (의존성) | 없음 — 검사하지 않는다 | 바이트코드 (pypackpack 이 받은 그대로) | -| 1 `checked` | **사용자 프로젝트의 파이썬 코드 전부 — 기본값** | 빌드 타임 타입 오류, `Any` 유출 0 (§4.2) | 바이트코드 | +| 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 | @@ -73,13 +73,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 +97,7 @@ TypedPython 의 목표는 **새 언어를 만들지 않고, 파이썬 문법 그 ## 4. 등급 1 의 구현 -### 4.1 프론트엔드 — Pyrefly +### 4.1 프론트엔드, Pyrefly Meta 의 Pyrefly 를 타입 추론기로 쓴다. @@ -108,7 +108,7 @@ Meta 의 Pyrefly 를 타입 추론기로 쓴다. **포크하지 않는다.** 처음에는 CLI 와 기계 판독 가능한 출력 수준에서 붙이고, 라이브러리 임베드는 API 안정성을 확인한 뒤로 미룬다. -### 4.2 건전성 게이트 — TypedPython 고유의 일 +### 4.2 건전성 게이트, TypedPython 고유의 일 타입 체커와 컴파일러는 목표가 반대다. @@ -125,7 +125,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 +136,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 +148,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,14 +163,14 @@ 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__` 은 객체 호출로 남는다. @@ -181,11 +181,11 @@ Gradle 태스크는 입력·출력을 정확히 선언해 up-to-date 판정과 검토하고 기각한 경로: -- **Cython** — 사용자 결정으로 폐기(위). -- **mypyc** — 타입 체커가 mypy 에 묶여 있어 Pyrefly 와 판정이 갈라진다. -- **Nuitka** — 동작 동일성을 우선해 타입 특수화를 하지 않는다. -- **LPython** — 업스트림 활동이 LFortran 쪽으로 옮겨간 것으로 보인다. -- **Rust 생성** — 위. +- **Cython**: 사용자 결정으로 폐기(위). +- **mypyc**: 타입 체커가 mypy 에 묶여 있어 Pyrefly 와 판정이 갈라진다. +- **Nuitka**: 동작 동일성을 우선해 타입 특수화를 하지 않는다. +- **LPython**: 업스트림 활동이 LFortran 쪽으로 옮겨간 것으로 보인다. +- **Rust 생성**: 위. ### 4.3.3 재귀 (SPEC N-8, #57) @@ -197,8 +197,8 @@ 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`). @@ -218,7 +218,7 @@ Gradle 태스크는 입력·출력을 정확히 선언해 up-to-date 판정과 **선행 조건:** 스텁 생성기가 픽스처 대역이 아닌 실제 `pythonx-map.toml` 을 읽고, 생성된 스텁이 IDE·타입 체커가 찾는 자리(pythonx-compose 배포본)에 설치돼야 한다. 2026-10 현재 미완료. -## 5. 등급 2 — 별도 언어 없는 GPU +## 5. 등급 2, 별도 언어 없는 GPU ### 5.1 하드 제약: AOT @@ -245,7 +245,7 @@ Triton · NVIDIA Warp 처럼 파이썬 함수에 `@kernel` 을 붙인다. 한 소스에서 모바일·데스크톱·웹 타깃이 나온다. -### 5.4 착지점 — torchnative +### 5.4 착지점, torchnative 컴파일된 커널을 **`torch.library` 커스텀 op 로 등록**하고 mps / vulkan 구현을 붙인다. 그러면 `transformers` 코드가 수정 없이 그 커널을 쓴다. @@ -254,7 +254,7 @@ torchnative 의 설계는 이미 이 자리를 비워두고 있다. 융합 어 예정된 해법" 으로 지목했고, 커널 소스 후보로 손으로 쓴 Metal/NEON, CubeCL, AOT Triton 을 올려 두었다. TypedPython 등급 2 는 **같은 자리를 파이썬으로 채우는 네 번째 후보**다. -### 5.5 선례 — Taichi +### 5.5 선례, Taichi 파이썬 문법으로 Vulkan / Metal 커널을 AOT 컴파일해 모바일에 실었던 가장 가까운 선례다. 지금은 활동이 크게 줄었다. 기술보다는 **독자 런타임과 독자 생태계로 고립된 것**이 원인으로 보인다. @@ -279,7 +279,7 @@ upstream 대비 수치 오차를 기준선으로 박아 둔다. - **위치:** `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 +288,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 가 아니다. @@ -316,19 +316,19 @@ upstream 대비 수치 오차를 기준선으로 박아 둔다. ## 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 폐기 후 갱신). +2. ~~고정폭 정수의 오버플로 의미~~, **결정됨:** `int` 는 i64 로 컴파일하고 오버플로 시 인터프리터 재실행으로 큰 정수로 승격(의미 보존). +3. ~~pypackpack Cython 슬롯 인터페이스 소유~~, **결정됨:** pypackpack 이 정한다. TypedPython 은 생성한 C 소스, 빌드 플래그, C 를 낸 모듈 목록을 넘긴다. 확장이 원본 소스를 품으므로 별도 `.py` 폴백은 없다(Cython 폐기 후 갱신). 4. GPU 착지를 torchnative 커스텀 op 로 할 것인가 (다른 레포 변경). -5. ~~TypedPython 을 별도 레포로 분리할 것인가~~ — **결정됨 (2026-10-02):** PythonMultiplatform 의 +5. ~~TypedPython 을 별도 레포로 분리할 것인가~~, **결정됨 (2026-10-02):** PythonMultiplatform 의 기본 동작. 검사는 기본, 컴파일은 opt-in. -6. 기본 검사에서 빠져나가는 방법을 둘 것인가 — 예: 동적 기능이 꼭 필요한 모듈 하나를 `checked` 에서 +6. 기본 검사에서 빠져나가는 방법을 둘 것인가, 예: 동적 기능이 꼭 필요한 모듈 하나를 `checked` 에서 빼는 표기. 두지 않으면 보장이 강해지고, 두면 이식이 쉬워진다. 7. §3.1 금지 목록을 `checked` 에서 경고로 둘 것인가, 에러로 둘 것인가. diff --git a/docs/design/upcall.md b/docs/design/upcall.md index 5471a604a..92600a5cf 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`, @@ -124,7 +124,7 @@ misreading of §12.1 and restored; §12.1 now says it governs namespaces only. S ### 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`: @@ -192,9 +192,9 @@ object Fragment_io_github_thisisthepy_ksp_fixtures_library : python.multiplatfor 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 +`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 @@ -225,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`. @@ -237,8 +237,8 @@ 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: @@ -312,7 +312,7 @@ derives `moduleName`. Notes from building it: compilation, which emitted a duplicate fragment shadowing `main`'s. `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 @@ -321,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 | @@ -344,11 +344,11 @@ 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. @@ -359,12 +359,12 @@ Measured with `ksp.incremental.log=true` on `:ksp-fixtures:app` (two files), mod | `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 +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` 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 @@ -412,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 | | @@ -425,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 @@ -437,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 @@ -481,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). @@ -515,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 @@ -528,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` @@ -539,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 @@ -548,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 @@ -559,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 @@ -579,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 | @@ -600,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). --- @@ -624,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,)) @@ -717,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 @@ -744,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 @@ -775,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 @@ -798,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 @@ -815,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 @@ -826,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 { @@ -855,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 @@ -867,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 @@ -879,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 | @@ -890,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 @@ -924,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 @@ -937,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): @@ -971,7 +971,7 @@ 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`, +Status: SPEC M-3, implemented on desktop (`desktopTest/.../ref/CycleCollectionTest.kt`, `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). @@ -1032,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 @@ -1055,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 | |---|---|---| @@ -1068,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 | |---|---|---|---| @@ -1076,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: @@ -1093,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 @@ -1119,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 @@ -1138,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`. @@ -1156,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 @@ -1170,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 @@ -1187,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 | @@ -1239,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:** @@ -1288,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 | @@ -1299,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 d0956b085..de25f7f0c 100644 --- a/docs/guide/concepts.html +++ b/docs/guide/concepts.html @@ -77,7 +77,7 @@

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/guide-kotlin-to-python.html b/docs/guide/guide-kotlin-to-python.html index dafdc81ea..6d63b8295 100644 --- a/docs/guide/guide-kotlin-to-python.html +++ b/docs/guide/guide-kotlin-to-python.html @@ -59,7 +59,7 @@

02Statements and expressions03Collections 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/index.html b/docs/guide/index.html index 430040007..25fa6def0 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.경계 메커니즘마다 오버헤드 테스트가 함께 있고, 비용 표는 모든 타깃의 실제 실행에서 렌더링된다.
  • 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/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/locale/README_ko.md b/docs/locale/README_ko.md index 040791ed6..2627bfaa4 100644 --- a/docs/locale/README_ko.md +++ b/docs/locale/README_ko.md @@ -4,7 +4,7 @@ # 🐍 python-multiplatform -**Kotlin Multiplatform 안의 진짜 CPython — Kotlin 이 Python 을, Python 이 Kotlin 을, 모든 타깃에서 부른다.** +**Kotlin Multiplatform 안의 진짜 CPython, Kotlin 이 Python 을, Python 이 Kotlin 을, 모든 타깃에서 부른다.** [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](../../LICENSE) [![Kotlin Multiplatform](https://img.shields.io/badge/Kotlin-Multiplatform-7F52FF.svg?logo=kotlin&logoColor=white)](https://kotlinlang.org/docs/multiplatform.html) @@ -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) 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/graal-native-image-verification.md b/docs/platforms/graal-native-image-verification.md index 12ccbb28c..9ff676ca7 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,18 @@ 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)**, +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 +151,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 +171,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 +217,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 | **NO**, the path uses none, which is the §7 design premise holding | 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..4aed0c125 100644 --- a/docs/platforms/ios-app-bundle.md +++ b/docs/platforms/ios-app-bundle.md @@ -36,11 +36,11 @@ one prefix holds one `lib-dynload`. Upstream's `utils.sh` has the same limit wit **Xcode** (`tools/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. @@ -101,7 +101,7 @@ export PYTHON_PAYLOAD_DIR="$SRCROOT/../sample/python/src/main" ``` 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 +`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. @@ -175,7 +175,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..ac7ab98b9 100644 --- a/docs/platforms/python-version-acquisition.md +++ b/docs/platforms/python-version-acquisition.md @@ -51,12 +51,12 @@ 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 diff --git a/docs/platforms/wasm-design.md b/docs/platforms/wasm-design.md index 414eb1b67..b9a9c29de 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,8 +16,8 @@ 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)) | +| 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` | | 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 | @@ -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): @@ -91,13 +91,13 @@ 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); diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 1e4a2a30b..e763d62c4 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 @@ -103,38 +103,38 @@ Closed in its core: build-time table, KSP generator, one entry point per platfor `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 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 +- **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. @@ -191,30 +191,30 @@ cycles, interning and `Py_ssize_t` are closed (`PM/wasmJsTest/`: `WasmFinalizati (`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 +- **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 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. @@ -262,7 +262,7 @@ plugin applies to Android modules (AGP 8.10.1); `ksp-fixtures/android` exists; t 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 `/python-multiplatform-home/`, payload to `/python/`, `.so` wrapped as frameworks with @@ -270,14 +270,14 @@ trampolines exist. GraalVM native image upcalls are verified by hand `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,15 +325,15 @@ 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; +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/`), reads Kotlin metadata (`GP/main/.../artifact/KotlinMetadata.kt`), walks klibs through an isolated worker (`KlibScanner.kt`, `KlibScanWorkAction.kt`), and takes several targets (`artifactTargets`, @@ -341,28 +341,28 @@ worker (`KlibScanner.kt`, `KlibScanWorkAction.kt`), and takes several targets (` `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 +- **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 `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 + 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 +- **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.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`) 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` +- **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 interpreter is not vendored. Removing them is a repository-size decision (`ecosystem.md` §5). diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPullRefresh.kt b/ksp-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/ksp-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/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt index 51b6ffb47..5ee546467 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt +++ b/ksp-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 + * 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 @@ -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 @@ -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. */ @@ -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. diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PullRefreshRenderTest.kt b/ksp-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/ksp-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/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..37bc48745 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 @@ -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 @@ -571,7 +571,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. * 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/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..e90cee6d5 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 @@ -42,7 +42,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 | diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/WasmProxyExportsRenderingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/WasmProxyExportsRenderingTest.kt index 30ec8f1a0..899c8cc99 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/WasmProxyExportsRenderingTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/WasmProxyExportsRenderingTest.kt @@ -78,7 +78,7 @@ class WasmProxyExportsRenderingTest { @Test fun traverseHasCorrectArity() { - // tp_traverse: (PyObject *self, visitproc visit, void *arg) -> int — three i32 params + // tp_traverse: (PyObject *self, visitproc visit, void *arg) -> int, three i32 params val source = renderWasmProxyExportsSource("test.pkg") assertTrue( source.contains("selfPtr: Int, visitPtr: Int, argPtr: Int"), @@ -88,7 +88,7 @@ class WasmProxyExportsRenderingTest { @Test fun clearHasCorrectArity() { - // tp_clear: (PyObject *self) -> int — one i32 param + // tp_clear: (PyObject *self) -> int, one i32 param val source = renderWasmProxyExportsSource("test.pkg") val clearFun = source.substringAfter("pmp_tp_clear\"") assertTrue( @@ -99,12 +99,12 @@ class WasmProxyExportsRenderingTest { @Test fun deallocHasVoidReturn() { - // tp_dealloc: (PyObject *self) -> void — no return type annotation (Unit) + // tp_dealloc: (PyObject *self) -> void, no return type annotation (Unit) val source = renderWasmProxyExportsSource("test.pkg") val deallocFun = source.substringAfter("pmp_tp_dealloc\"") assertTrue( deallocFun.contains("selfPtr: Int) ="), - "dealloc must be '(selfPtr: Int) =' — no return type (Unit/void)", + "dealloc must be '(selfPtr: Int) =', no return type (Unit/void)", ) } diff --git a/python-multiplatform-ksp/src/main/python/typedpython/__init__.py b/python-multiplatform-ksp/src/main/python/typedpython/__init__.py index 507809dab..876758d5b 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/__init__.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/__init__.py @@ -1,4 +1,4 @@ -"""TypedPython — static checking for the Python code of a PythonMultiplatform project. +"""TypedPython, static checking for the Python code of a PythonMultiplatform project. See docs/design/typedpython.md. This package is the soundness gate (§4.2). """ diff --git a/python-multiplatform-ksp/src/main/python/typedpython/__main__.py b/python-multiplatform-ksp/src/main/python/typedpython/__main__.py index 5b3ef98f4..bd220e713 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/__main__.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/__main__.py @@ -135,7 +135,7 @@ def run(load: object) -> tuple[str, float]: compiled_names = [f.name for f in result.module.functions] print(f"compiled functions: {', '.join(compiled_names) or '(none)'}") for name, reason in sorted(result.skipped.items()): - print(f" interpreted: {name} — {reason}") + print(f" interpreted: {name}, {reason}") out_i, t_i = run(interpreted) out_c, t_c = run(lambda: pipeline.load(result)) print(f"interpreted: {t_i * 1000:.1f} ms compiled: {t_c * 1000:.1f} ms " diff --git a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py index 0ded53910..b5e5f0829 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py @@ -27,14 +27,14 @@ block, so ok, error and deopt exits all leave exactly once. Ownership rule (ir.py "Safety"), implemented by the `_own_*` emit helpers below and nowhere else: - 1. every OBJ local — including each OBJ parameter, which the impl copies with a new reference at - entry — owns one strong reference, or is NULL; + 1. every OBJ local, including each OBJ parameter, which the impl copies with a new reference at + entry, owns one strong reference, or is NULL; 2. every OBJ-typed expression evaluates into a fresh temporary that owns a new reference; temporaries are NULL at every statement boundary; 3. a temporary is consumed exactly once: moved into a local (the local's old reference is released), moved into `*tp_out` by Return, or released (`tp_release`) right after the helper that borrowed it returns; - 4. every exit of the impl — return, error, deopt — goes through one exit block that releases + 4. every exit of the impl, return, error, deopt, goes through one exit block that releases every OBJ local and temporary. The generated code never decrements a reference except through `tp_release` (API.md); the only increment is `Py_NewRef` when an OBJ local is read or an OBJ parameter is adopted. @@ -44,9 +44,9 @@ (`{0}`), with `list == NULL` and `dirty == NULL`. It is owned by the function: 1. `Assign(local, NewArray | CopyArray)` builds the new array in a zeroed temporary struct (`tp_*_array_new` / `_iota` / `_copy`), and only when that succeeded frees the old array - (`tp_*_array_free`) and moves the temporary into the slot — so a failed allocation leaves the + (`tp_*_array_free`) and moves the temporary into the slot, so a failed allocation leaves the old binding intact, as `a = [0] * n` does, and `a = a[:]` copies before it frees; - 2. every exit of the impl — return, error, deopt — frees every local array and every array + 2. every exit of the impl, return, error, deopt, frees every local array and every array temporary in the same exit block that releases the OBJ locals (a deopt of a pure function frees them before the wrapper redoes the call interpreted); 3. local arrays never go through `tp_*_array_enter/exit` (there is no list to write back to) and @@ -66,7 +66,7 @@ 2. a `Param.cls` guard (`type(x) is C`, or `x is None` when optional) runs in the wrapper AND at the top of the impl, before tp_enter_call and before anything is owned: the wrapper's copy serves impure functions (whose impl may never return 1), the impl's copy serves C to C Calls, where a - failure is a deopt — so a Call of a function with a `Param.cls` parameter counts as a node that + failure is a deopt, so a Call of a function with a `Param.cls` parameter counts as a node that can deopt (`_can_deopt`), exactly like a callee with entry_globals; 3. FieldGet / FieldSet / New go through `tp_field_get` / `tp_field_set` / `tp_new_fixed` with the captured descriptors; CheckExact deopts (return 1) on mismatch and is legal only in a pure @@ -76,7 +76,7 @@ guards above only select the path; correctness never depends on them, because user code can change a class between calls or, in an impure function, between entry and the access. Every access re-proves what it relies on, right where CPython would look: - 4. FieldGet / FieldSet: `tp_class_current(obj, ...)` — `type(obj) is C` and C's tp_version_tag is + 4. FieldGet / FieldSet: `tp_class_current(obj, ...)`, `type(obj) is C` and C's tp_version_tag is the tag captured at init (or re-proved by `tp_class_refresh`, which adopts a new tag when every field is still the very member descriptor and __init__ the very function captured at init: `Node.counter = 1` costs one refresh, not a slow path forever). One type compare and one tag @@ -91,11 +91,11 @@ binding on the slow path). When a check fails: in an impure function, the slow path with CPython's semantics and no deopt (FieldGet -> tp_getattr, FieldSet -> tp_setattr, New -> call what the global held when it was - loaded, IsExact -> compare with the current global); in a pure function, a deopt (return 1) — + loaded, IsExact -> compare with the current global); in a pure function, a deopt (return 1), running the property or __init__ there could repeat its effect if a later node deopted and the call were redone. A pure function may thus return 1 even when `may_deopt` is False, so: 7. an impure caller of a pure callee that uses classes, in a Call without `redo`, redoes that - callee interpreted on a 1 (`_redo_call`, any return type) — the callee deopted before running + callee interpreted on a 1 (`_redo_call`, any return type), the callee deopted before running any user code, so the redo is unobservable; the impure caller itself never deopts; 8. an impure function with entry globals (closed: it runs no user code, ir.Function.entry_globals) checks at entry, in the wrapper, that every class it reaches is current and every class global @@ -433,7 +433,7 @@ def generate(module: ir.Module, source_path: Path, display_path: str | None = No out: list[str] = [] w = out.append - w(f"/* Generated by typedpython.cgen from {source_path.name} — do not edit. */") + w(f"/* Generated by typedpython.cgen from {source_path.name}, do not edit. */") w("#define PY_SSIZE_T_CLEAN") w("#include ") w("#include ") @@ -570,8 +570,8 @@ def _check_function(f: ir.Function, functions: dict[str, ir.Function]) -> None: def _is_closed(f: ir.Function, functions: dict[str, ir.Function], seen=None) -> bool: - """ir.Function.entry_globals: runs no user code — no effect node, Calls only to closed - functions — so nothing can rebind a module global while it runs.""" + """ir.Function.entry_globals: runs no user code, no effect node, Calls only to closed + functions, so nothing can rebind a module global while it runs.""" seen = set() if seen is None else seen if f.name in seen: return True @@ -668,7 +668,7 @@ def check(self, call: str) -> None: def check_proven(self, call: str) -> None: """A checked I64 helper on a `proven` op (ir.BinOp.proven): it can only return 0 or 1, and - 1 means the front end's and the verifier's proof was wrong — an internal error, never a + 1 means the front end's and the verifier's proof was wrong, an internal error, never a deopt (a proven op may sit after an effect).""" self.emit(f"tp_s = {call};") self.emit("if (tp_s != 0) { PyErr_SetString(PyExc_SystemError, " @@ -677,7 +677,7 @@ def check_proven(self, call: str) -> None: def fail(self) -> None: self.emit("{ tp_rc = -1; goto tp_exit; }") - # ownership helpers — the only places that create or drop references (see module docstring) + # ownership helpers, the only places that create or drop references (see module docstring) def _own_new(self, call: str) -> str: """A fresh temporary owning the new reference `call` returns (NULL -> error exit).""" @@ -1662,7 +1662,7 @@ def _state_functions(mg: _ModGen) -> str: def _class_info_function(mg: _ModGen) -> str: """`__typedpython_class_info__()`: {class name: {compiled, captured_tag, tag, current, slow, - refreshed, watched}} — introspection of the guards (module docstring).""" + refreshed, watched}}, introspection of the guards (module docstring).""" L = [ "static PyObject *tp_class_info(PyObject *tp_module, PyObject *tp_unused)", "{", diff --git a/python-multiplatform-ksp/src/main/python/typedpython/frontend.py b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py index bf2bf5555..10b5712c5 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/frontend.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py @@ -3,7 +3,7 @@ `lower(path)` reads one module and returns an `ir.Module`: - Which functions: module-level `def`s decorated with the builtin `@compiled` (a bare name, no - import — #42), or every module-level `def` when the first non-empty line is + import, #42), or every module-level `def` when the first non-empty line is `# typedpython: compiled`. - A function is lowered only when **all** of it is expressible in the IR; otherwise it is left out and `Module.skipped[name]` says why, with the line. There is no partial lowering. @@ -1525,7 +1525,7 @@ def direct_call(self, name: str, node: ast.Call) -> ir.Expr | None: if p.type != Type.OBJ and e.type != p.type: direct = False # the callee's guard would deopt; the global keeps CPython's result # A class parameter's guard (`type(x) is C`) fails for an argument not proved to be a C: - # that is a deopt of the callee at its entry — redoable only by a pure caller. + # that is a deopt of the callee at its entry, redoable only by a pure caller. unproved = any(isinstance(p, ir.Param) and p.cls is not None and not self.arg_proved(e, p) for p, e in zip(callee.params, args)) if unproved and self.impure_mode: diff --git a/python-multiplatform-ksp/src/main/python/typedpython/incremental.py b/python-multiplatform-ksp/src/main/python/typedpython/incremental.py index 594de1ab2..30a1e8344 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/incremental.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/incremental.py @@ -52,7 +52,7 @@ def _canon(obj) -> bytes: def interface_hash(module: ir.Module) -> str: - """sha256 over the compiled functions' signatures and the skipped names — nothing else.""" + """sha256 over the compiled functions' signatures and the skipped names, nothing else.""" functions = [] for f in sorted(module.functions, key=lambda f: f.name): params = [[p.name, p.type.value, "array" if isinstance(p, ir.ArrayParam) else "scalar"] diff --git a/python-multiplatform-ksp/src/main/python/typedpython/ir.py b/python-multiplatform-ksp/src/main/python/typedpython/ir.py index dbc8dbe5a..b97fe6bfc 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/ir.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/ir.py @@ -23,7 +23,7 @@ Safety (`verify.py`, maintainer decision 2026-10-03: "IR 단계에서 안전성을 증명하도록 해"). Before any C is generated, the verifier proves on the IR that the C will be memory-safe and keep the rules -above; **a function it cannot prove is not compiled** — it stays interpreted and the reason is +above; **a function it cannot prove is not compiled**, it stays interpreted and the reason is reported as a diagnostic. What it proves: - definite assignment: no local is read before it is assigned on every path; @@ -108,20 +108,20 @@ class BinOp(Expr): inserts `ToFloat` for mixed int/float, as CPython converts the int). I64 (result I64, except TRUEDIV → F64): - ADD/SUB/MUL — checked; overflow → deopt. - FLOORDIV/MOD — Python floor semantics (result sign follows the divisor for MOD; the runtime + ADD/SUB/MUL, checked; overflow → deopt. + FLOORDIV/MOD, Python floor semantics (result sign follows the divisor for MOD; the runtime handles -2**63 % -1 == 0 without C's undefined behaviour); divisor 0 → ZeroDivisionError with the running interpreter's message (3.13: "integer division or - modulo by zero" / "integer modulo by zero"; 3.14: "division by zero" for every case — the + modulo by zero" / "integer modulo by zero"; 3.14: "division by zero" for every case, the runtime selects by version and its differential tests pin both); -2**63 // -1 overflows → deopt. Every ZeroDivisionError/math-domain message below follows the same rule. - TRUEDIV — CPython rounds int/int correctly; when both |operands| <= 2**53 the C double + TRUEDIV, CPython rounds int/int correctly; when both |operands| <= 2**53 the C double division is exact-rounded too, otherwise → deopt. Divisor 0 → ZeroDivisionError("division by zero"). F64: - ADD/SUB/MUL — IEEE, as CPython. - TRUEDIV — divisor 0.0 (either sign) → ZeroDivisionError("float division by zero"). - FLOORDIV/MOD — CPython's float floor division and modulo (sign of divisor, `-0.0` cases), + ADD/SUB/MUL, IEEE, as CPython. + TRUEDIV, divisor 0.0 (either sign) → ZeroDivisionError("float division by zero"). + FLOORDIV/MOD, CPython's float floor division and modulo (sign of divisor, `-0.0` cases), divisor 0.0 → ZeroDivisionError("float floor division by zero") / ("float modulo by zero"). OBJ: CPython's generic operation on objects (PyNumber_*), never deopts. @@ -164,7 +164,7 @@ class CompareKind(Enum): @dataclass(frozen=True) class Compare(Expr): """Result BOOL. Chains (`a < b < c`) are lowered by the front end into `And` with each middle - operand evaluated once (via a temporary). Mixed I64/F64 compares **exactly**, as CPython does — + operand evaluated once (via a temporary). Mixed I64/F64 compares **exactly**, as CPython does, not by converting the int to double (2**53+1 != float(2**53)). NaN compares as in IEEE.""" op: CompareKind @@ -247,14 +247,14 @@ class Call(Expr): exactly that class, so such a call counts as a node that can deopt: the caller is pure (and `may_deopt`), or the call is `redo`. A pure callee that uses a compiled class can also return the deopt signal with `may_deopt` False, when the class changed after init (N-11); an impure caller - then redoes that callee interpreted, for OBJ and I64 results as well — such a callee is pure and + then redoes that callee interpreted, for OBJ and I64 results as well, such a callee is pure and its I64 result is interval-proved, so the redone value is the same and fits.""" function: str args: tuple[Expr, ...] # Set when an impure caller calls a may_deopt (therefore pure) callee: on a deopt inside the - # callee, only the callee is redone by its interpreted function — unobservable, because the - # callee is pure — and the caller continues. Allowed only for callees returning F64, BOOL or + # callee, only the callee is redone by its interpreted function, unobservable, because the + # callee is pure, and the caller continues. Allowed only for callees returning F64, BOOL or # NONE: an F64-typed IR expression is a Python float in CPython too (ints reach floats only # through explicit ToFloat or float arithmetic), so the redone result is exactly a float; an # I64 result could be a big int and is not allowed here. @@ -274,7 +274,7 @@ class Global(Expr): @dataclass(frozen=True) class GetAttr(Expr): - """`obj.name` on an OBJ (PyObject_GetAttr — descriptors, properties and binder proxies' attribute + """`obj.name` on an OBJ (PyObject_GetAttr, descriptors, properties and binder proxies' attribute hooks run exactly as in CPython). Type OBJ. Makes the function impure: a property may have effects, so the call can never be redone.""" @@ -284,7 +284,7 @@ class GetAttr(Expr): @dataclass(frozen=True) class CallObject(Expr): - """Calling an OBJ callable — a binder Kotlin function or proxy method, any Python callable — + """Calling an OBJ callable, a binder Kotlin function or proxy method, any Python callable, with positional `args` and keyword arguments `kwnames` (the last len(kwnames) args are the keyword values, as in vectorcall). Arguments are OBJ (scalars arrive through Box). Type OBJ. Makes the function impure. This is the general path that keeps Kotlin interop available to @@ -308,7 +308,7 @@ class CompareObj(Expr): """A comparison of two OBJ operands (a scalar side arrives through `Box`) **directly as a condition**: an If/While cond, or an And/Or operand when that And/Or is itself the condition. Semantics of `if a == b:` in CPython: the rich comparison runs, then the truth value of its - result — **without** PyObject_RichCompareBool's identity shortcut (a NaN float compared with + result, **without** PyObject_RichCompareBool's identity shortcut (a NaN float compared with itself is False in CPython's `if x == x`). Type BOOL. Anywhere the comparison's own result object would be kept (`x = a < b`, `r = c and a < b`, `not (a < b)`) it is not allowed, because that object need not be a bool.""" @@ -348,7 +348,7 @@ class CopyArray(Expr): @dataclass(frozen=True) class Tuple(Expr): - """`(a, b, ...)` of scalars/OBJs — a new tuple (type OBJ). Scalar elements arrive through Box.""" + """`(a, b, ...)` of scalars/OBJs, a new tuple (type OBJ). Scalar elements arrive through Box.""" elements: tuple[Expr, ...] @@ -378,7 +378,7 @@ class IsExact(Expr): @dataclass(frozen=True) class CheckExact(Expr): """`obj` itself (type OBJ), after checking `type(obj) is C`; anything else → deopt. Because it - can deopt it is legal only in a pure function — the same rule as Unbox. It is the proof the + can deopt it is legal only in a pure function, the same rule as Unbox. It is the proof the verifier accepts for a field access the front end cannot guard with a dominating IsExact.""" obj: Expr @@ -393,7 +393,7 @@ class FieldGet(Expr): AttributeError for an unset slot. Runs no user code, so it is not an effect. Type OBJ. The class may change after init (a property replacing the field, `obj.__class__ = Other`): the back end re-checks `type(obj) is C` and C's version tag at the access, and otherwise does what - CPython does — `getattr(obj, field)` in an impure function, a deopt in a pure one (where running + CPython does, `getattr(obj, field)` in an impure function, a deopt in a pure one (where running user code could be repeated by a later deopt's redo). FieldSet likewise (setattr).""" obj: Expr @@ -404,7 +404,7 @@ class FieldGet(Expr): @dataclass(frozen=True) class New(Expr): """`C(args)` for a compiled class whose `__init__` only assigns each slot once from its parameters - (ClassDecl.trivial_init): allocate with C's tp_alloc and fill the slots in __init__'s order — + (ClassDecl.trivial_init): allocate with C's tp_alloc and fill the slots in __init__'s order, the observable result of running that __init__. A fresh object is not an effect. Type OBJ; args are OBJ (scalars through Box). `C` is read as a module global before the args, as CPython's LOAD_GLOBAL is: if it is no longer the captured class, or the class changed (e.g. __init__), the @@ -460,7 +460,7 @@ class StoreIndex(Stmt): @dataclass(frozen=True) class FieldSet(Stmt): - """`obj.field = value` on a proved-exact compiled class (as FieldGet). An effect — unless `obj` is + """`obj.field = value` on a proved-exact compiled class (as FieldGet). An effect, unless `obj` is a New made in this same function and not yet escaped (not assumed in M2b: always an effect).""" obj: Expr @@ -492,7 +492,7 @@ class ForRange(Stmt): """`for var in range(start, stop, step)`: `step` is a non-zero I64 Const; `start`/`stop` are evaluated once before the loop, as range() does. `var` is I64 and is not assigned in the body (the front end checks), so it never overflows: it stays between start and stop. After the loop - `var` keeps its last value, and if the loop ran zero times it keeps its previous binding — the + `var` keeps its last value, and if the loop ran zero times it keeps its previous binding, the front end rejects reading it after the loop unless it was definitely assigned before.""" var: str @@ -538,7 +538,7 @@ class ArrayParam: the same list object (aliasing would make separate copies diverge). Then the elements are copied into a C array. The function body only indexes, stores into and takes `len` of it (no calls that could observe the list, no appends: the front end checks), so the list and the array cannot - diverge observably. On **every** exit — return, exception, or deopt — dirty elements are + diverge observably. On **every** exit, return, exception, or deopt, dirty elements are written back as new float/int objects before control leaves the function, so CPython's partial-update-then-raise behaviour is preserved, and a deopt redo starts from the same list state the interpreted function would see... which is only correct because a function with array @@ -558,24 +558,24 @@ class Function: interpreted; `frontend` records why). Local arrays (fannkuch, M2): a local of an array type holds a native array the function owns, - created only by NewArray/CopyArray. It never escapes — used only by Index/StoreIndex/Len and - as a CopyArray source; never returned, passed, boxed or captured — so stores into it are not + created only by NewArray/CopyArray. It never escapes, used only by Index/StoreIndex/Len and + as a CopyArray source; never returned, passed, boxed or captured, so stores into it are not effects (a function whose only stores are into local arrays can be pure), and no list object ever exists for it. Reassigning the local frees the old array; every exit frees all of them. Its length never changes after creation, so a bounds proof against Len(local) holds. - `pure`: no effect outside its own locals — no stores into anything but locals, no CallObject, + `pure`: no effect outside its own locals, no stores into anything but locals, no CallObject, no GetAttr (a property can have effects), no Truth/CompareObj/ObjToFloat/OBJ BinOp (they run user methods), only `Call`s to pure functions. Kotlin interop (maintainer: compiled code must still talk to Kotlin freely): a function that - calls into the binder is impure, so its int arithmetic cannot use the deopting I64 path — + calls into the binder is impure, so its int arithmetic cannot use the deopting I64 path, unbounded ints there are OBJ (CPython's own int objects, PyNumber_*), while floats (F64 never deopts), bounded loop indices, and the loops around the calls are still native. Only a pure function may contain deopting nodes after entry. Entry guards are generated by the back end from the params: exact runtime type per scalar/array - param (`type(x) is float`, not isinstance — CPython would compute with the int or the subclass + param (`type(x) is float`, not isinstance, CPython would compute with the int or the subclass it was given), i64 range for I64, the ArrayParam checks. A failing guard → deopt (always safe: nothing has happened yet). Wrong arity or keyword use → deopt too, so CPython raises its own TypeError. A function returning a value must return on every path (no falling off the end). @@ -591,8 +591,8 @@ class Function: source_line: int # Module globals of a scalar type read ONCE at entry, as part of the guards: each must be # exactly that type (else deopt, before any effect), and the body reads it as `Local(name)`. - # Legal only for a *closed* function — one that runs no user code (no GetAttr, CallObject, - # Truth, CompareObj, ObjToFloat, OBJ BinOp; Calls only to closed functions) — so nothing can + # Legal only for a *closed* function, one that runs no user code (no GetAttr, CallObject, + # Truth, CompareObj, ObjToFloat, OBJ BinOp; Calls only to closed functions), so nothing can # rebind the global between entry and use, and reading it once equals reading it at each use. entry_globals: tuple[Param, ...] = () diff --git a/python-multiplatform-ksp/src/main/python/typedpython/pipeline.py b/python-multiplatform-ksp/src/main/python/typedpython/pipeline.py index aca7a74a5..cd19151b4 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/pipeline.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/pipeline.py @@ -4,7 +4,7 @@ 1. The gate in `compiled` mode: a module with type errors is not compiled at all. 2. `frontend.lower`: functions outside the IR are left interpreted (`skipped`, with reasons). -3. `verify.verify`: functions the verifier cannot prove are left interpreted too (SPEC N-9 — +3. `verify.verify`: functions the verifier cannot prove are left interpreted too (SPEC N-9, unsafe code is never generated silently). 4. If nothing is left to compile, no C is produced (`extension is None`): the module ships as ordinary Python. diff --git a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md index 51c18a750..b5d6a173f 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md @@ -1,9 +1,9 @@ -# tp_runtime.h — the C runtime API generated code may call +# tp_runtime.h, the C runtime API generated code may call Generated C (cgen.py) reaches memory and `PyObject`s **only** through these helpers (SPEC N-9). Header-only, `static inline`, C99 + CPython C API, no other dependencies. Every helper implements the CPython behaviour named in `ir.py`. Return convention unless stated: `0` ok, `1` deopt (no -Python error set, nothing to undo), `-1` Python error set (CPython's exception type and message — **of the running interpreter**: 3.14 changed several ZeroDivisionError and math-domain messages, and the runtime selects them by PY_VERSION_HEX). +Python error set, nothing to undo), `-1` Python error set (CPython's exception type and message, **of the running interpreter**: 3.14 changed several ZeroDivisionError and math-domain messages, and the runtime selects them by PY_VERSION_HEX). ## Integers (int64_t) int tp_add_i64(int64_t a, int64_t b, int64_t *out); /* 0 / 1 on overflow */ diff --git a/python-multiplatform-ksp/src/main/python/typedpython/verify.py b/python-multiplatform-ksp/src/main/python/typedpython/verify.py index 2257cd3a2..9ad89afc3 100644 --- a/python-multiplatform-ksp/src/main/python/typedpython/verify.py +++ b/python-multiplatform-ksp/src/main/python/typedpython/verify.py @@ -6,7 +6,7 @@ `Diagnostic`. A proved function comes back with each `Index`/`StoreIndex` whose bounds were proved marked `proven=True`; every other access has `proven=False` (an input `proven` is never trusted). -The verifier reads nothing but `ir` — it is independent of the front end — and it trusts no flag +The verifier reads nothing but `ir`, it is independent of the front end, and it trusts no flag the front end set (`pure`, `may_deopt`, `ArrayParam.stored`, node `type`s): each is recomputed from the body and compared. diff --git a/python-multiplatform-ksp/src/test/python/test_cgen.py b/python-multiplatform-ksp/src/test/python/test_cgen.py index eea61452b..8fd8c3704 100644 --- a/python-multiplatform-ksp/src/test/python/test_cgen.py +++ b/python-multiplatform-ksp/src/test/python/test_cgen.py @@ -89,7 +89,7 @@ def fn(name, params, returns, locals_, body, *, pure, may_deopt, line=1, entry_g # The module source that every e2e module embeds; each test picks the functions it compiles. SOURCE = textwrap.dedent('''\ - """typedpython test module — the interpreted reference.""" + """typedpython test module, the interpreted reference.""" calls = [] def record(x): @@ -713,7 +713,7 @@ def test_deopt_propagates_through_a_c_to_c_call(mod): assert deopts(mod) == before assert mod.twice_mul(7, 40) == 2 * 7 ** 40 # The outermost compiled call is redone once (+1). The interpreted twice_mul then calls the - # global mul_all — the compiled one, since there is one namespace — which deopts again (+1). + # global mul_all, the compiled one, since there is one namespace, which deopts again (+1). assert deopts(mod) == before + 2 diff --git a/python-multiplatform-ksp/src/test/python/test_cgen_class_changes.py b/python-multiplatform-ksp/src/test/python/test_cgen_class_changes.py index 23063d209..7e1c76d96 100644 --- a/python-multiplatform-ksp/src/test/python/test_cgen_class_changes.py +++ b/python-multiplatform-ksp/src/test/python/test_cgen_class_changes.py @@ -7,7 +7,7 @@ * changes `Node.__init__`, or adds an unrelated class attribute (`Node.counter = 1`); * exhausts the class's version tags (tp_version_tag stays 0); and it must do so mid-body too, when an impure compiled function runs a Python callback -(CallObject) between entry and the field access / New — without a deopt after that effect. +(CallObject) between entry and the field access / New, without a deopt after that effect. Every scenario runs in a CHILD process (a crash fails one test, not the run) on a fresh compiled module instance, and the same scenario runs on the plain interpreted source (loaded as an ordinary @@ -41,7 +41,7 @@ TEST_DIR = Path(__file__).resolve().parent SOURCE = textwrap.dedent('''\ - """typedpython class-change test module — the interpreted reference.""" + """typedpython class-change test module, the interpreted reference.""" class Node: __slots__ = ("left", "right") @@ -257,7 +257,7 @@ def norm(x): for f in ("left", "right", "extra"): try: d[f] = norm(getattr(x, f)) - except Exception as e: # noqa: BLE001 — the outcome is the result + except Exception as e: # noqa: BLE001, the outcome is the result d[f] = ["exc", type(e).__name__, str(e)] return d @@ -529,7 +529,7 @@ def call(name, *args): return ["exc", type(e).__name__, str(e)] try: out = SCENARIOS[scenario](M, call, P) - except Exception as e: # noqa: BLE001 — a scenario must not raise itself + except Exception as e: # noqa: BLE001, a scenario must not raise itself out = ["scenario-raised", type(e).__name__, str(e)] return out, probes diff --git a/python-multiplatform-ksp/src/test/python/test_cgen_classes.py b/python-multiplatform-ksp/src/test/python/test_cgen_classes.py index c7200cb11..b3355c5d0 100644 --- a/python-multiplatform-ksp/src/test/python/test_cgen_classes.py +++ b/python-multiplatform-ksp/src/test/python/test_cgen_classes.py @@ -28,7 +28,7 @@ ) SOURCE = textwrap.dedent('''\ - """typedpython class test module — the interpreted reference.""" + """typedpython class test module, the interpreted reference.""" class Node: __slots__ = ("left", "right") @@ -287,7 +287,7 @@ def test_other_argument_types_take_the_interpreted_path(m): def outcome(f, x): try: return ("ok", f(x)) - except Exception as e: # noqa: BLE001 — the type and text are the result + except Exception as e: # noqa: BLE001, the type and text are the result return (type(e), str(e)) for bad in (5, "x", object(), m.Fat(None, None), m.Fat(m.Fat(None, None), None)): diff --git a/python-multiplatform-ksp/src/test/python/test_verify.py b/python-multiplatform-ksp/src/test/python/test_verify.py index e2df11290..d98046d81 100644 --- a/python-multiplatform-ksp/src/test/python/test_verify.py +++ b/python-multiplatform-ksp/src/test/python/test_verify.py @@ -179,7 +179,7 @@ def test_read_in_loop_body_before_its_assignment_in_the_body_is_rejected(): def test_continue_skips_the_rest_of_the_loop_body(): # `continue` before the assignment: the read after the If in the body is reached only on the - # path that assigned x — accepted. The read after the loop is not (zero iterations). + # path that assigned x, accepted. The read after the loop is not (zero iterations). body_ok = (If(lb("c"), (Continue(),), (Assign("x", i(1)),)), ExprStmt(li("x"))) assert_accepted(fn(params=[Param("c", BOOL)], locals_={"x": I64}, body=[While(lb("c"), body_ok), Return()])) @@ -881,7 +881,7 @@ def test_proven_flag_survives_verification(): # --- 8. Call.redo --------------------------------------------------------------------------------- def _callee(name="g", returns=F64, pure=True, may_deopt=True): - """g(n: int) — deopts on n * 2 overflow when may_deopt; returns `returns`.""" + """g(n: int), deopts on n * 2 overflow when may_deopt; returns `returns`.""" prod = BinOp(I64, BinOpKind.MUL, li("n"), i(2)) if may_deopt else li("n") value = {F64: ToFloat(F64, prod), I64: prod, BOOL: lt(prod, i(0)), NONE: None}[returns] body = ([ExprStmt(prod), Return()] if returns is NONE else [Return(value)]) @@ -1190,7 +1190,7 @@ def _breaking_mutations(rng, g): return "drop box", _replace_body(g, bpath, bx.operand), {OWNERSHIP, TYPE} return "drop box", _replace_body(g, path, Box(OBJ, e)) if e.type in (I64, F64, BOOL) \ else _replace_body(g, path, Unbox(F64, e)), {OWNERSHIP, TYPE, STRUCTURE} - # kind 13: drop the first Assign (its local becomes possibly unassigned) — breaking because the + # kind 13: drop the first Assign (its local becomes possibly unassigned), breaking because the # local is read later, which holds for the first Assign of both base functions. k = next(j for j, s in enumerate(g.body) if isinstance(s, Assign)) return "drop assign", dataclasses.replace(g, body=g.body[:k] + g.body[k + 1:]), {DA} diff --git a/python-multiplatform/build.gradle.kts b/python-multiplatform/build.gradle.kts index 43b75fa91..62db64db1 100644 --- a/python-multiplatform/build.gradle.kts +++ b/python-multiplatform/build.gradle.kts @@ -1390,7 +1390,7 @@ kotlin { } /** - * The iOS `Python.framework` ships only the interpreter binary and headers — it carries no + * The iOS `Python.framework` ships only the interpreter binary and headers, it carries no * standard library. `Py_Initialize()` therefore aborts the process with * "Fatal Python error: Failed to import encodings module" unless PYTHONHOME points at a * prefix containing `lib/python3.13`. @@ -2177,7 +2177,7 @@ val wasmBrowserRuntimeZip by tasks.registering(Zip::class) { * * Byte-identical in intent to the two substitutions the `KotlinJsTest` block below performs on this * module's own test bundle, and it exists as a function because a *consumer* has to perform them - * too — on its own webpack context, against its own module name. Neither can be done from inside + * too, on its own webpack context, against its own module name. Neither can be done from inside * Kotlin: one is the compiler's `intrinsics.memory` placeholder, the other needs a value * (`wasmInstance.exports`) that exists only in the generated entry module's scope. * @@ -2233,11 +2233,11 @@ fun patchKotlinWasmOutputForCPython(dir: File, modulePrefix: String, logger: org * The entry module with `pmpSetKotlinExports(exports)` inserted, and the *position* is the point. * * This wiring was written against a test bundle, whose entry module ends at - * `setWasmExports(wasmExports)` — the runner calls `startUnitTests` later, from outside — so + * `setWasmExports(wasmExports)`, the runner calls `startUnitTests` later, from outside, so * appending the handoff was enough. An **executable** bundle does not end there: * `binaries.executable()` makes the generated entry module finish with `exports._start()`, which is * Kotlin `main()`. Appended text therefore ran *after* the whole application had already executed, - * and every upcall in it failed with `pmpRegisterUpcall returned -1` — the code for "`kotlinExports` + * and every upcall in it failed with `pmpRegisterUpcall returned -1`, the code for "`kotlinExports` * is still null". * * Observed in a browser rather than reasoned about: the sample printed sections 1-4 correctly and diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/PythonInstrumentationRunner.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/PythonInstrumentationRunner.kt index 45d65ab22..4becab007 100644 --- a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/PythonInstrumentationRunner.kt +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/PythonInstrumentationRunner.kt @@ -9,12 +9,12 @@ import python.native.ffi.PythonOnDevice * `commonTest` is platform-agnostic by construction: its fixture reaches straight for * `Python3.initialize()`. On desktop and the iOS simulator that is enough, because the build * hands those runs a `PYTHONHOME` with a real standard library behind it. On Android the stdlib - * lives inside the APK as assets and has to be unpacked to app-private storage first — without + * lives inside the APK as assets and has to be unpacked to app-private storage first, without * that, `Py_Initialize()` does not fail, it *aborts the process* with "Failed to import encodings * module", taking every other test on the device with it. * * Doing it in the runner rather than in a `@Before` keeps `commonTest` free of Android knowledge, - * which it has to be — it also compiles for iOS and desktop. + * which it has to be, it also compiles for iOS and desktop. * * [PythonOnDevice.ensureInitialised] is idempotent, so the instrumented tests that call it * themselves are unaffected. @@ -22,14 +22,14 @@ import python.native.ffi.PythonOnDevice class PythonInstrumentationRunner : AndroidJUnitRunner() { /** - * `onStart` runs on the instrumentation thread — the same thread that then executes the + * `onStart` runs on the instrumentation thread, the same thread that then executes the * tests. * * This used to say that mattered because `Py_Initialize()` leaves the GIL held by its caller, * so initialising here "keeps the GIL on the thread that is about to use it". That was the * pre-§1 contract and it is now exactly backwards: the GIL must **not** stay held, or no * cleaner thread can ever attach to release a reference. `PythonOnDevice.ensureInitialised` - * parks the thread state, and every entry into Python — including the ones on this thread — + * parks the thread state, and every entry into Python, including the ones on this thread, * goes through `withGIL`. * * What still matters about the thread is only that the staging and the first C API call diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/assembled/AssembledApiTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/assembled/AssembledApiTest.kt index 468f3cf8b..c2c5b17c8 100644 --- a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/assembled/AssembledApiTest.kt +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/assembled/AssembledApiTest.kt @@ -18,7 +18,7 @@ import python.native.ffi.PythonOnDevice * ### Why this file exists * * Android's instrumented tests all reach through `bindings` and rebuild each call by hand, and - * `commonTest` — which does exercise the object model — only runs on iOS and desktop. Nothing + * `commonTest`, which does exercise the object model, only runs on iOS and desktop. Nothing * ran the real API on a device, so nothing noticed that it does not work there: `Python3.exec` * crashes the process on its **first** call, on both API 26 and API 36, because * `PyImport_AddModuleRef`, `PyObject_GetAttrString` and `PyRun_String` still take a Kotlin @@ -29,7 +29,7 @@ import python.native.ffi.PythonOnDevice * * ### Why it is disabled * - * These crash the instrumentation **process**, not just the test — a crashed runner takes every + * These crash the instrumentation **process**, not just the test, a crashed runner takes every * other test on the device with it, so leaving them enabled would hide the suite rather than * report a failure. * diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/env/PythonPayloadStagingTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/env/PythonPayloadStagingTest.kt index c062d50fc..7631c32c1 100644 --- a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/env/PythonPayloadStagingTest.kt +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/env/PythonPayloadStagingTest.kt @@ -17,7 +17,7 @@ import kotlin.test.assertTrue * `assets/python/` is a *consumer's* asset: `toolchain`'s `stagePythonBundleAndroid` registers a * staging root into the consuming application's AGP asset sources. This library's own instrumented * APK is not such a consumer and carries no payload, so the copy loop in - * [PythonBootstrap.stagePayload] is unreachable from here — there is no fixture to drive it with, + * [PythonBootstrap.stagePayload] is unreachable from here, there is no fixture to drive it with, * and manufacturing one means adding an asset source directory to `build.gradle.kts`, which is a * build-structure change and not this test's to make. * diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniOverheadBenchmark.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniOverheadBenchmark.kt index 9b12d7bbe..d6f143efc 100644 --- a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniOverheadBenchmark.kt +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniOverheadBenchmark.kt @@ -64,7 +64,7 @@ class JniOverheadBenchmark { /** * The echo probes touch no interpreter state, but `realCPythonCallByCallingConvention` calls * CPython for real, and the main thread state is parked (see - * [PythonOnDevice.ensureInitialised]) — so this thread has to attach for itself. Done once + * [PythonOnDevice.ensureInitialised]), so this thread has to attach for itself. Done once * per test, outside the timed loops, so the attach is not what gets measured. */ private var gilState = 0 diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniWiringTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniWiringTest.kt index 47d37eccf..701d32829 100644 --- a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniWiringTest.kt +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniWiringTest.kt @@ -11,7 +11,7 @@ import org.junit.runner.RunWith * Settles, on a real device, whether the JNI wiring between the Kotlin/Native `@CName` exports and * the `external fun` declarations in [bindings] actually passes arguments correctly. * - * The exports take only their declared arguments — none of them accepts a `JNIEnv*`. But `bindings` + * The exports take only their declared arguments, none of them accepts a `JNIEnv*`. But `bindings` * declares them as ordinary JNI methods, and ART invokes those as * `f(JNIEnv*, jobject, args...)`. If that is what happens, every argument arrives shifted by two * slots and the first one reads the `JNIEnv` pointer instead of its value. @@ -57,13 +57,13 @@ class JniWiringTest { @Test fun zeroArgCallsWorkRegardless() { - // Expected to pass even with a broken convention — recorded so the contrast with + // Expected to pass even with a broken convention, recorded so the contrast with // argumentsArriveUnshifted is visible in the results. // // This used to assert 0, "before initialisation". That premise died when commonTest was // connected to Android: 176 tests share one process, and whether the interpreter is // already up when this runs is decided by class ordering, not by wiring. It failed on - // both API levels for exactly that reason — the interpreter was already up, so it read 1. + // both API levels for exactly that reason, the interpreter was already up, so it read 1. // // Pinning the interpreter to a known state first and asserting the exact value is the // stronger check anyway: a zero-arg call under a shifted convention returns whatever diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/PythonOnDevice.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/PythonOnDevice.kt index b3a0cde41..3f0703647 100644 --- a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/PythonOnDevice.kt +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/PythonOnDevice.kt @@ -35,8 +35,8 @@ object PythonOnDevice { * Calling the C function directly here skipped the parking, and then made the omission * permanent: `Python3.isInitialized` is seeded from `Py_IsInitialized()` the first time the * object is touched, so it latched to `true` and [Python3.initialize] returned early for the - * rest of the process. The instrumentation thread — which is also the thread every test body - * runs on — held the GIL for the entire run, and every cleaner blocked on the first + * rest of the process. The instrumentation thread, which is also the thread every test body + * runs on, held the GIL for the entire run, and every cleaner blocked on the first * `PyGILState_Ensure` it reached. * * That state is invisible while nothing releases the GIL, which is why it survived: the old diff --git a/python-multiplatform/src/androidMain/README.md b/python-multiplatform/src/androidMain/README.md index 5e6a03298..94313778c 100644 --- a/python-multiplatform/src/androidMain/README.md +++ b/python-multiplatform/src/androidMain/README.md @@ -1,4 +1,4 @@ -# androidMain — rules +# androidMain, rules The JVM half of Android. Reaches CPython through JNI into the Kotlin/Native library built from `artMain` + `nativeMain`. @@ -6,9 +6,9 @@ The JVM half of Android. Reaches CPython through JNI into the Kotlin/Native libr ## The host app's bootstrap belongs here, not in the README `PythonBootstrap` unpacks the stdlib out of the AAR's assets, sets `PYTHONHOME` and calls -`Python3.initialize`. It exists because the same ~30–55 lines had been hand-written three times — +`Python3.initialize`. It exists because the same ~30–55 lines had been hand-written three times, `sample`'s `MainActivity`, the external consumer app of ROADMAP §15d, and this repo's own -`PythonOnDevice` fixture — from one README paragraph, and **all three got the same thing wrong**: +`PythonOnDevice` fixture, from one README paragraph, and **all three got the same thing wrong**: each decided "already unpacked" by probing one entry of the result, which a copy interrupted partway through satisfies. See ROADMAP §15f. @@ -22,7 +22,7 @@ Two rules follow for anything added here: before a rewrite starts, so a partial tree cannot look complete. `encodings/ exists` is a property of a half-finished copy too. -`AssetManager.open()` throwing `FileNotFoundException` is how a directory is detected — it folds +`AssetManager.open()` throwing `FileNotFoundException` is how a directory is detected, it folds the classification into the `open()` the file needed anyway and is worth ~2x over a `list()` per entry (192→124 ms on API 26, 324→150 ms on API 36, for 804 files / 20.1 MB). It is **not** in `AssetManager`'s documented contract, and AssetManager was reimplemented wholesale in API 28, so @@ -31,7 +31,7 @@ whatever API level is running rather than trusting it. ## The consumer's payload is a second tree, with a second stamp -`assets/python/` is the consumer's own Python — `toolchain`'s `stagePythonBundleAndroid` registers +`assets/python/` is the consumer's own Python, `toolchain`'s `stagePythonBundleAndroid` registers its staging root as an AGP asset source. `PythonBootstrap.stagePayload` unpacks it to `/python` and registers it; `Python3.initialize` puts it on `sys.path[0]`. @@ -57,18 +57,18 @@ steps: `PythonBootstrap` does the half that needs a `Context`, exactly as it alr declarations here. Two reasons, both measured: - Name-based linking of a `@CriticalNative` method **aborts the ART runtime** below Android 12 - (`zygote64: runtime.cc:492] Runtime aborting...`), and is merely slow above it — 76.42 ns + (`zygote64: runtime.cc:492] Runtime aborting...`), and is merely slow above it, 76.42 ns against 46.55 ns for the registered form on API 36 hardware. - Registration removes the dependence on JNI name mangling, which is what produced the original argument-shift bug in the first place. -### Every `external fun` here needs a table entry — no exceptions by reachability +### Every `external fun` here needs a table entry, no exceptions by reachability An unregistered declaration does not fail cleanly when it is finally reached. It falls back to a `@CName` export in `nativeMain` that has no `JNIEnv*`/`jclass` prologue, so the arguments arrive shifted by two registers, and the process dies on a truncated pointer (ROADMAP §2 records two: `PyImport_AddModule` at `0xc24110e8`, `PyErr_GetRaisedException` at `0xc2411160`). "Nothing calls -it today" is not a defence — it is a description of when the crash happens, not whether. +it today" is not a defence, it is a description of when the crash happens, not whether. There are exactly four deliberate exceptions and they are named in ROADMAP §2. Adding a fifth means writing down why. @@ -85,7 +85,7 @@ Two mechanical rules fall out of this: grep -oE "^static [a-z]+ (f_[A-Za-z0-9_]+)" jni_onload.def | awk '{print $3}' | sort | uniq -d ``` -To audit the whole surface, join the compiled class against the table — the class, not the Kotlin +To audit the whole surface, join the compiled class against the table, the class, not the Kotlin source, because the descriptor the JVM will match on is the one in the bytecode: ```bash @@ -96,17 +96,17 @@ javap -p -s build/tmp/kotlin-classes/debug/python/native/ffi/bindings.class Two independent axes. Correctness first. -**Axis 1 — what the function may do.** `@FastNative` and `@CriticalNative` both stop the +**Axis 1, what the function may do.** `@FastNative` and `@CriticalNative` both stop the collector for the duration of the call, and `@CriticalNative` has no `JNIEnv`, so nothing under -it can re-enter the runtime. Anything that can execute arbitrary Python — a module's top-level -code during import, a `__getattr__`, a `__del__` reached by dropping the last reference — +it can re-enter the runtime. Anything that can execute arbitrary Python, a module's top-level +code during import, a `__getattr__`, a `__del__` reached by dropping the last reference, stays on ordinary JNI at every API level. `PyErr_Clear` and `PyObject_GetAttrString` both look like leaves and are not. When in doubt, ordinary JNI. A wrong guess that way costs nanoseconds; the other way is a crash once upcalls exist. -**Axis 2 — what the device fast-paths.** Measured across five API levels, net of the Kotlin +**Axis 2, what the device fast-paths.** Measured across five API levels, net of the Kotlin floor, ns/call: | API | `@CriticalNative` | `@FastNative` | ordinary JNI | @@ -122,7 +122,7 @@ Hence `bindings.preferFastNative = SDK_INT >= 34`. `dispatchPicksTheFasterConven re-measures both on whatever device runs it and fails if the flag disagrees, so a future release moving the boundary shows up as a test failure rather than silent slowness. -Read the table in the direction that matters when adding a call site — how much a promotion is +Read the table in the direction that matters when adding a call site, how much a promotion is worth, relative to leaving it ordinary: | API | ordinary → `@FastNative` | ordinary → `@CriticalNative` | @@ -146,7 +146,7 @@ Ordinary JNI is the default. A promotion has to survive all five steps. Any refcount reaching zero deallocates and runs `__del__`, which is arbitrary Python. This catches far more than the names suggest: every setter that overwrites (`PyTuple_SetItem`, `PyList_SetItem`, -`PyDict_SetItem*`, `PySys_SetObject`), every delete, `PyErr_Clear`, `Py_Finalize` — and +`PyDict_SetItem*`, `PySys_SetObject`), every delete, `PyErr_Clear`, `Py_Finalize`, and `PyGILState_Release`, because the release that drops the counter to zero deletes the thread state, and clearing a thread state decrefs its dict and exception state. A function that looks pure and touches a refcount is re-entrant. @@ -159,7 +159,7 @@ and descriptor `__get__` on any attribute access, `__hash__`/`__eq__` on any dic *including lookups*, `__iter__`/`__next__` inside `PySequence_Fast`, `__getitem__` inside `PyUnicode_Translate`, module top-level code on any import, a Python codec on any encode/decode that takes an encoding name, and `warnings.showwarning` inside `PyErr_WarnExplicit`. -`PyThreadState_GetDict` belongs here too — it allocates the thread dict lazily on first call per +`PyThreadState_GetDict` belongs here too, it allocates the thread dict lazily on first call per thread, and is not the struct read its name implies. **3. Can it block?** → ordinary. `PyGILState_Ensure`, `PyEval_RestoreThread`. A thread waiting for @@ -171,7 +171,7 @@ this call. There is no C API function that cannot fail, and CPython reports failure by constructing an exception instance, which is GC-tracked, which lands back in step 2. So step 4 never returns -"provably a leaf" — it returns "leaf on the success path, second-order re-entrant on the error +"provably a leaf", it returns "leaf on the success path, second-order re-entrant on the error path". That is why the promoted set is short and enumerated rather than derived from a rule: it is the set where a benchmark showed the convention *is* the call cost, not the set that passed an argument. @@ -182,7 +182,7 @@ The C wrapper for the `@FastNative` twin is the same body with a leading `JNIEnv ### The five steps are enforced, not just written down -`JniCallConventionClassificationTest` (desktopTest — it reads sources, so it needs no device) +`JniCallConventionClassificationTest` (desktopTest, it reads sources, so it needs no device) re-derives the classification on every run from `bindings.kt`, `artMain/cinterop/jni_onload.def` and the CPython headers bundled in `src/nativeInterop/cinterop/include`, and fails if a call site reaches a GC-blocking binding that is not in its enumerated promoted set. @@ -190,11 +190,11 @@ a GC-blocking binding that is not in its enumerated promoted set. Two things it is deliberately not: - **It does not decide promotions.** The promoted set is a `Map` in the test with a reason per entry, - because step 4 has no derivable answer — every C API function can fail, and failure allocates a + because step 4 has no derivable answer, every C API function can fail, and failure allocates a GC-tracked exception. What the test enforces is that the set stays *enumerated*: a new `@FastNative`/`@CriticalNative` declaration fails the build until someone writes down why. -- **It does not claim to prove leafness.** A header prototype can condemn a function — a `PyObject *` - crossing it means a decref, a slot, or a GC-tracked allocation is reachable — but nothing in the +- **It does not claim to prove leafness.** A header prototype can condemn a function, a `PyObject *` + crossing it means a decref, a slot, or a GC-tracked allocation is reachable, but nothing in the headers clears one. `Py_Initialize` and `PyRun_SimpleString` mention no `PyObject *` at all and both run arbitrary Python, so everything the rule cannot condemn lands in an **UNDECIDED** bucket that is barred from promotion. The bucket is currently 65 of 366 registrations. The audit that reported @@ -208,21 +208,21 @@ holds 366 today. | guessed wrong toward | costs | |---|---| -| ordinary | 5.8 ns (API 33/34) to 41 ns (API 30) per call — measurable, and bounded | +| ordinary | 5.8 ns (API 33/34) to 41 ns (API 30) per call, measurable, and bounded | | a promotion | the collector stalls for as long as the Python runs; under `@CriticalNative` there is no `JNIEnv`, so any upcall that Python reaches has none either | Only the first of those shows up in a test. The second needs a device, live upcalls, *and* the -cyclic collector to fire inside that particular call — so it is found in production, not in CI. +cyclic collector to fire inside that particular call, so it is found in production, not in CI. ## The boundary carries primitives only -`Long`, `Int`, `Double`, `Unit`. No `String`, no nullable `JNIPointer?` — a nullable `Long` is a +`Long`, `Int`, `Double`, `Unit`. No `String`, no nullable `JNIPointer?`, a nullable `Long` is a boxed `java.lang.Long` on the JVM against a raw `jlong` on the other side. Strings cross as addresses; null pointers cross as `0L`. Conversion happens here, in `EmbedAPI.android.kt`. ## String marshalling is the expensive part, not crossings -Composing `Python3.exec` natively nearly halves it — 17.8 µs to 10.1 µs. That is **not** the +Composing `Python3.exec` natively nearly halves it, 17.8 µs to 10.1 µs. That is **not** the crossing count: 7.7 µs over ~11 crossings would be 700 ns each, against the ~7 ns a crossing actually costs, and the per-call figure is identical on API 26 and API 36 despite a 20x difference in per-crossing cost. It is the three `ffiAllocUtf8` round trips, each a JNI call @@ -245,7 +245,7 @@ libcore's `System.gc()` records a request and defers the collection until the ne `System.runFinalization()`, so a loop of bare `System.gc()` calls runs **no collection at all**. On HotSpot the same loop collects, which is why the desktop suite never saw this. -Anything on Android that waits for the collector — every GC-driven release test — must use +Anything on Android that waits for the collector, every GC-driven release test, must use `Runtime.getRuntime().gc()` followed by `System.runFinalization()`. The whole of `GCLeakTest` failed on both API levels for this reason, and the failure reads as "no cleaner ran", which points at the cleaner threads rather than at the collector that never started. @@ -257,7 +257,7 @@ thread state with `PyEval_SaveThread()` immediately afterwards, and that parking cleaner thread attach through `PyGILState_Ensure` and call `Py_DecRef` (ROADMAP §1). `PythonInstrumentationRunner` used to bring the interpreter up with a bare `Py_Initialize()`, -which skipped the parking — and then made the omission permanent, because `Python3.isInitialized` +which skipped the parking, and then made the omission permanent, because `Python3.isInitialized` is seeded from `Py_IsInitialized()` when the object is first touched, so it latched to `true` and `Python3.initialize()` returned early for the rest of the process. The instrumentation thread, which is also the thread every test body runs on, held the GIL for the whole run. @@ -269,6 +269,6 @@ Two consequences worth knowing, because neither looks like a GIL problem: gets stuck **during** the test; one stuck in an earlier test is indistinguishable from "nothing was collected". - Instrumented tests that reach `bindings` directly ran fine only because that thread happened - to hold the GIL. Once it is parked they must attach for themselves — + to hold the GIL. Once it is parked they must attach for themselves, `PythonOnDevice.attach()`/`detach()` in a `@Before`/`@After` pair, which is what `CompositionBenchmark`, `JniOverheadBenchmark` and `JniWiringTest` now do. diff --git a/python-multiplatform/src/androidMain/kotlin/python/multiplatform/env/PythonPayload.android.kt b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/env/PythonPayload.android.kt index 2c4045920..08e805dfe 100644 --- a/python-multiplatform/src/androidMain/kotlin/python/multiplatform/env/PythonPayload.android.kt +++ b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/env/PythonPayload.android.kt @@ -14,7 +14,7 @@ import java.io.File * * ### Why discovery here is a registry rather than a scan * - * Unpacking assets needs a `Context`, and `Python3.initialize()` has none — it is a `commonMain` + * Unpacking assets needs a `Context`, and `Python3.initialize()` has none, it is a `commonMain` * function on an object with no platform state. The two-step (stage, then register; discover, then * install) is what lets `PythonBootstrap.initialize(context)` do the part that needs a `Context` * before the part that does not. `PythonBootstrap` already sits in exactly that position for diff --git a/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ref/PyAutoCloseable.android.kt b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ref/PyAutoCloseable.android.kt index c2ebe6f99..f3a20a979 100644 --- a/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ref/PyAutoCloseable.android.kt +++ b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ref/PyAutoCloseable.android.kt @@ -16,7 +16,7 @@ actual interface PlatformCleaner : AutoCloseable { * [PhantomCleanerRegistry], which is the same mechanism written by hand. * * This nullable value, not `SDK_INT`, is what the code below branches on. The two are equivalent - * only as long as nothing else can make the `Cleaner` absent — and the previous version of this + * only as long as nothing else can make the `Cleaner` absent, and the previous version of this * file branched on `SDK_INT` and then called `sharedCleaner?.register(...)`, so a null here on * API 33+ would have produced a wrapper with **no release registered at all**, silently, with the * `?.` reading as caution. Branching on the object removes the case rather than tolerating it. @@ -32,11 +32,11 @@ actual fun registerCleaner( } /** - * One release, run by whichever of `close()` and the collector gets there first — and never by + * One release, run by whichever of `close()` and the collector gets there first, and never by * both. Both branches guarantee that: `Cleaner.Cleanable.clean()` is documented to run its action * at most once, and [PhantomCleanerRegistry.Cleanable] holds a compare-and-set flag for it. It has * to be a guarantee rather than a convention, because a second run is a second `Py_DecRef` on a - * reference this wrapper no longer owns — the double free of ROADMAP §1 and §4. + * reference this wrapper no longer owns, the double free of ROADMAP §1 and §4. */ private class AndroidCleaner( pointer: NativePointer, diff --git a/python-multiplatform/src/androidMain/kotlin/python/native/ffi/EmbedAPI.android.kt b/python-multiplatform/src/androidMain/kotlin/python/native/ffi/EmbedAPI.android.kt index 45d6130c2..5b6c42d3d 100644 --- a/python-multiplatform/src/androidMain/kotlin/python/native/ffi/EmbedAPI.android.kt +++ b/python-multiplatform/src/androidMain/kotlin/python/native/ffi/EmbedAPI.android.kt @@ -16,8 +16,8 @@ actual fun AddressValue.toNativePointer(): NativePointer = NativePointer((this a * Deliberately does not delegate to the `JNIPointer?` overload below. * * `JNIPointer` is a typealias for `Long`, so that overload's receiver is `Long?` and this one's is - * `Long`. Overload resolution prefers the more specific, non-null receiver — that is, this function - * itself — so writing `toNativePointer()` here recurses forever and blows the stack. On Android the + * `Long`. Overload resolution prefers the more specific, non-null receiver, that is, this function + * itself, so writing `toNativePointer()` here recurses forever and blows the stack. On Android the * platform pointer *is* the `Long`, so the conversion is written out instead. */ @HighOverheadNativeCall diff --git a/python-multiplatform/src/androidMain/kotlin/python/native/ffi/UpcallEntry.android.kt b/python-multiplatform/src/androidMain/kotlin/python/native/ffi/UpcallEntry.android.kt index 350f4e300..885cc6702 100644 --- a/python-multiplatform/src/androidMain/kotlin/python/native/ffi/UpcallEntry.android.kt +++ b/python-multiplatform/src/androidMain/kotlin/python/native/ffi/UpcallEntry.android.kt @@ -57,11 +57,11 @@ import python.multiplatform.reflection.UpcallTable * `threading.Thread` is a bare pthread, so the first time one reaches ART it must be attached. * `pmp_attach` holds that attachment for the life of the thread and gives it back from a * `pthread_key_create` destructor, which is what turned it from a per-call charge of 14–62 µs into - * a per-thread one — but a thread that upcalls exactly once still pays all of it. + * a per-thread one, but a thread that upcalls exactly once still pays all of it. * * It is not marked with [HighOverheadNativeCall]. That marker is `@RequiresOptIn`, and its job is - * to make a Kotlin *caller* acknowledge a cost; nothing in Kotlin calls these — C does, from - * CPython — so applying it here would be inert. The cost is recorded instead where the callers + * to make a Kotlin *caller* acknowledge a cost; nothing in Kotlin calls these, C does, from + * CPython, so applying it here would be inert. The cost is recorded instead where the callers * that can act on it will look: here, in `pmp_upcall_invoke_meth`, and in `docs/design/upcall.md`. */ object UpcallCallbacks { diff --git a/python-multiplatform/src/artMain/README.md b/python-multiplatform/src/artMain/README.md index 05beb7ca0..0a02bda2c 100644 --- a/python-multiplatform/src/artMain/README.md +++ b/python-multiplatform/src/artMain/README.md @@ -1,4 +1,4 @@ -# artMain — rules +# artMain, rules androidNative sources shared only by `androidNativeArm64` and `androidNativeX64`. iOS depends on `nativeMain` directly and never sees this, which is what makes it the right home for JNI code. @@ -29,5 +29,5 @@ once. Measured against the per-call equivalent on device: | `Python3.exec` | 17799.46 ns | 10123.94 ns | 1.8x | They must take and return primitives, or fill a caller-provided array: Kotlin/Native objects -cannot cross to the JVM. And they run Python, so they use ordinary JNI — a GC-blocking +cannot cross to the JVM. And they run Python, so they use ordinary JNI, a GC-blocking convention would be wrong regardless of speed. diff --git a/python-multiplatform/src/artMain/kotlin/python/multiplatform/env/PythonPayload.art.kt b/python-multiplatform/src/artMain/kotlin/python/multiplatform/env/PythonPayload.art.kt index 4067deaa0..d4bbdff14 100644 --- a/python-multiplatform/src/artMain/kotlin/python/multiplatform/env/PythonPayload.art.kt +++ b/python-multiplatform/src/artMain/kotlin/python/multiplatform/env/PythonPayload.art.kt @@ -4,7 +4,7 @@ package python.multiplatform.env * androidNative has no packaging step to receive a payload from, so there is nothing to discover. * * This target is the Kotlin/Native half of Android: it is built into a `.so` that the JVM half - * loads through JNI, and everything an Android app *ships* — assets included — is packaged by AGP + * loads through JNI, and everything an Android app *ships*, assets included, is packaged by AGP * on the JVM side. `assets/python/` is therefore read by `androidMain`'s * [PythonBootstrap.stagePayload], which has the `Context` that reading an APK's assets requires, * and this side sees the result as a plain directory that is already on `sys.path` by the time any diff --git a/python-multiplatform/src/artMain/kotlin/python/multiplatform/ffi/JniExport.kt b/python-multiplatform/src/artMain/kotlin/python/multiplatform/ffi/JniExport.kt index 413bdd7bf..877c11aa2 100644 --- a/python-multiplatform/src/artMain/kotlin/python/multiplatform/ffi/JniExport.kt +++ b/python-multiplatform/src/artMain/kotlin/python/multiplatform/ffi/JniExport.kt @@ -126,7 +126,7 @@ fun sayHello() { * Diagnostic only: returns its argument unchanged. * * Exported under `python.native.ffi.bindings` so the Android JVM side can call it through the same - * path as every other binding. Its whole purpose is to make an argument-slot mismatch visible — + * path as every other binding. Its whole purpose is to make an argument-slot mismatch visible, * if the JNI calling convention does not match this export's signature, the value read back will * not be the value passed in. */ diff --git a/python-multiplatform/src/commonMain/README.md b/python-multiplatform/src/commonMain/README.md index ac0c24332..ba88cf071 100644 --- a/python-multiplatform/src/commonMain/README.md +++ b/python-multiplatform/src/commonMain/README.md @@ -1,8 +1,8 @@ -# commonMain — rules +# commonMain, rules The object model and the `expect` declarations every platform implements. -## Layering — the object model must not reach past the FFI layer +## Layering, the object model must not reach past the FFI layer python.multiplatform.ffi PyObject, PyType, collections, conversion | may call @@ -13,7 +13,7 @@ The object model and the `expect` declarations every platform implements. python.native.ffi.bindings JNI externals / Panama handles `PyObject` and friends call EmbedAPI functions and nothing below them. `bindings` is a platform -detail that only `EmbedAPI..kt` may know about — it does not exist at all on the +detail that only `EmbedAPI..kt` may know about, it does not exist at all on the native targets, which use cinterop. A composed operation is a platform implementation difference, so it belongs in the FFI layer as @@ -34,18 +34,18 @@ requirement that a thread be attached before touching any object. records scopes *Kotlin* opened, and C is free to have dropped the GIL inside one of them: `ctypes.CFUNCTYPE` releases it around every foreign call (`PYFUNCTYPE` does not). So a callback entered from C can run with a non-zero depth and no thread state at all, and the first C API call -segfaults inside `_PyThreadState_GET` — observed here as `PyErr_Occurred+0x1c`. +segfaults inside `_PyThreadState_GET`, observed here as `PyErr_Occurred+0x1c`. Anything Python calls into therefore takes its own `PyGILState_Ensure`/`Release` pair unconditionally and restores the counters around it; `python.multiplatform.ffi.upcall .UpcallTrampoline.attached` is that. It does **not** apply to CPython's own type slots (`tp_traverse`, `tp_clear`, `tp_dealloc`), which are entered with the GIL held and must neither -take nor release it — see `ProxyTypeFactory`. +take nor release it, see `ProxyTypeFactory`. ### An asynchronous completion is a second entry point, on a thread nobody attached An exposed `suspend fun` returns an `asyncio.Future` and its value arrives later, on whatever -thread resumed the coroutine — which is generally not the thread that took the upcall, and may be +thread resumed the coroutine, which is generally not the thread that took the upcall, and may be one CPython has never seen. That thread takes its own `withGIL` scope before touching anything (`python.multiplatform.ffi.upcall.AsyncUpcall`), and `withGIL` is right here where it is wrong for a C entry point: this is *not* reached from C, so the thread's nesting depth is its own honest @@ -53,20 +53,20 @@ record. A completer thread with depth 0 takes a real `PyGILState_Ensure`; a comp from inside a later upcall correctly skips, under the depth that upcall's `attached` set. The same acquisition is what publishes `PendingCall`'s fields. That class is unsynchronised on the -stated condition that every mutation happens on a GIL-holding thread — so **a dispatcher that +stated condition that every mutation happens on a GIL-holding thread, so **a dispatcher that resumes an exposed call's continuation must hold the GIL while it does so**, not merely take it afterwards to talk to Python. Measured: `AsyncCompletionProbeTest` (a Kotlin-created thread can take the GIL while the interpreter is parked in `run_until_complete`) and `AsyncUpcallDeliveryTest`. -## Attaching is not enough — someone has to reach an eval-loop checkpoint +## Attaching is not enough, someone has to reach an eval-loop checkpoint `_Py_HandlePending` is the only place CPython merges the free-threaded build's deferred reference-count queue, processes QSBR-deferred frees, runs a *scheduled* cyclic collection and drains pending calls and signals. Nothing in the C API calls it; only the `_CHECK_PERIODIC` uop that opens every Python-level frame does. So an embedder that never executes bytecode never reaches one, and a reference the cleaner gave back on a free-threaded build stays unreclaimed -however long you wait — the count is right, the memory is not returned. +however long you wait, the count is right, the memory is not returned. `Python3.drainPendingReleases()` reaches one, by calling a cached empty Python function (measured 355 ns, against 11 µs for `exec("pass")`). It also runs automatically once every @@ -77,13 +77,13 @@ proved that deadlocks. See ROADMAP §9. ### The GIL build needs the same checkpoint, for cyclic garbage specifically -`_PY_GC_SCHEDULED_BIT` — set by `_Py_ScheduleGC` on an allocation that crosses a generation -threshold, and how allocation has triggered a cyclic collection since 3.12 — is read by the same +`_PY_GC_SCHEDULED_BIT`, set by `_Py_ScheduleGC` on an allocation that crosses a generation +threshold, and how allocation has triggered a cyclic collection since 3.12, is read by the same `_Py_HandlePending` as the free-threaded merge above, and nowhere else. That part of the problem is **not free-threading-specific**: an embedder that only calls `PyObject_Call` and friends never runs the cyclic collector on either build, because neither build ever executes a bytecode frame on -its own. A plain (non-cyclic) reference still frees immediately on the GIL build — there is no -queue there — but a reference cycle accumulates exactly as it would on the free-threaded build's +its own. A plain (non-cyclic) reference still frees immediately on the GIL build, there is no +queue there, but a reference cycle accumulates exactly as it would on the free-threaded build's deferred-release queue. Measured: 10,000 cyclic groups built purely through the C API leave 20,000+ objects uncollected indefinitely with `autoDrainInterval = 0`; the same workload with `autoDrainInterval` on leaves only what has accumulated since the last checkpoint. See @@ -92,15 +92,15 @@ deferred-release queue. Measured: 10,000 cyclic groups built purely through the `Python3.autoDrainInterval` therefore defaults to **off** on the GIL build too, and that default is deliberate rather than an oversight: turning it on is a real behavioural change, not a free one. A checkpoint tripped inside an ordinary `withGIL` scope can run whatever the collector or the -release queue was holding onto — `__del__`, weakref callbacks, pending calls — at a point the +release queue was holding onto, `__del__`, weakref callbacks, pending calls, at a point the caller never asked to yield from. Measured reentrant-safe (a `__del__` that calls back into -Kotlin mid-collection completes without deadlocking, looping or crashing — +Kotlin mid-collection completes without deadlocking, looping or crashing, `GCSchedulingMeasurementTest.testReentrancyDuringCheckpoint`), and cheap (~5.5 ns per outermost -`withGIL` scope, ~2.9% of the floor, amortised over the interval) — but still a side effect an +`withGIL` scope, ~2.9% of the floor, amortised over the interval), but still a side effect an embedder has to opt into with eyes open, by setting `autoDrainInterval` explicitly or calling `Python3.drainPendingReleases()`. An embedder that would rather force an immediate, unconditional collection without touching either can call `PyGC_Collect()` directly (`python.native.ffi`, or -`python.multiplatform.ffi.utils.PyGC.collect()` for the `gc.collect()`-equivalent route) — the +`python.multiplatform.ffi.utils.PyGC.collect()` for the `gc.collect()`-equivalent route), the same function `drainPendingReleases()` documents as the way to reclaim on behalf of *another* thread. @@ -109,9 +109,9 @@ thread. CPython's C API is inconsistent about ownership and getting it wrong is silent. Write which one applies: -- `PyTuple_SetItem`, `PyList_SetItem` **steal** — do not release afterwards -- `PyTuple_GetItem`, `PyDict_GetItem` **borrow** — do not release; incref to keep -- most `PyObject_*` return **new references** — release when done +- `PyTuple_SetItem`, `PyList_SetItem` **steal**, do not release afterwards +- `PyTuple_GetItem`, `PyDict_GetItem` **borrow**, do not release; incref to keep +- most `PyObject_*` return **new references**, release when done `RefCountTest` checks the balance in both directions. One too few leaks; one too many frees an object still in use, and that crash surfaces somewhere unrelated. @@ -122,7 +122,7 @@ object still in use, and that crash surfaces somewhere unrelated. together. The rule is per type, not per shape: cache what copied itself out of CPython (`Long`, `Double`, `Boolean`, a decoded `String`, a container recursively built out of those), refuse what would still point into Python-owned memory (`bytes`/`bytearray`/`memoryview`, whose native form is -a pointer into the object's own buffer), and never store a bare `NativePointer` — what +a pointer into the object's own buffer), and never store a bare `NativePointer`, what `ConversionStrategy.RAW` hands back is an address, not a reference. `isIndependentOfPythonMemory` in `conversion/PyProxy.kt` is that rule as code, and every store to diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Versions.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Versions.kt index 94bb845bf..49cbc440c 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Versions.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Versions.kt @@ -6,7 +6,7 @@ package python.multiplatform * * This used to be an enum whose only entry was `PYTHON_3_13_0`, validated against * [BuildConfig.pythonVersion] at class-initialisation time. That made every version bump a source - * change, and failing to make it did not produce a build error — it produced an + * change, and failing to make it did not produce a build error, it produced an * `ExceptionInInitializerError` the first time anything touched the FFI at runtime, which is how * moving the build to 3.14.7 broke the Android path while every compile target still passed. * @@ -22,7 +22,7 @@ class Versions private constructor(val versionString: String) { * Parses `major.minor[.patch][suffix]`, e.g. `3.14.7` or `3.15.0rc1`. * * Rejects anything without at least a major and minor component, since [compactVersionString] - * — which names the shared library and the stdlib directory — depends on both. + *, which names the shared library and the stdlib directory, depends on both. */ fun parse(versionString: String): Versions { val parts = versionString.split(".") diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPath.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPath.kt index fb88bc157..0925b971d 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPath.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPath.kt @@ -19,7 +19,7 @@ import python.native.ffi.Py_DecRef * * That form has to be *parsed and compiled* on every call (`Python3.exec` goes through * `PyRun_String`, measured at ~11 µs for a trivial statement in `EvalCheckpointTest`), and it - * interpolates a filesystem path into Python source — a payload directory containing a quote or a + * interpolates a filesystem path into Python source, a payload directory containing a quote or a * backslash, which every Windows path does, either breaks the statement or changes what it does. * Going through the list object directly has neither problem. * @@ -34,7 +34,7 @@ import python.native.ffi.Py_DecRef * ### Ordering * * [prepend] puts the entry at `sys.path[0]`, which is where CPython itself puts the directory of - * the script being run. That is the right position for a payload — it *is* the application's own + * the script being run. That is the right position for a payload, it *is* the application's own * code, and an application must be able to ship a module that overrides one it inherits. The cost * of that choice is that a payload module named after a standard library one shadows it, so * [append] exists for an embedder who wants the opposite and knows why. @@ -101,7 +101,7 @@ object PythonPath { true } - /** `sys.path`, a **borrowed** reference — `PySys_GetObject` does not hand ownership over. */ + /** `sys.path`, a **borrowed** reference, `PySys_GetObject` does not hand ownership over. */ private fun sysPathHoldingGIL(): NativePointer? = PySys_GetObject("path") private fun readEntriesHoldingGIL(path: NativePointer): List { diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPayload.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPayload.kt index 7018fb3d8..348584356 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPayload.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPayload.kt @@ -13,7 +13,7 @@ package python.multiplatform.env * `toolchain`'s `49da1d8` then delivered a payload into the desktop jar's root and the APK's assets * and recorded the same finding from the other side: nothing reads it there. * - * The only written contract is the producing side's — `pypackpack`'s `ResourceBundler` KDoc: + * The only written contract is the producing side's, `pypackpack`'s `ResourceBundler` KDoc: * * > **Payload root is `python/`.** The bundle directory holds a `python/` subdirectory intended to * > be placed on `sys.path` verbatim (staged into Android assets, an iOS resource directory, or a @@ -25,20 +25,20 @@ package python.multiplatform.env * ### Where in start-up this happens, and why it cannot be earlier * * `Python3.initialize()` calls [installStagedRoots] immediately after `Py_Initialize()` has - * returned and the main thread state has been parked, before it hands control back to its caller — + * returned and the main thread state has been parked, before it hands control back to its caller, * so before any `PyImport_ImportModule` this library can reach. * * It cannot happen earlier, and that is a property of CPython rather than a choice: **`sys.path` * does not exist until `Py_Initialize()` has built it.** `PySys_GetObject("path")` before that - * point has no interpreter to read from. The two mechanisms that *do* run before initialisation — + * point has no interpreter to read from. The two mechanisms that *do* run before initialisation, * `PYTHONHOME` (an environment variable read by `getenv(3)`) and [PythonHomeCheck] (a filesystem - * probe of that variable) — are both about the standard library, and neither has a place to put a + * probe of that variable), are both about the standard library, and neither has a place to put a * consumer directory. So the payload is strictly a post-`Py_Initialize` step, and the useful * question is only whether it happens before the caller can import anything. It does. * * The alternative considered was `PYTHONPATH`, which *is* read at initialisation and would need no * post-step. It was rejected: a JVM cannot set an environment variable for itself (see - * `desktopMain/README.md` — the same reason `PYTHONHOME` is set by the Gradle plugin as the + * `desktopMain/README.md`, the same reason `PYTHONHOME` is set by the Gradle plugin as the * process is launched), so on the platform this had to work on first it is not available at all. * * ### It stays automatic @@ -50,7 +50,7 @@ package python.multiplatform.env object PythonPayload { /** - * `python` — `ResourceBundler.PYTHON_ROOT`, the directory name its manifest records as + * `python`, `ResourceBundler.PYTHON_ROOT`, the directory name its manifest records as * `pythonRoot`, and what `toolchain`'s `PythonStagingLayout.PAYLOAD_ROOT` stages under on all * three platforms. */ @@ -59,7 +59,7 @@ object PythonPayload { /** * Whether `Python3.initialize()` puts discovered payload roots on `sys.path` for you. * - * Set it to false *before* initialising to take the job over — nothing re-reads it afterwards. + * Set it to false *before* initialising to take the job over, nothing re-reads it afterwards. */ var autoInstall: Boolean = true @@ -105,16 +105,16 @@ object PythonPayload { * The `Python3.initialize()` hook. Does nothing when [autoInstall] is off, and does its work at * most once per process. * - * The once-only flag is not an optimisation, though it is also that — discovery is filesystem + * The once-only flag is not an optimisation, though it is also that, discovery is filesystem * work on every platform and a class path scan on desktop. It is what lets * `Python3.initialize()` call this on the path where it *returns early* because the interpreter * was already up. That path is real and is exactly where a payload would otherwise be lost: * `Python3.isInitialized` is seeded from `Py_IsInitialized()`, so any component that reached - * `Py_Initialize()` first — Android's `PythonInstrumentationRunner` did precisely this, see - * `androidMain/README.md` — latches it to true and makes every later `initialize()` a no-op. + * `Py_Initialize()` first, Android's `PythonInstrumentationRunner` did precisely this, see + * `androidMain/README.md`, latches it to true and makes every later `initialize()` a no-op. * * Failures propagate rather than being swallowed. A payload that was staged but cannot be read - * is a broken build, and the alternative — starting anyway — produces a `ModuleNotFoundError` + * is a broken build, and the alternative, starting anyway, produces a `ModuleNotFoundError` * at whatever point the application first imports itself, with nothing left to say why. */ internal fun installStagedRootsOnStartup() { @@ -131,7 +131,7 @@ object PythonPayload { * can go on `sys.path` as they are. * * Returns an empty list when there is no payload, which is the normal state of an application that - * has not configured one. Implementations may have to *materialise* the directory first — a + * has not configured one. Implementations may have to *materialise* the directory first, a * classpath resource inside a jar and an entry in an APK's asset archive are both readable by the * platform and not by CPython's importer, which opens files with `open(2)`. */ diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/GILScope.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/GILScope.kt index 889e87f16..f58002b10 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/GILScope.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/GILScope.kt @@ -29,7 +29,7 @@ public class ThreadGILState(var state: Int = 0, var depth: Int = 0) { * release mark skips it entirely when no reference has been given back since the previous one, so * a workload that allocates nothing pays a field compare and nothing else. * - * Not inline, so that [withGIL] — which is — can reach the internal counters through it. + * Not inline, so that [withGIL], which is, can reach the internal counters through it. */ @PublishedApi internal fun maybeReachEvalCheckpoint(tState: ThreadGILState) { diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/Python3.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/Python3.kt index 48aa9cc63..82db23b49 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/Python3.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/Python3.kt @@ -181,8 +181,8 @@ object Python3 { * ### What this is instead of * * The body this replaces called `Py_RunMain()`, and the reason that is not a bug that can be - * patched is in the header: `Py_RunMain()` is declared in `cpython/pylifecycle.h` — a - * *lifecycle* function, and not part of the Limited API this binding otherwise targets — and + * patched is in the header: `Py_RunMain()` is declared in `cpython/pylifecycle.h`, a + * *lifecycle* function, and not part of the Limited API this binding otherwise targets, and * its contract is to run whatever `PyConfig.run_command`/`run_module`/`run_filename` names * **and then finalize Python**. There is no mode in which it leaves the runtime standing. So * it destroyed the interpreter it was asked to run a module in, while [isInitialized] still @@ -199,7 +199,7 @@ object Python3 { * * ### What it does * - * `runpy.run_module(moduleName, run_name="__main__", alter_sys=True)` — the standard library's + * `runpy.run_module(moduleName, run_name="__main__", alter_sys=True)`, the standard library's * own answer, and the machinery CPython's `-m` switch itself goes through * (`runpy._run_module_as_main` shares `_get_module_details`/`_run_code` with it). It touches * no lifecycle function, so nothing here can finalize anything. Concretely, `alter_sys=True` @@ -207,7 +207,7 @@ object Python3 { * * - `_TempModule("__main__")` installs a **fresh** module as `sys.modules["__main__"]` for the * duration and puts the previous one back in `__exit__`. The module therefore gets a clean - * `__main__` namespace, and the embedder's own `__main__` — the one [exec] writes into — is + * `__main__` namespace, and the embedder's own `__main__`, the one [exec] writes into, is * neither read nor written, and is restored whether the module returns or raises. * - `_ModifiedArgv0(spec.origin)` points `sys.argv[0]` at the module's file while it runs and * restores it afterwards, which is what `python -m` does. @@ -218,7 +218,7 @@ object Python3 { * including when the module raises. Two halves to that: * * - It has to be *set*, because `runpy`'s `_ModifiedArgv0.__enter__` reads `sys.argv[0]` - * before writing it. An embedded interpreter is not guaranteed to have a usable one — this + * before writing it. An embedded interpreter is not guaranteed to have a usable one, this * is the same soft spot the old body fell into from the other side. * - It has to be *restored*, because it is process-global state that the caller did not ask * to have rewritten. A module run is a nested activity here, not the process's reason for @@ -230,7 +230,7 @@ object Python3 { * ### Exceptions, and `sys.exit()` * * An exception escaping the module body propagates as a [PyException] carrying the real - * Python type, message and traceback, and the interpreter stays usable — a failing module is + * Python type, message and traceback, and the interpreter stays usable, a failing module is * an ordinary failure, not a poisoned runtime. * * `SystemExit` is deliberately **not** in that class. A module written to be run as `__main__` @@ -330,10 +330,10 @@ object Python3 { * The Python side of [runMain]. * * It is written in Python rather than assembled out of C API calls because every line of it - * is a `try`/`finally` or an `except` — restoring `sys.argv` on both paths, and telling + * is a `try`/`finally` or an `except`, restoring `sys.argv` on both paths, and telling * `SystemExit` apart from a real failure. Expressing that through the C API would mean * `PyErr_GetRaisedException` plus a `PyErr_GivenExceptionMatches` against a `SystemExit` this - * binding exposes no `PyExc_*` handle for, then re-raising by hand — more code, and all of it + * binding exposes no `PyExc_*` handle for, then re-raising by hand, more code, and all of it * in the one path that exists to report failures. * * What it deliberately does *not* do is catch anything but `SystemExit`: any other exception @@ -376,7 +376,7 @@ def __pmp_run_module__(mod_name, argv): /** * Compiles [RUN_MODULE_SOURCE] once into a private globals dict and keeps a strong reference - * to the resulting function, exactly as [checkpointCallableHoldingGIL] does — including the + * to the resulting function, exactly as [checkpointCallableHoldingGIL] does, including the * detail that a module-level `def` needs no `__builtins__` entry of its own, because frame * setup falls back to the interpreter's builtins when the globals mapping has none. * @@ -412,55 +412,55 @@ def __pmp_run_module__(mod_name, argv): /** * Runs [args] the way `python ` would parse and dispatch them, in an interpreter that - * **survives the call** — the same property [runMain] exists for, and for the same reason: + * **survives the call**, the same property [runMain] exists for, and for the same reason: * `Py_BytesMain(argc, argv)` is CPython's CLI `main`, it reaches `Py_RunMain()` * (`cpython/pylifecycle.h`), and that function's contract is to run and then finalize. There * is no mode in which it leaves the runtime standing, so it can never be this function's - * implementation — wiring up its `(int argc, char **argv)` marshalling would only have bought + * implementation, wiring up its `(int argc, char **argv)` marshalling would only have bought * a function with exactly the defect [runMain] was fixed to avoid. (ROADMAP §12 has the full * history: the marshalling used to be recorded as the blocker, but it was never the real one.) * * ### The decided, deliberately smaller scope * - * `Py_BytesMain` also *parses* the command line — `-c`, `-m`, a script path, `-`, and a long - * tail of single-letter flags — and reimplementing all of that against a `PyConfig` this + * `Py_BytesMain` also *parses* the command line, `-c`, `-m`, a script path, `-`, and a long + * tail of single-letter flags, and reimplementing all of that against a `PyConfig` this * binding does not carry (struct layout is exactly what the Stable ABI does not promise) is * not worth paying for options that mostly cannot mean anything here anyway. So this parses a * deliberately reduced subset of the same grammar: * - * - **`-c `** — runs `` as a statement sequence in the embedder's own, persistent - * `__main__` — the same namespace [exec] writes into. `sys.argv[0]` is `"-c"`, matching + * - **`-c `**, runs `` as a statement sequence in the embedder's own, persistent + * `__main__`, the same namespace [exec] writes into. `sys.argv[0]` is `"-c"`, matching * CPython (`python3 -c "import sys;print(sys.argv)" a b` → `['-c', 'a', 'b']`, verified * against the system interpreter while designing this). - * - **`-m `** — a straight delegation to [runMain], which already *is* `python -m`'s + * - **`-m `**, a straight delegation to [runMain], which already *is* `python -m`'s * implementation (`runpy.run_module(..., alter_sys=True)`). Nothing here duplicates it. - * - **a bare script path** — reads the file (through Python's own `open()`, not a new - * per-platform Kotlin file API — `nativeMain` has no filesystem abstraction of its own, and + * - **a bare script path**, reads the file (through Python's own `open()`, not a new + * per-platform Kotlin file API, `nativeMain` has no filesystem abstraction of its own, and * CPython already has one that works identically everywhere this binding runs) and runs it, * again in the embedder's persistent `__main__`. `sys.argv[0]` is the path exactly as - * given — CPython does not resolve it (`cd /tmp && python3 argvtest.py` → `sys.argv[0] == + * given, CPython does not resolve it (`cd /tmp && python3 argvtest.py` → `sys.argv[0] == * 'argvtest.py'`, not an absolute path; verified the same way). - * - **`--` followed by a path** — forces the next token to be read as a script path even if it + * - **`--` followed by a path**, forces the next token to be read as a script path even if it * looks like an option, mirroring CPython's own observed behaviour: `python3 -- -c args` * does *not* run `-c` as a command, it tries to open a file literally named `-c`. This binding * does not carry `-c`/`-m` support past a leading `--`, since CPython's own parser does not * either once option scanning has been told to stop. * - **`-`, and anything else starting with `-`** (`-E -I -S -s -B -O -OO -X -W -u -P` among - * them, per the design that fixed this scope) — refused **by name** rather than silently + * them, per the design that fixed this scope), refused **by name** rather than silently * ignored. `-` means "read the program from stdin", which an embedded interpreter has no * sane default for. The rest are `PyConfig` fields (`isolated`, `use_environment`, * `site_import`, `user_site_directory`, `write_bytecode`, `optimization_level`, `Xoptions`, * warning filters, unbuffered I/O, `safe_path`) that only mean anything **before** - * `Py_Initialize()` — this interpreter is already initialized by the time [runApp] is + * `Py_Initialize()`, this interpreter is already initialized by the time [runApp] is * called, so honouring them would mean either lying about what happened or silently doing * nothing, and the second one is exactly the defect this function used to have. Naming the * option in the refusal is deliberate: a caller who typed `-I` expecting isolation and got a * generic "unsupported" would not necessarily notice their isolation request was dropped. - * - **empty `args`** — refused the same way, since CPython's own default with nothing on the + * - **empty `args`**, refused the same way, since CPython's own default with nothing on the * command line is also to read from stdin (interactively, if it is a tty). * * Anything after the mode-selecting token (the command, the module name, or the script path) - * is passed through verbatim as `sys.argv[1:]` — no re-parsing, so an argument to the + * is passed through verbatim as `sys.argv[1:]`, no re-parsing, so an argument to the * caller's own script that happens to start with `-` is never mistaken for one of the options * above. * @@ -475,7 +475,7 @@ def __pmp_run_module__(mod_name, argv): * returning and the raising path. * * @param args the command-line arguments *after* the program name, e.g. `["-c", "print(1)"]` - * or `["script.py", "--flag"]` — the same slice `Py_BytesMain(argc, argv)` would see as + * or `["script.py", "--flag"]`, the same slice `Py_BytesMain(argc, argv)` would see as * `argv[1:]`. * @return the exit status: 0 for a clean run, otherwise whatever the program passed to * `sys.exit()`. @@ -540,10 +540,10 @@ def __pmp_run_module__(mod_name, argv): * a `SystemExit` handle this binding exposes no accessor for. * * `__pmp_run_file__` reads the script through Python's own `open()` rather than a Kotlin file - * API deliberately — `nativeMain` has no cross-platform filesystem abstraction of its own + * API deliberately, `nativeMain` has no cross-platform filesystem abstraction of its own * (`iosMain`/`artMain` diverge below it), and CPython already has one that behaves identically * on every target this binding runs on. `compile()` is given the raw `bytes` `open("rb")` - * returns, not a decoded `str`, so a PEP 263 encoding cookie in the script is still honoured — + * returns, not a decoded `str`, so a PEP 263 encoding cookie in the script is still honoured, * passing a `str` would have silently ignored one. */ private const val RUN_APP_SOURCE = """ @@ -661,7 +661,7 @@ def __pmp_run_file__(path, argv): /** * Calls [helper] with [leadingArgs] (converted to `str`) followed by [argv] (converted to a - * `list[str]`), and reads back the `int` it returns the same way [runMain] does — [helper]'s + * `list[str]`), and reads back the `int` it returns the same way [runMain] does, [helper]'s * only non-`SystemExit` failure mode is an escaped exception, which arrives here as * `PyObject_CallObject` returning `null` with the indicator set. * @@ -735,8 +735,8 @@ def __pmp_run_file__(path, argv): /** * CPython defers a set of housekeeping jobs to a *checkpoint* that only the evaluation loop - * reaches. `_Py_HandlePending` — called from the `_CHECK_PERIODIC` uop that begins every - * Python-level frame and closes every call instruction — is the single place that + * reaches. `_Py_HandlePending`, called from the `_CHECK_PERIODIC` uop that begins every + * Python-level frame and closes every call instruction, is the single place that * * - merges the free-threaded build's biased reference-counting queue * (`_PY_EVAL_EXPLICIT_MERGE_BIT` → `_Py_brc_merge_refcounts`), @@ -759,24 +759,24 @@ def __pmp_run_file__(path, argv): * reason this is not done from the cleaner: the cleaner owns nothing, so a checkpoint taken * there would drain an empty queue while running Python on a thread whose only job is to hand * references back. Use [PyGC_Collect] instead if you need to reclaim on behalf of some other - * thread — it stops the world and merges every thread's queue, at a cost proportional to the + * thread, it stops the world and merges every thread's queue, at a cost proportional to the * heap rather than to the queue. * - * **This is not only a free-threading fix.** `_PY_GC_SCHEDULED_BIT` — the bit `_Py_ScheduleGC` - * sets on every allocation that crosses a generation threshold — is cleared by the same + * **This is not only a free-threading fix.** `_PY_GC_SCHEDULED_BIT`, the bit `_Py_ScheduleGC` + * sets on every allocation that crosses a generation threshold, is cleared by the same * `_Py_HandlePending` this reaches. That scheduling has been how allocation triggers a cyclic * collection since 3.12, on *both* builds. An embedder driving CPython purely through the C * API therefore never runs the cyclic collector at all, GIL or not: reference cycles the * collector would otherwise break simply accumulate. Calling this reclaims them exactly as it - * merges the free-threaded queue — one call, one checkpoint, both jobs. See + * merges the free-threaded queue, one call, one checkpoint, both jobs. See * `docs/investigations/gc-scheduling-investigation.md` §1 and §6. * * The trade-off is what runs at the checkpoint, not just what it reclaims: any `__del__`, * weakref callback or pending call attached to something the collector or the queue was - * holding can execute here, inside whatever `withGIL` scope happened to trip the checkpoint — + * holding can execute here, inside whatever `withGIL` scope happened to trip the checkpoint, * a scope its caller did not ask to yield control from. Measured reentrant-safe (a `__del__` * that calls back into Kotlin mid-collection completes without deadlocking, looping or - * crashing — `GCSchedulingMeasurementTest.testReentrancyDuringCheckpoint`), but it is still a + * crashing, `GCSchedulingMeasurementTest.testReentrancyDuringCheckpoint`), but it is still a * side effect the caller did not request, and is why [autoDrainInterval] defaults to off on * the GIL build rather than always on. * @@ -803,7 +803,7 @@ def __pmp_run_file__(path, argv): * released since the previous one, so a workload that never drops a wrapper never pays. * * Defaults to on for free-threaded builds, where deferred release is the defect described on - * [drainPendingReleases] — a build with the global lock frees a *plain* reference the + * [drainPendingReleases], a build with the global lock frees a *plain* reference the * cleaner's `Py_DecRef` immediately, with no queue to drain, so that half of the checkpoint's * job really is pure overhead there. * @@ -817,7 +817,7 @@ def __pmp_run_file__(path, argv): * a `withGIL` scope exit the caller never asked to yield from. Measured cost when on: ~5.5 ns * per outermost scope (~2.9% of the ~190 ns floor), amortised over this interval. Turn it on * explicitly, call [drainPendingReleases] to force one checkpoint without changing this, or - * call [PyGC_Collect] directly for an immediate, unconditional collection — [drainPendingReleases] + * call [PyGC_Collect] directly for an immediate, unconditional collection, [drainPendingReleases] * and `PyGC_Collect` both work regardless of this setting. */ var autoDrainInterval: Int = if (BuildConfig.pythonFreeThreaded) DEFAULT_AUTO_DRAIN_INTERVAL else 0 @@ -881,7 +881,7 @@ def __pmp_run_file__(path, argv): internal fun reachEvalCheckpointHoldingGIL() { // Do not disturb an error indicator its owner has not read yet. Several call sites read // the indicator *after* their `withPython { ... }` scope has closed (see - // `PyObject.getAttr`), and entering the eval loop can replace it — a pending signal + // `PyObject.getAttr`), and entering the eval loop can replace it, a pending signal // handler or a finaliser raising is enough. if (PyErr_Occurred() != null) { CheckpointCounter.skipped++ @@ -894,7 +894,7 @@ def __pmp_run_file__(path, argv): val result = PyObject_CallNoArgs(callable) if (result == null) { // The body is `pass`, so nothing raised here belongs to the caller: it came out of - // the checkpoint itself — a pending signal, a pending call, a finaliser. There is no + // the checkpoint itself, a pending signal, a pending call, a finaliser. There is no // caller to hand it to, and leaving it set would surface as a spurious failure in // whatever ran next, so it is cleared and counted. PyErr_Clear() diff --git a/python-multiplatform/src/commonMain/kotlin/python/native/ffi/EmbedAPI.kt b/python-multiplatform/src/commonMain/kotlin/python/native/ffi/EmbedAPI.kt index 5903e9daf..d73267082 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/native/ffi/EmbedAPI.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/native/ffi/EmbedAPI.kt @@ -288,8 +288,8 @@ expect inline fun Py_GetBuildInfo(): String? * * This is no longer bound to a C symbol. `ceval.h` marks it `Py_DEPRECATED(3.9)`, which is a * compiler-enforced deprecation and therefore a removal schedule, and since 3.9 the C function's - * body has been empty. A Kotlin no-op is not an approximation of it — it is the same behaviour with - * one fewer FFI crossing — so nothing is lost by taking it out of the `expect`/`actual` surface, + * body has been empty. A Kotlin no-op is not an approximation of it, it is the same behaviour with + * one fewer FFI crossing, so nothing is lost by taking it out of the `expect`/`actual` surface, * and callers written against older embeddings still compile. `EmbedApiSurfaceTest` derives the * `Py_DEPRECATED` set from the bundled headers and fails if this name reappears in the surface. */ @@ -1287,7 +1287,7 @@ expect fun PyImport_ImportModule(name: String): NativePointer? * [PyImport_ImportModule] instead. * * This is no longer bound to a C symbol. CPython 3.15 removed the entry point, and since 3.3 - * it had been an exact alias of `PyImport_ImportModule` — not a distinct code path — so + * it had been an exact alias of `PyImport_ImportModule`, not a distinct code path, so * forwarding here is behaviour-preserving on every supported version rather than an * approximation. Keeping the name costs nothing at the FFI boundary and lets existing source * compile through the version bump; `EmbedApiLowLevelTest` pins it to the function it aliases. @@ -1537,7 +1537,7 @@ expect fun PyImport_GetImporter(path: NativePointer): NativePointer? * the module is not found, and "-1" with an exception set if the * initialization failed. To access the imported module on a * successful load, use "PyImport_ImportModule()". (Note the misnomer - * — this function would reload the module if it was already + *, this function would reload the module if it was already * imported.) * * Added in version 3.3. @@ -4133,16 +4133,16 @@ expect fun PyWeakref_NewProxy(ob: NativePointer, callback: NativePointer): Nativ * with [Py_DecRef]. Returns `null` if the referent is no longer live, and also `null` if *ref* * is not a weak reference object. * - * The C function distinguishes those two outcomes by its `int` return — `0` for a dead referent - * and `-1` for an error — while writing the object through an out-parameter. An out-parameter + * The C function distinguishes those two outcomes by its `int` return, `0` for a dead referent + * and `-1` for an error, while writing the object through an out-parameter. An out-parameter * does not survive this FFI boundary (Android requires primitives only across JNI), so both * arrive here as `null`. No information is lost: the error case sets the error indicator and the * dead case does not, so [PyErr_Occurred] separates them. `EmbedApiLowLevelTest` asserts that a * dead referent leaves the indicator clear. * * This replaces `PyWeakref_GetObject`, which CPython removed in 3.15. The two are not - * interchangeable: the removed function returned a *borrowed* reference and `Py_None` — not - * `null` — for a dead referent. `PyWeakref_GetRef` exists in 3.13 and 3.14 as well, so binding + * interchangeable: the removed function returned a *borrowed* reference and `Py_None`, not + * `null`, for a dead referent. `PyWeakref_GetRef` exists in 3.13 and 3.14 as well, so binding * it does not cost support for the version this build defaults to. */ expect fun PyWeakref_GetRef(ref: NativePointer): NativePointer? diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/VersionsTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/VersionsTest.kt index a1d992638..c64073c1b 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/VersionsTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/VersionsTest.kt @@ -8,7 +8,7 @@ import kotlin.test.assertTrue * Pins the version-string arithmetic that names the CPython artefacts on disk. * * The interesting case is the free-threaded flavour. A free-threaded install renames *everything* - * the runtime looks up by name — `libpython3.14t.dylib`, `lib/python3.14t/`, `bin/python3.14t` — + * the runtime looks up by name, `libpython3.14t.dylib`, `lib/python3.14t/`, `bin/python3.14t`, * and it does not ship the un-suffixed names at all. Getting [Versions.taggedVersionString] wrong * therefore does not degrade gracefully: `manager.loadLibPython` throws `UnsatisfiedLinkError` for * a file that was downloaded and is sitting right there under a different name. diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/env/InterpreterAvailabilityTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/env/InterpreterAvailabilityTest.kt index f43c7fc42..f3cd93be7 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/env/InterpreterAvailabilityTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/env/InterpreterAvailabilityTest.kt @@ -16,7 +16,7 @@ import kotlin.test.assertTrue * pointer-level measurements can run, and every functional test has to be skipped. * * The embedded `Python.framework` contains no standard library, so `PYTHONHOME` must point at - * a prefix holding `lib/python3.14`. The build wires that up for simulator test runs — see + * a prefix holding `lib/python3.14`. The build wires that up for simulator test runs, see * `extractIosSimulatorStdlib` in `build.gradle.kts`. */ class InterpreterAvailabilityTest { diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PythonTestFixture.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PythonTestFixture.kt index a31137892..6e672c57d 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PythonTestFixture.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PythonTestFixture.kt @@ -85,7 +85,7 @@ object PythonTestFixture { */ fun mainGlobals(): PyObject = Python3.withPython { // These reach the C API directly, so they need the GIL like any other call. The - // initialising thread no longer holds it — see Python3.initialize. + // initialising thread no longer holds it, see Python3.initialize. // PyImport_AddModule: *borrowed* reference, only read below and never wrapped. val modulePtr = python.multiplatform.ffi.Python3.withPython { PyImport_AddModule("__main__") } ?: error("Could not get __main__ module") // PyObject_GetAttrString: *new* reference, adopted by the wrapper. diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/RunAppTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/RunAppTest.kt index 340d646a8..8e87c76d0 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/RunAppTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/RunAppTest.kt @@ -7,7 +7,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertTrue /** - * [Python3.runApp] — the decided, deliberately reduced subset of CPython's command-line parser + * [Python3.runApp], the decided, deliberately reduced subset of CPython's command-line parser * that an *embedder* can drive without calling `Py_BytesMain`/`Py_RunMain` (which finalize the * interpreter; see ROADMAP §12/§14b item 10 and [RunMainTest]'s header for why those cannot be the * implementation of anything called more than once). diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/RunMainTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/RunMainTest.kt index e163d9771..04ceb35dd 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/RunMainTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/RunMainTest.kt @@ -8,13 +8,13 @@ import kotlin.test.assertTrue import kotlin.time.TimeSource /** - * [Python3.runMain] — running a module as `__main__` in an interpreter that has to **survive** + * [Python3.runMain], running a module as `__main__` in an interpreter that has to **survive** * the call. * * The design question these tests fix an answer to is recorded in ROADMAP §12. The short form: * * - `Py_RunMain()` (and `Py_BytesMain()`, which reaches it) **always finalizes the interpreter** - * — `cpython/pylifecycle.h` declares it, and it is not in the Limited API at all. Neither can + * , `cpython/pylifecycle.h` declares it, and it is not in the Limited API at all. Neither can * be the implementation of anything an embedder calls more than once. * - `PyImport_ImportModule()` runs the module body under its *own* `__name__` and caches it in * `sys.modules`, so `if __name__ == "__main__":` never fires and a second call is a no-op. @@ -25,7 +25,7 @@ import kotlin.time.TimeSource * `alter_sys=True` is the part that makes the module observe a `__main__`-shaped world: * `runpy._run_module_code` runs the code in a **fresh** module namespace, installs it as * `sys.modules["__main__"]` for the duration, points `sys.argv[0]` at the module's origin, and - * restores both in `__exit__` — so the `__main__` this suite's other tests write globals into is + * restores both in `__exit__`, so the `__main__` this suite's other tests write globals into is * put back whether the module returns or raises. [theInterpreterAndItsMainNamespaceSurviveRunMain] * is the guard for that specifically. */ @@ -301,7 +301,7 @@ class RunMainTest { * `runMain` is not in the same cost class as `exec` and must not be reached for in a loop: * every call re-runs the import system's `find_spec`, recompiles or reloads the module's code * object and builds a fresh namespace. This prints the measured per-call cost of both rather - * than asserting a threshold — the number is machine- and load-dependent, and the point is + * than asserting a threshold, the number is machine- and load-dependent, and the point is * that it is visible in the log next to `exec`'s, not that it sits under some bound. * * The one assertion is directional and holds by construction (a module run does strictly more @@ -359,7 +359,7 @@ class RunMainTest { * A meta-path finder serving module source out of a dict, installed once. * * `runpy` goes through `importlib.util.find_spec` and then `spec.loader.get_code(...)` - * (runpy.py, `_get_module_details`), so a plain `sys.modules` entry is not enough — the + * (runpy.py, `_get_module_details`), so a plain `sys.modules` entry is not enough, the * spec has to be able to produce a code object. This is the smallest thing that can, and * being in-memory it works identically on every target this suite runs on. */ diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ref/DoubleReleaseTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ref/DoubleReleaseTest.kt index 9ddbdba6f..892842e94 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ref/DoubleReleaseTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ref/DoubleReleaseTest.kt @@ -14,7 +14,7 @@ import kotlin.test.assertTrue * `GCLeakTest` asks whether a dropped wrapper's reference eventually comes back, and every one of * its assertions is a *lower* bound: the count fell, a cleaner ran, the delta is positive. A * mechanism that gives the same reference back **twice** satisfies all of them. That is not a - * hypothetical failure in this repository — a wrapper taking ownership of a pointer it had only + * hypothetical failure in this repository, a wrapper taking ownership of a pointer it had only * borrowed produced exactly one extra release per wrapper, corrupted CPython's free lists, and * surfaced as a segfault in a test that had nothing to do with the code at fault. It is the reason * ROADMAP §1 looked unfixable for three attempts. @@ -27,7 +27,7 @@ import kotlin.test.assertTrue * The target carries [BALLAST] extra references throughout. Without them, a double free of * [WRAPPERS] references would drive the count to zero, CPython would free an object this test is * still holding, and the failure would arrive as a crash somewhere else instead of as a readable - * assertion here — which is precisely the failure mode being guarded against, and a test that + * assertion here, which is precisely the failure mode being guarded against, and a test that * reproduces it uncontrolled is worth less than one that reports it. */ private const val WRAPPERS = 100 @@ -102,8 +102,8 @@ private fun takeAndDrop(target: PyObject, count: Int) { /** * A cross-thread counter for release actions. * - * `@Volatile` rather than an atomic because each instance is written by exactly one releaser — the - * cleaner thread for a dropped cleaner, the test thread for an explicit `close()` — so there is no + * `@Volatile` rather than an atomic because each instance is written by exactly one releaser, the + * cleaner thread for a dropped cleaner, the test thread for an explicit `close()`, so there is no * read-modify-write to lose. Two counters, never one shared between two cleaners, for that reason. */ private class ReleaseCount { diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ref/EvalCheckpointTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ref/EvalCheckpointTest.kt index b4e3df7f7..3ef23f66a 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ref/EvalCheckpointTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ref/EvalCheckpointTest.kt @@ -15,7 +15,7 @@ import kotlin.time.measureTime /** * What an embedder owes CPython that a script never has to think about (ROADMAP §9). * - * CPython defers work to a checkpoint that only the evaluation loop reaches — `_Py_HandlePending`, + * CPython defers work to a checkpoint that only the evaluation loop reaches, `_Py_HandlePending`, * called from the `_CHECK_PERIODIC` uop that opens every Python-level frame. Nothing in the C API * reaches it on its own. A program that drives the interpreter entirely through `PyObject_Call` * and friends therefore never merges the free-threaded build's biased reference-counting queue, @@ -296,7 +296,7 @@ class EvalCheckpointTest { /** * A checkpoint must be cheap enough to ride on ordinary calls, which is the whole reason it is - * a cached function object and not `exec("pass")` — the latter recompiles a module every time. + * a cached function object and not `exec("pass")`, the latter recompiles a module every time. * * The assertion is the ordering, not an absolute number: absolute nanoseconds are a property * of the machine, but "a cached call beats recompiling" is a property of the design and would @@ -343,9 +343,9 @@ class EvalCheckpointTest { } /** - * The automatic path must not fire from a cleaner. It is suppressed there for two reasons — + * The automatic path must not fire from a cleaner. It is suppressed there for two reasons, * the queue that needs merging belongs to the owning thread, and running Python on a cleaner is - * what ROADMAP §1 turned into a deadlock — and neither is visible from the outside, so it is + * what ROADMAP §1 turned into a deadlock, and neither is visible from the outside, so it is * pinned here by counting checkpoints while only cleaners are running. */ @Test diff --git a/python-multiplatform/src/commonTest/kotlin/python/native/ffi/EmbedApiLowLevelTest.kt b/python-multiplatform/src/commonTest/kotlin/python/native/ffi/EmbedApiLowLevelTest.kt index 93735a4df..b0051d7e0 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/native/ffi/EmbedApiLowLevelTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/native/ffi/EmbedApiLowLevelTest.kt @@ -14,7 +14,7 @@ import python.multiplatform.ffi.PythonTestFixture * * ### Why this file exists * - * Every other test under `commonTest` exercises the assembled layer — `Python3`, `PyObject`, + * Every other test under `commonTest` exercises the assembled layer, `Python3`, `PyObject`, * the collections. That is the right default, but it means a broken individual binding is only * ever seen through whatever the object model happens to call, and only in the combinations it * happens to use. Android demonstrated the cost of the mirror-image gap: its instrumented tests @@ -26,7 +26,7 @@ import python.multiplatform.ffi.PythonTestFixture * where it found it. * * The reference conventions are the part worth being explicit about, because getting one wrong - * is silent in both directions — one too few leaks, one too many frees an object still in use + * is silent in both directions, one too few leaks, one too many frees an object still in use * and the crash lands somewhere unrelated. */ class EmbedApiLowLevelTest { diff --git a/python-multiplatform/src/desktopMain/README.md b/python-multiplatform/src/desktopMain/README.md index a09a2b612..a8bd1e185 100644 --- a/python-multiplatform/src/desktopMain/README.md +++ b/python-multiplatform/src/desktopMain/README.md @@ -1,4 +1,4 @@ -# desktopMain — rules +# desktopMain, rules JVM on macOS, Linux and Windows, reaching CPython through Project Panama. @@ -11,7 +11,7 @@ JVM on macOS, Linux and Windows, reaching CPython through Project Panama. inline fun PyList_Size(list: Long): Long = PyList_SizeHandle.invokeExact(list) as Long This is not a micro-optimisation. Converting the bulk of these wrappers took a real C API call -(`PyList_Size` on `sys.path`) from **1015.95 ns to 2.65 ns** — about 380x. Before that, desktop +(`PyList_Size` on `sys.path`) from **1015.95 ns to 2.65 ns**, about 380x. Before that, desktop was the slowest FFI path in the project by two orders of magnitude, on the machine that is fastest at everything else. @@ -23,7 +23,7 @@ proves nothing; the tests are the check. Both are 8 bytes and travel in the same register on a 64-bit ABI. `ADDRESS` makes Panama hand back a `MemorySegment`, which then has to be unwrapped through per-call argument and return -filters — and those filters were reflective `MethodHandle` invocations building a segment on +filters, and those filters were reflective `MethodHandle` invocations building a segment on every call. `JAVA_LONG` deletes them, and it is what makes the handle type `(long, long, ...) -> long` so that `invokeExact` is reachable at all. @@ -31,14 +31,14 @@ every call. `JAVA_LONG` deletes them, and it is what makes the handle type The backend resolves `java.lang.foreign` or `jdk.incubator.foreign` reflectively so the code compiles against any JDK without importing either. That is correct and should stay. What must -never happen is reflection reaching the per-call path — that was the other half of the 1015 ns. +never happen is reflection reaching the per-call path, that was the other half of the 1015 ns. Resolve handles once per symbol at startup; after that a call is a handle invocation and nothing else. ## String ownership `withUtf8 { }` allocates, passes the address, and frees in a `finally`. Values owned by CPython -(`Py_GetVersion`, `PyUnicode_AsUTF8`) are read and **not** freed — freeing them is a +(`Py_GetVersion`, `PyUnicode_AsUTF8`) are read and **not** freed, freeing them is a use-after-free. Desktop's string marshalling costs about 200 ns per string, measured at 606.74 ns (10.5%) of a @@ -49,7 +49,7 @@ off-heap without crossing the boundary at all. Composing binder operations natively was measured and rejected for desktop: it would buy ~10% and cost macOS/Linux Kotlin/Native targets, a packaging path, and permanent Windows asymmetry. -See ROADMAP §6. Android is different — there the same measurement gives 1.8x. +See ROADMAP §6. Android is different, there the same measurement gives 1.8x. ## Never hand-edit `reachability-metadata.json` @@ -59,7 +59,7 @@ the non-derivable half (`reflection`, and any `resources` globs) is checked in, `python-multiplatform/native-image/reachability-metadata.base.json`. Under GraalVM's closed world, a `FunctionDescriptor` that is not declared there does not fail the -build — it fails on the first call, with `MissingForeignRegistrationError`. So every new descriptor +build, it fails on the first call, with `MissingForeignRegistrationError`. So every new descriptor has to reach the metadata by construction: - A new CPython entry point is a `find("sym", ReturnType, Params...)` line in `bindings.kt`, and @@ -69,14 +69,14 @@ has to reach the metadata by construction: parameterTypes = [...])` marker directly above it; its descriptor is assembled reflectively and cannot be read out of the code. The generator refuses to run without the marker. -`ReachabilityMetadataTest` checks the shipped file from the other direction — against the -`MethodHandle.type()`s the loaded classes actually built — so a parser that stops recognising a +`ReachabilityMetadataTest` checks the shipped file from the other direction, against the +`MethodHandle.type()`s the loaded classes actually built, so a parser that stops recognising a declaration form surfaces as a failing test, not as a binary that dies at a customer. ## The stdlib is staged by the Gradle plugin, and `PYTHONHOME` is set when the JVM starts `desktopJar` carries `libpython` for four platforms under `lib//` and **no standard -library** — verified, not assumed: the published jar's `lib/` tree is 14 entries, all of them +library**, verified, not assumed: the published jar's `lib/` tree is 14 entries, all of them shared libraries. `Py_Initialize()` therefore dies with `Failed to import encodings module` until `PYTHONHOME` names a prefix that has one. @@ -86,13 +86,13 @@ cannot set an environment variable for itself.** CPython reads `PYTHONHOME` with `System.getenv` is an immutable snapshot taken at start-up, and mutating it (reflectively or otherwise) does not touch the native environment CPython reads. Android's `PythonBootstrap` calls `Os.setenv`; there is no such call here. A runtime helper would have to reach libc `setenv` -through Panama — a different symbol on Windows (`_putenv_s`) — and would then be setting a value +through Panama, a different symbol on Windows (`_putenv_s`), and would then be setting a value `PythonHomeCheck` could no longer read. Setting it as the child process is launched, which is what Gradle's `environment(...)` does and what this repo's own `desktopTest` already does, leaves CPython and `PythonHomeCheck` reading the same value from the same place. The staged prefix is shared per machine (under the Gradle user home), keyed by version + upstream -release + platform, and stamped after the last extracted byte — so an interrupted extraction is +release + platform, and stamped after the last extracted byte, so an interrupted extraction is never mistaken for a finished one. See ROADMAP §15h. ## A packaged app names its prefix in-process, with `Py_SetPythonHome` (SPEC L-9) @@ -115,7 +115,7 @@ The section above is about `PYTHONHOME` **the environment variable**. A packaged `Py_SetPythonHome` is deprecated since 3.11 in favour of `PyConfig.home`, but `PyConfig` is a struct whose layout the Stable ABI does not promise, and the function is in the Stable ABI, so its -symbol stays exported. It takes `wchar_t *` — UTF-32 here, UTF-16 on Windows — encoded by hand in +symbol stays exported. It takes `wchar_t *`, UTF-32 here, UTF-16 on Windows, encoded by hand in `encodeWideString` rather than through `Py_DecodeLocale`, which before pre-initialisation decodes with the C locale (the ANSI code page on Windows). The buffer is never freed (a few hundred bytes, once per process): the API asks for static storage. @@ -152,11 +152,11 @@ same completion-marker discipline `PythonBootstrap.stageStdlib` uses, and the re **Do not match on the name `python/`.** This library's own top-level Kotlin package *is* `python`, so `python/multiplatform` and `python/native` class files answer `getResources("python/")` on every -classpath this library is on — the build output directory during a Gradle build, and +classpath this library is on, the build output directory during a Gradle build, and `python-multiplatform.jar` itself once published. Name-based discovery therefore puts a directory of `.class` files at `sys.path[0]` in every consuming application, and extracts the whole library jar to do it. Observed, not theorised: the first run of `PythonPayloadTest` returned -`build/classes/kotlin/desktop/{main,test}/python`. Discovery asks what is *in* the root instead — at +`build/classes/kotlin/desktop/{main,test}/python`. Discovery asks what is *in* the root instead, at least one immediate child that is a `.py`/`.pyc`, or a directory with an `__init__` in it, which is exactly what `ResourceBundler` produces. @@ -175,6 +175,6 @@ does not exist until `Py_Initialize()` has built it. resources into the image heap: that copy was 19.4 MB of a 35.4 MB binary, written straight back out to a temp file at startup. The library resource is therefore *not* registered, and `manager.kt` falls back to `$PYTHONHOME/lib/` (`PYTHON_MULTIPLATFORM_LIBPYTHON` overrides an absolute path). -`PYTHONHOME` has to point at a real prefix regardless — `Py_Initialize` needs the matching stdlib — +`PYTHONHOME` has to point at a real prefix regardless, `Py_Initialize` needs the matching stdlib, so nothing is lost, and the binary is 16.7 MB. Loading the library out of the same prefix as the stdlib also removes the version-skew hazard the jar copy had. diff --git a/python-multiplatform/src/desktopMain/kotlin/python/multiplatform/env/PythonPayload.desktop.kt b/python-multiplatform/src/desktopMain/kotlin/python/multiplatform/env/PythonPayload.desktop.kt index 24a4a2ac0..ef8189ca8 100644 --- a/python-multiplatform/src/desktopMain/kotlin/python/multiplatform/env/PythonPayload.desktop.kt +++ b/python-multiplatform/src/desktopMain/kotlin/python/multiplatform/env/PythonPayload.desktop.kt @@ -15,7 +15,7 @@ import java.util.zip.ZipEntry * * `toolchain`'s `stagePythonBundleDesktop` registers `build/pythonStaging/desktop` as a JVM * resource root, so `processResources` copies `python/...` to the root of the consumer's jar. That - * is a **classpath resource, not a filesystem path** — and CPython's importer opens modules with + * is a **classpath resource, not a filesystem path**, and CPython's importer opens modules with * `open(2)`, which cannot see inside a zip. So a jar payload has to be materialised before it can * go on `sys.path`; a payload sitting on the classpath as a plain directory (a Gradle `run`, an * exploded application image, an IDE run configuration) already is one and is used where it lies. @@ -42,7 +42,7 @@ internal object ClasspathPayload { * * The context class loader is what an application server, a Gradle test worker or any other * container sets to "the class path of the thing currently running", and it is the only one a - * test can substitute — which is what makes the start-up path testable at all rather than only + * test can substitute, which is what makes the start-up path testable at all rather than only * its inner half. This class's own loader is the fallback and covers the ordinary case where * the library and the payload ship on the same class path. */ @@ -55,7 +55,7 @@ internal object ClasspathPayload { * `/python-multiplatform-payload`, overridable with the `python.multiplatform.payload.cache` * system property. * - * The temporary directory rather than a user cache directory because the content is derived — + * The temporary directory rather than a user cache directory because the content is derived, * losing it costs one re-extraction, and the stamp key makes a stale copy impossible rather * than merely unlikely. */ @@ -113,7 +113,7 @@ internal object ClasspathPayload { * `python/` as the class loader sees it, asked for both with and without the trailing slash. * * A jar can only answer either of these from a `python/` **directory entry**, and not every - * archive has one — `JarOutputStream` writes directory entries only if asked. [classPathJarFiles] + * archive has one, `JarOutputStream` writes directory entries only if asked. [classPathJarFiles] * is the fallback for that case; asking here first is what keeps the common case a single * lookup instead of a class path scan. */ @@ -145,14 +145,14 @@ internal object ClasspathPayload { * collision nobody had noticed: this library's own top-level Kotlin package is `python`.** * `python.multiplatform` and `python.native` compile to class files under `python/multiplatform` * and `python/native`, so *every* class path this library is on already answers - * `getResources("python/")` — with the library's own class output directory during a Gradle + * `getResources("python/")`, with the library's own class output directory during a Gradle * build, and with a `jar:` URL into `python-multiplatform.jar` once it is published. Name-based * discovery would therefore have put a directory of `.class` files at `sys.path[0]` in every * application that used this library, and extracted the entire library jar to do it. Observed, * not theorised: this returned * `build/classes/kotlin/desktop/{main,test}/python` on the very first run of the suite. * - * The test is what `ResourceBundler` itself produces — its `findPythonPackages` bundles exactly + * The test is what `ResourceBundler` itself produces, its `findPythonPackages` bundles exactly * those immediate children that are directories holding an `__init__.py`, plus whatever the * `src/` overlay adds. So: at least one immediate child that is a module file, or a * directory with an `__init__` in it. A `python/` holding only `.class` files fails it, and so @@ -185,7 +185,7 @@ internal object ClasspathPayload { private fun String.isModuleFileName(): Boolean = endsWith(".py") || endsWith(".pyc") - /** `bytecode`-level bundles ship `.pyc` beside (or instead of) the `.py` — see `ResourceBundler` assumption 6. */ + /** `bytecode`-level bundles ship `.pyc` beside (or instead of) the `.py`, see `ResourceBundler` assumption 6. */ private val INIT_NAMES = listOf("__init__.py", "__init__.pyc") private fun jarOf(url: URL): JarFile? = runCatching { @@ -195,8 +195,8 @@ internal object ClasspathPayload { /** * The jars on [loader]'s own class path, for the archives [payloadUrls] cannot see into. * - * `URLClassLoader` is asked directly; for everything else — including the `AppClassLoader` a - * plain `java -jar` run gets — `java.class.path` is the only description of the class path the + * `URLClassLoader` is asked directly; for everything else, including the `AppClassLoader` a + * plain `java -jar` run gets, `java.class.path` is the only description of the class path the * JVM exposes. Both are walked up the parent chain, because a payload jar is as likely to be * on a parent loader as on the one that loaded this class. */ @@ -233,8 +233,8 @@ internal object ClasspathPayload { * Materialises the `python/` subtree of [jar] under [cacheDir], or returns null when it holds * none. * - * The directory is named by a digest of *what is in the archive* — every payload entry's name, - * size and CRC — rather than by the jar's path or timestamp. Two consequences, both wanted: + * The directory is named by a digest of *what is in the archive*, every payload entry's name, + * size and CRC, rather than by the jar's path or timestamp. Two consequences, both wanted: * a rebuilt jar with identical content reuses the extraction, and a jar rewritten in place with * different content cannot be mistaken for it. The CRCs come from the archive's central * directory, so the key costs no reading of file content at all. diff --git a/python-multiplatform/src/desktopMain/kotlin/python/native/ffi/Panama.kt b/python-multiplatform/src/desktopMain/kotlin/python/native/ffi/Panama.kt index d16c8a634..21054441e 100644 --- a/python-multiplatform/src/desktopMain/kotlin/python/native/ffi/Panama.kt +++ b/python-multiplatform/src/desktopMain/kotlin/python/native/ffi/Panama.kt @@ -25,7 +25,7 @@ internal object Panama { // ---- public surface used by bindings.kt ---- - /** Pointer type carrier for MethodType signatures — always Long. */ + /** Pointer type carrier for MethodType signatures, always Long. */ val POINTER_TYPE: Class<*> = Long::class.javaPrimitiveType!! /** The null-pointer sentinel as a Long. */ @@ -649,7 +649,7 @@ internal object Panama { return h } - // Static adapters called via MethodHandle — must use Any to avoid importing MemorySegment + // Static adapters called via MethodHandle, must use Any to avoid importing MemorySegment @JvmStatic fun modernSegmentToLong(seg: Any?): Long { if (seg == null) return 0L diff --git a/python-multiplatform/src/desktopMain/kotlin/python/native/ffi/bindings.kt b/python-multiplatform/src/desktopMain/kotlin/python/native/ffi/bindings.kt index 62030d26e..e09e7c1a6 100644 --- a/python-multiplatform/src/desktopMain/kotlin/python/native/ffi/bindings.kt +++ b/python-multiplatform/src/desktopMain/kotlin/python/native/ffi/bindings.kt @@ -927,7 +927,7 @@ inline fun PyDict_GetItemString(p: Long, key: String): Long { fun PyObject_GC_UnTrack(op: Long) = PyObject_GC_UnTrackHandle.invokeExact(op) as Unit init { - val P = Panama.POINTER_TYPE // Long.TYPE — represents a native pointer + val P = Panama.POINTER_TYPE // Long.TYPE, represents a native pointer // Load the native library first manager.loadLibPython() diff --git a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PythonPayloadTest.kt b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PythonPayloadTest.kt index 2fa2c2fc9..784599b01 100644 --- a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PythonPayloadTest.kt +++ b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/env/PythonPayloadTest.kt @@ -249,7 +249,7 @@ class PythonPayloadTest { * **This library's own top-level Kotlin package is `python`.** * * `python.multiplatform` compiles to class files under `python/multiplatform`, so every class path this - * library is on answers `getResources("python/")` — with `build/classes/kotlin/desktop/main/python` + * library is on answers `getResources("python/")`, with `build/classes/kotlin/desktop/main/python` * during a Gradle build, and with a `jar:` URL into `python-multiplatform.jar` once published. * Name-based discovery therefore puts a directory of `.class` files at `sys.path[0]` in every * application that uses this library. It is not hypothetical: this test failed on the first run diff --git a/python-multiplatform/src/iosMain/README.md b/python-multiplatform/src/iosMain/README.md index 883b8e3a3..1516054ea 100644 --- a/python-multiplatform/src/iosMain/README.md +++ b/python-multiplatform/src/iosMain/README.md @@ -1,4 +1,4 @@ -# iosMain — rules +# iosMain, rules iOS-specific sources. Depends on `nativeMain`; CPython arrives as `Python.xcframework`. @@ -6,7 +6,7 @@ iOS-specific sources. Depends on `nativeMain`; CPython arrives as `Python.xcfram `Python.xcframework` contains the interpreter binary and headers and nothing else. `Py_Initialize()` needs `PYTHONHOME` to point at the stdlib on disk, and there are two different -ways it fails to if that's wrong — which one you get depends on *what's running*, not just on what +ways it fails to if that's wrong, which one you get depends on *what's running*, not just on what `PYTHONHOME` says, and only one of them is easy to diagnose. **A missing or empty prefix aborts.** `Py_Initialize()` calls `Py_FatalError()` and kills the @@ -16,10 +16,10 @@ exception, no interpreter left afterward, but at least it is fast and it says wh `python.multiplatform.env.PythonHomeCheck`) that catches this shape of misconfiguration and turns it into a catchable `IllegalStateException` naming the path instead. -**A `PYTHONHOME` under a sandboxed path can hang instead — with 0% CPU and nothing in the +**A `PYTHONHOME` under a sandboxed path can hang instead, with 0% CPU and nothing in the console.** Reproduced directly (ROADMAP, 2026-08-13, "PYTHONHOME on an external volume hangs the app instead of failing it"): the *installed simulator app* parked forever inside `open$NOCANCEL` -importing `encodings` from a path under `/Volumes/` — a blank window, no sandbox denial, no Python +importing `encodings` from a path under `/Volumes/`, a blank window, no sandbox denial, no Python error anywhere in the log. `PythonHomeCheck`'s own filesystem probe cannot promise to catch this either: it reads the same directory through the same kind of syscall CPython's import machinery does, and a path whose access is *parked* rather than *denied* can park the check identically. @@ -27,7 +27,7 @@ does, and a path whose access is *parked* rather than *denied* can park the chec **The test binary and an installed app get their stdlib differently.** The build extracts the stdlib into `build/python-stdlib/`, and the simulator *test* task points `SIMCTL_CHILD_PYTHONHOME` at it (`simctl` only forwards variables prefixed `SIMCTL_CHILD_`). That path is on this workspace's -external volume — fine for the test binary, which `simctl` launches with fewer sandbox restrictions +external volume, fine for the test binary, which `simctl` launches with fewer sandbox restrictions than an installed app, and exactly the shape that hangs an app rather than starting it. An installed **app** carries its own prefix inside its bundle (SPEC L-11, diff --git a/python-multiplatform/src/jvmMain/kotlin/python/multiplatform/ref/PhantomCleanerRegistry.kt b/python-multiplatform/src/jvmMain/kotlin/python/multiplatform/ref/PhantomCleanerRegistry.kt index e636a9131..848633ff5 100644 --- a/python-multiplatform/src/jvmMain/kotlin/python/multiplatform/ref/PhantomCleanerRegistry.kt +++ b/python-multiplatform/src/jvmMain/kotlin/python/multiplatform/ref/PhantomCleanerRegistry.kt @@ -14,13 +14,13 @@ import java.util.concurrent.atomic.AtomicLong * [ReferenceQueue] the collector enqueues them on, and a thread that drains it. * * **Why this is in `jvmMain` rather than `androidMain`.** It is the fallback for an Android version, - * but nothing in it is Android-specific — `PhantomReference` and `ReferenceQueue` are Java 1.2. It + * but nothing in it is Android-specific, `PhantomReference` and `ReferenceQueue` are Java 1.2. It * used to live in `androidMain`, and the consequence was that ROADMAP §4 could only say "Android * below API 33 uses the `PhantomReference` path and is not covered yet": the only way to run it was * on a device below API 33, and the emulators this project has are API 36, which take the `Cleaner` * branch and never reach here. Sitting in `jvmMain` it is reachable from `desktopTest`, and - * `PhantomCleanerRegistryTest` runs it on every desktop build. `desktopMain` does not use it — it - * has a real `Cleaner` — so this is a test seam, not a change of behaviour for that target. + * `PhantomCleanerRegistryTest` runs it on every desktop build. `desktopMain` does not use it, it + * has a real `Cleaner`, so this is a test seam, not a change of behaviour for that target. * * **The action must not be able to reach its owner.** The registry holds every live entry strongly, * and each entry holds its action strongly; an action that could reach the owner would therefore @@ -40,7 +40,7 @@ internal object PhantomCleanerRegistry { * Every entry that has not been released yet, held strongly. * * A `PhantomReference` nobody holds is itself collectable, and a collected reference is never - * enqueued — so this table is not bookkeeping, it is what makes the mechanism work at all. + * enqueued, so this table is not bookkeeping, it is what makes the mechanism work at all. */ private val active = ConcurrentHashMap() @@ -84,7 +84,7 @@ internal object PhantomCleanerRegistry { * **The `Throwable` catch is the whole point of this loop's shape**, and it was measured before * it was written: with only the `InterruptedException` catch this loop used to have, one * release action that threw propagated out of the loop, out of the `Runnable`, and ended the - * thread — after which *every* later release on the whole process was silently never made. + * thread, after which *every* later release on the whole process was silently never made. * `PhantomCleanerRegistryTest.aReleaseThatThrowsDoesNotStopLaterReleases` failed with * `count: 0`, and so did the unrelated case that happened to run after it. `java.lang.ref * .Cleaner`'s own thread swallows `Throwable` for exactly this reason, which is why the API 33+ @@ -118,7 +118,7 @@ internal object PhantomCleanerRegistry { /** * Arranges for [action] to run once [owner] is unreachable, or once [Cleanable.clean] is - * called — whichever happens first, and never both. + * called, whichever happens first, and never both. */ fun register(owner: Any, action: Runnable): Cleanable { val entry = Entry(owner, action) diff --git a/python-multiplatform/src/jvmMain/kotlin/python/native/ffi/EmbedAPI.jvm.kt b/python-multiplatform/src/jvmMain/kotlin/python/native/ffi/EmbedAPI.jvm.kt index 13ee65f72..c8f9f18d9 100644 --- a/python-multiplatform/src/jvmMain/kotlin/python/native/ffi/EmbedAPI.jvm.kt +++ b/python-multiplatform/src/jvmMain/kotlin/python/native/ffi/EmbedAPI.jvm.kt @@ -2,7 +2,7 @@ package python.native.ffi /** * `NativePointer.address` always carries a boxed `Long` on both JVM leaves (Android's `JNIPointer` - * is a typealias for `Long`), so this cast — and [toRawValue] below — behave identically on + * is a typealias for `Long`), so this cast, and [toRawValue] below, behave identically on * Android and desktop and don't need a platform-specific `actual`. */ inline fun NativePointer.toPlatformPointer(): Long = this.address as Long diff --git a/python-multiplatform/src/jvmTest/kotlin/python/multiplatform/ref/PhantomCleanerRegistryTest.kt b/python-multiplatform/src/jvmTest/kotlin/python/multiplatform/ref/PhantomCleanerRegistryTest.kt index 75ec4212f..76bae7491 100644 --- a/python-multiplatform/src/jvmTest/kotlin/python/multiplatform/ref/PhantomCleanerRegistryTest.kt +++ b/python-multiplatform/src/jvmTest/kotlin/python/multiplatform/ref/PhantomCleanerRegistryTest.kt @@ -9,24 +9,24 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue /** - * The `PhantomReference` release path — ROADMAP §4's one uncovered corner. + * The `PhantomReference` release path, ROADMAP §4's one uncovered corner. * * `java.lang.ref.Cleaner` arrived in **Android API 33**. Every device below that runs a different * mechanism, and §4 has said "Android below API 33 uses the `PhantomReference` path and is not * covered yet" for as long as the section has existed. It stayed uncovered for a structural reason * rather than an oversight: the code lived in `androidMain`, so the only way to run it was on a - * device, and the emulators in this project are API 36 — which takes the `Cleaner` branch and never + * device, and the emulators in this project are API 36, which takes the `Cleaner` branch and never * touches the fallback at all. The fallback could have been arbitrarily broken and every test in * the repository would still have passed. * * [PhantomCleanerRegistry] therefore lives in `jvmMain`, where `desktopTest` can reach it. Nothing - * in it is Android-specific — `java.lang.ref.PhantomReference` and `ReferenceQueue` are Java 1.2 — + * in it is Android-specific, `java.lang.ref.PhantomReference` and `ReferenceQueue` are Java 1.2, * so running it on a desktop JVM exercises the same class an API 26 device would. * * **It runs on both runtimes, and that immediately paid for itself.** This class sits in `jvmTest`, * which `desktopTest` and `androidInstrumentedTest` both depend on, so every case here executes on * HotSpot *and* on ART. The first six-target run after it landed was green on desktop and red on - * both emulators — API 36 with two failures, API 26 with three — for two distinct reasons. One is + * both emulators, API 36 with two failures, API 26 with three, for two distinct reasons. One is * the test's own (`System.gc()` is not a collection on ART; see [awaitCount]) and one is real * (`Reference.reachabilityFence` is API 28+ and the fallback's whole audience is below API 33; see * [keptAlive]). Neither is visible from a desktop-only run, and the second is a genuine API-level @@ -54,7 +54,7 @@ class PhantomCleanerRegistryTest { * * The action closes over [counter] and nothing else. An action that could reach its own owner * would keep that owner strongly reachable through the registry's entry table, and the release - * would never run — which is the single way to write this class that disables it silently. + * would never run, which is the single way to write this class that disables it silently. */ private fun registerAndDrop(counter: AtomicInteger, throwing: Boolean = false): WeakReference { val owner = Owner() @@ -90,7 +90,7 @@ class PhantomCleanerRegistryTest { } } - /** Registers, releases by hand, and drops the owner — all inside one frame, as above. */ + /** Registers, releases by hand, and drops the owner, all inside one frame, as above. */ private fun registerCleanAndDrop(counter: AtomicInteger, cleanTimes: Int = 1) { val owner = Owner() val cleanable = PhantomCleanerRegistry.register(owner, Runnable { counter.incrementAndGet() }) @@ -112,7 +112,7 @@ class PhantomCleanerRegistryTest { * `System.gc()` body back and running this class alone on API 36 reproduced it directly: * `owners collected: 0/2; drainThread alive: true; registry drained: 1, failed: 1` for * [twoDroppedOwnersRunTwoReleases], and `owners collected: 0/1` for the other two waiting cases. - * Twenty seconds of `System.gc()` had reclaimed *nothing* — while the drain thread was alive the + * Twenty seconds of `System.gc()` had reclaimed *nothing*, while the drain thread was alive the * whole time, and every entry the collector genuinely did hand over (`drained: 1`, plus the * throwing one at `failed: 1`) was delivered correctly. The registry was never the fault; the * waiting was. diff --git a/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/idlelib/help.html b/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/idlelib/help.html index 827d230b5..480d32e60 100644 --- a/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/idlelib/help.html +++ b/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/idlelib/help.html @@ -25,7 +25,7 @@ - + @@ -135,7 +135,7 @@

    Table of Contents

    Previous topic

    tkinter.ttk — Tk themed widgets

    + title="previous chapter">tkinter.ttk, Tk themed widgets

    Next topic

    @@ -171,7 +171,7 @@

    Navigation

    next |
  • - previous |
  • python logo
  • @@ -1084,7 +1084,7 @@

    Table of Contents

    Previous topic

    tkinter.ttk — Tk themed widgets

    + title="previous chapter">tkinter.ttk, Tk themed widgets

    Next topic

    @@ -1123,7 +1123,7 @@

    Navigation

    next |
  • - previous |
  • python logo
  • diff --git a/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/pydoc_data/topics.py b/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/pydoc_data/topics.py index dbfeb5599..171e3d22b 100644 --- a/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/pydoc_data/topics.py +++ b/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/pydoc_data/topics.py @@ -1703,7 +1703,7 @@ 'exception. How this value is computed depends on the type of the\n' 'callable object.\n' '\n' - 'If it is—\n' + 'If it is, \n' '\n' 'a user-defined function:\n' ' The code block for the function is executed, passing it the\n' @@ -1958,7 +1958,7 @@ 'most\n' 'important built-in types.\n' '\n' - '* Numbers of built-in numeric types (Numeric Types — int, ' + '* Numbers of built-in numeric types (Numeric Types, int, ' 'float,\n' ' complex) and of the standard library types ' '"fractions.Fraction" and\n' @@ -3647,7 +3647,7 @@ 'be omitted from a call, in which case the parameter’s default ' 'value is\n' 'substituted. If a parameter has a default value, all following\n' - 'parameters up until the “"*"” must also have a default value — ' + 'parameters up until the “"*"” must also have a default value, ' 'this is\n' 'a syntactic restriction that is not expressed by the grammar.\n' '\n' @@ -4600,7 +4600,7 @@ '\n' ' Note:\n' '\n' - ' "del x" doesn’t directly call "x.__del__()" — the ' + ' "del x" doesn’t directly call "x.__del__()", the ' 'former\n' ' decrements the reference count for "x" by one, and the ' 'latter is\n' @@ -4959,7 +4959,7 @@ ' neither "__len__()" nor "__bool__()", all its instances ' 'are\n' ' considered true.\n', - 'debugger': '"pdb" — The Python Debugger\n' + 'debugger': '"pdb", The Python Debugger\n' '***************************\n' '\n' '**Source code:** Lib/pdb.py\n' @@ -5486,7 +5486,7 @@ ' followed by end). This is because any time you resume ' 'execution\n' ' (even with a simple next or step), you may encounter another\n' - ' breakpoint—which could have its own command list, leading to\n' + ' breakpoint, which could have its own command list, leading to\n' ' ambiguities about which list to execute.\n' '\n' ' If you use the "silent" command in the command list, the ' @@ -5598,7 +5598,7 @@ '\n' ' Note:\n' '\n' - ' "print()" can also be used, but is not a debugger command — ' + ' "print()" can also be used, but is not a debugger command, ' 'this\n' ' executes the Python "print()" function.\n' '\n' @@ -7559,7 +7559,7 @@ 'be omitted from a call, in which case the parameter’s default ' 'value is\n' 'substituted. If a parameter has a default value, all following\n' - 'parameters up until the “"*"” must also have a default value — ' + 'parameters up until the “"*"” must also have a default value, ' 'this is\n' 'a syntactic restriction that is not expressed by the grammar.\n' '\n' @@ -8143,7 +8143,7 @@ 'were\n' 'imported and used within the module).\n' '\n' - 'The wild card form of import — "from module import *" — is only\n' + 'The wild card form of import, "from module import *", is only\n' 'allowed at the module level. Attempting to use it in class or\n' 'function definitions will raise a "SyntaxError".\n' '\n' @@ -8270,7 +8270,7 @@ 'the\n' 'future statement. This can be controlled by optional arguments ' 'to\n' - '"compile()" — see the documentation of that function for details.\n' + '"compile()", see the documentation of that function for details.\n' '\n' 'A future statement typed at an interactive interpreter prompt ' 'will\n' @@ -9075,7 +9075,7 @@ 'Objects are never explicitly destroyed; however, when they ' 'become\n' 'unreachable they may be garbage-collected. An implementation is\n' - 'allowed to postpone garbage collection or omit it altogether — it ' + 'allowed to postpone garbage collection or omit it altogether, it ' 'is a\n' 'matter of implementation quality how garbage collection is\n' 'implemented, as long as no objects are collected that are still\n' @@ -9341,7 +9341,7 @@ '\n' ' pass_stmt ::= "pass"\n' '\n' - '"pass" is a null operation — when it is executed, nothing happens. ' + '"pass" is a null operation, when it is executed, nothing happens. ' 'It\n' 'is useful as a placeholder when a statement is required ' 'syntactically,\n' @@ -10055,7 +10055,7 @@ '\n' ' Note:\n' '\n' - ' "del x" doesn’t directly call "x.__del__()" — the ' + ' "del x" doesn’t directly call "x.__del__()", the ' 'former\n' ' decrements the reference count for "x" by one, and the ' 'latter is\n' @@ -15288,7 +15288,7 @@ 'different object types.\n' '\n' 'See Function definitions for more information.\n', - 'typesmapping': 'Mapping Types — "dict"\n' + 'typesmapping': 'Mapping Types, "dict"\n' '**********************\n' '\n' 'A *mapping* object maps *hashable* values to arbitrary ' @@ -15875,7 +15875,7 @@ 'written as\n' '"".\n', - 'typesseq': 'Sequence Types — "list", "tuple", "range"\n' + 'typesseq': 'Sequence Types, "list", "tuple", "range"\n' '*****************************************\n' '\n' 'There are three basic sequence types: lists, tuples, and range\n' @@ -16361,7 +16361,7 @@ 'is\n' ' stable if it guarantees not to change the relative order ' 'of\n' - ' elements that compare equal — this is helpful for sorting ' + ' elements that compare equal, this is helpful for sorting ' 'in\n' ' multiple passes (for example, sort by department, then by ' 'salary\n' @@ -16542,7 +16542,7 @@ 'Range objects implement the "collections.abc.Sequence" ABC, and\n' 'provide features such as containment tests, element index ' 'lookup,\n' - 'slicing and support for negative indices (see Sequence Types — ' + 'slicing and support for negative indices (see Sequence Types, ' 'list,\n' 'tuple, range):\n' '\n' diff --git a/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/_support.py b/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/_support.py index 5a76c163f..2b00cba15 100644 --- a/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/_support.py +++ b/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/_support.py @@ -30,7 +30,7 @@ def get_modules(): Because this function manipulates the import system in a way that might be fragile or do unexpected things if it is run many times, it uses a `call_once` decorator to ensure that this is only ever called exactly - one time — in other words, when using this function you will only ever + one time, in other words, when using this function you will only ever get one copy of each module rather than a fresh import each time. """ import zoneinfo as c_module @@ -80,7 +80,7 @@ def pop_tzdata_modules(): with lock: if block_tzdata: # In order to fully exclude tzdata from the path, we need to - # clear the sys.modules cache of all its contents — setting the + # clear the sys.modules cache of all its contents, setting the # root package to None is not enough to block direct access of # already-imported submodules (though it will prevent new # imports of submodules). diff --git a/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/test_zoneinfo.py b/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/test_zoneinfo.py index 8bcd6d2e9..b36653a01 100644 --- a/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/test_zoneinfo.py +++ b/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/test_zoneinfo.py @@ -812,7 +812,7 @@ def construct_zone(self, transitions, after=None, version=3): def zt_as_tuple(zt): # zt may be a tuple (timestamp, offset_before, offset_after) or - # a ZoneTransition object — this is to allow the timestamp to be + # a ZoneTransition object, this is to allow the timestamp to be # values that are outside the valid range for datetimes but still # valid 64-bit timestamps. if isinstance(zt, tuple): diff --git a/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/test_zoneinfo_property.py b/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/test_zoneinfo_property.py index feaa77f3e..ffe66c4a8 100644 --- a/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/test_zoneinfo_property.py +++ b/python-multiplatform/src/nativeInterop/cinterop/lib/android/arm64-v8a/python3.13/test/test_zoneinfo/test_zoneinfo_property.py @@ -41,7 +41,7 @@ def valid_key(key): except ModuleNotFoundError: return False - # This relies on the fact that dictionaries maintain insertion order — for + # This relies on the fact that dictionaries maintain insertion order, for # shrinking purposes, it is preferable to start with the standard version, # then move to the posix/ version, then to the right/ version. out_zones = {"": available_zones} diff --git a/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/idlelib/help.html b/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/idlelib/help.html index 827d230b5..480d32e60 100644 --- a/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/idlelib/help.html +++ b/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/idlelib/help.html @@ -25,7 +25,7 @@ - + @@ -135,7 +135,7 @@

    Table of Contents

    Previous topic

    tkinter.ttk — Tk themed widgets

    + title="previous chapter">tkinter.ttk, Tk themed widgets

    Next topic

    @@ -171,7 +171,7 @@

    Navigation

    next |
  • - previous |
  • python logo
  • @@ -1084,7 +1084,7 @@

    Table of Contents

    Previous topic

    tkinter.ttk — Tk themed widgets

    + title="previous chapter">tkinter.ttk, Tk themed widgets

    Next topic

    @@ -1123,7 +1123,7 @@

    Navigation

    next |
  • - previous |
  • python logo
  • diff --git a/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/pydoc_data/topics.py b/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/pydoc_data/topics.py index dbfeb5599..171e3d22b 100644 --- a/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/pydoc_data/topics.py +++ b/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/pydoc_data/topics.py @@ -1703,7 +1703,7 @@ 'exception. How this value is computed depends on the type of the\n' 'callable object.\n' '\n' - 'If it is—\n' + 'If it is, \n' '\n' 'a user-defined function:\n' ' The code block for the function is executed, passing it the\n' @@ -1958,7 +1958,7 @@ 'most\n' 'important built-in types.\n' '\n' - '* Numbers of built-in numeric types (Numeric Types — int, ' + '* Numbers of built-in numeric types (Numeric Types, int, ' 'float,\n' ' complex) and of the standard library types ' '"fractions.Fraction" and\n' @@ -3647,7 +3647,7 @@ 'be omitted from a call, in which case the parameter’s default ' 'value is\n' 'substituted. If a parameter has a default value, all following\n' - 'parameters up until the “"*"” must also have a default value — ' + 'parameters up until the “"*"” must also have a default value, ' 'this is\n' 'a syntactic restriction that is not expressed by the grammar.\n' '\n' @@ -4600,7 +4600,7 @@ '\n' ' Note:\n' '\n' - ' "del x" doesn’t directly call "x.__del__()" — the ' + ' "del x" doesn’t directly call "x.__del__()", the ' 'former\n' ' decrements the reference count for "x" by one, and the ' 'latter is\n' @@ -4959,7 +4959,7 @@ ' neither "__len__()" nor "__bool__()", all its instances ' 'are\n' ' considered true.\n', - 'debugger': '"pdb" — The Python Debugger\n' + 'debugger': '"pdb", The Python Debugger\n' '***************************\n' '\n' '**Source code:** Lib/pdb.py\n' @@ -5486,7 +5486,7 @@ ' followed by end). This is because any time you resume ' 'execution\n' ' (even with a simple next or step), you may encounter another\n' - ' breakpoint—which could have its own command list, leading to\n' + ' breakpoint, which could have its own command list, leading to\n' ' ambiguities about which list to execute.\n' '\n' ' If you use the "silent" command in the command list, the ' @@ -5598,7 +5598,7 @@ '\n' ' Note:\n' '\n' - ' "print()" can also be used, but is not a debugger command — ' + ' "print()" can also be used, but is not a debugger command, ' 'this\n' ' executes the Python "print()" function.\n' '\n' @@ -7559,7 +7559,7 @@ 'be omitted from a call, in which case the parameter’s default ' 'value is\n' 'substituted. If a parameter has a default value, all following\n' - 'parameters up until the “"*"” must also have a default value — ' + 'parameters up until the “"*"” must also have a default value, ' 'this is\n' 'a syntactic restriction that is not expressed by the grammar.\n' '\n' @@ -8143,7 +8143,7 @@ 'were\n' 'imported and used within the module).\n' '\n' - 'The wild card form of import — "from module import *" — is only\n' + 'The wild card form of import, "from module import *", is only\n' 'allowed at the module level. Attempting to use it in class or\n' 'function definitions will raise a "SyntaxError".\n' '\n' @@ -8270,7 +8270,7 @@ 'the\n' 'future statement. This can be controlled by optional arguments ' 'to\n' - '"compile()" — see the documentation of that function for details.\n' + '"compile()", see the documentation of that function for details.\n' '\n' 'A future statement typed at an interactive interpreter prompt ' 'will\n' @@ -9075,7 +9075,7 @@ 'Objects are never explicitly destroyed; however, when they ' 'become\n' 'unreachable they may be garbage-collected. An implementation is\n' - 'allowed to postpone garbage collection or omit it altogether — it ' + 'allowed to postpone garbage collection or omit it altogether, it ' 'is a\n' 'matter of implementation quality how garbage collection is\n' 'implemented, as long as no objects are collected that are still\n' @@ -9341,7 +9341,7 @@ '\n' ' pass_stmt ::= "pass"\n' '\n' - '"pass" is a null operation — when it is executed, nothing happens. ' + '"pass" is a null operation, when it is executed, nothing happens. ' 'It\n' 'is useful as a placeholder when a statement is required ' 'syntactically,\n' @@ -10055,7 +10055,7 @@ '\n' ' Note:\n' '\n' - ' "del x" doesn’t directly call "x.__del__()" — the ' + ' "del x" doesn’t directly call "x.__del__()", the ' 'former\n' ' decrements the reference count for "x" by one, and the ' 'latter is\n' @@ -15288,7 +15288,7 @@ 'different object types.\n' '\n' 'See Function definitions for more information.\n', - 'typesmapping': 'Mapping Types — "dict"\n' + 'typesmapping': 'Mapping Types, "dict"\n' '**********************\n' '\n' 'A *mapping* object maps *hashable* values to arbitrary ' @@ -15875,7 +15875,7 @@ 'written as\n' '"".\n', - 'typesseq': 'Sequence Types — "list", "tuple", "range"\n' + 'typesseq': 'Sequence Types, "list", "tuple", "range"\n' '*****************************************\n' '\n' 'There are three basic sequence types: lists, tuples, and range\n' @@ -16361,7 +16361,7 @@ 'is\n' ' stable if it guarantees not to change the relative order ' 'of\n' - ' elements that compare equal — this is helpful for sorting ' + ' elements that compare equal, this is helpful for sorting ' 'in\n' ' multiple passes (for example, sort by department, then by ' 'salary\n' @@ -16542,7 +16542,7 @@ 'Range objects implement the "collections.abc.Sequence" ABC, and\n' 'provide features such as containment tests, element index ' 'lookup,\n' - 'slicing and support for negative indices (see Sequence Types — ' + 'slicing and support for negative indices (see Sequence Types, ' 'list,\n' 'tuple, range):\n' '\n' diff --git a/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/_support.py b/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/_support.py index 5a76c163f..2b00cba15 100644 --- a/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/_support.py +++ b/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/_support.py @@ -30,7 +30,7 @@ def get_modules(): Because this function manipulates the import system in a way that might be fragile or do unexpected things if it is run many times, it uses a `call_once` decorator to ensure that this is only ever called exactly - one time — in other words, when using this function you will only ever + one time, in other words, when using this function you will only ever get one copy of each module rather than a fresh import each time. """ import zoneinfo as c_module @@ -80,7 +80,7 @@ def pop_tzdata_modules(): with lock: if block_tzdata: # In order to fully exclude tzdata from the path, we need to - # clear the sys.modules cache of all its contents — setting the + # clear the sys.modules cache of all its contents, setting the # root package to None is not enough to block direct access of # already-imported submodules (though it will prevent new # imports of submodules). diff --git a/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/test_zoneinfo.py b/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/test_zoneinfo.py index 8bcd6d2e9..b36653a01 100644 --- a/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/test_zoneinfo.py +++ b/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/test_zoneinfo.py @@ -812,7 +812,7 @@ def construct_zone(self, transitions, after=None, version=3): def zt_as_tuple(zt): # zt may be a tuple (timestamp, offset_before, offset_after) or - # a ZoneTransition object — this is to allow the timestamp to be + # a ZoneTransition object, this is to allow the timestamp to be # values that are outside the valid range for datetimes but still # valid 64-bit timestamps. if isinstance(zt, tuple): diff --git a/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/test_zoneinfo_property.py b/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/test_zoneinfo_property.py index feaa77f3e..ffe66c4a8 100644 --- a/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/test_zoneinfo_property.py +++ b/python-multiplatform/src/nativeInterop/cinterop/lib/android/x86_64/python3.13/test/test_zoneinfo/test_zoneinfo_property.py @@ -41,7 +41,7 @@ def valid_key(key): except ModuleNotFoundError: return False - # This relies on the fact that dictionaries maintain insertion order — for + # This relies on the fact that dictionaries maintain insertion order, for # shrinking purposes, it is preferable to start with the standard version, # then move to the posix/ version, then to the right/ version. out_zones = {"": available_zones} diff --git a/python-multiplatform/src/nativeMain/README.md b/python-multiplatform/src/nativeMain/README.md index 489b3c0d8..3b0c7e721 100644 --- a/python-multiplatform/src/nativeMain/README.md +++ b/python-multiplatform/src/nativeMain/README.md @@ -1,9 +1,9 @@ -# nativeMain — rules +# nativeMain, rules Kotlin/Native sources shared by iOS **and** androidNative. Reaches CPython through cinterop, in the same process, with no boundary to cross. -## Shared with iOS — check before adding anything +## Shared with iOS, check before adding anything `artMain` (androidNative-only) depends on this, and so does `iosMain`. Anything added here is compiled for iPhones too. JNI exports, `@CName` symbols intended for ART, and anything else @@ -12,7 +12,7 @@ Android-shaped belong in `artMain`. ## There is no boundary here Kotlin and CPython are in one binary. A call is a call. This is the fastest platform in the -project for FFI, and composition — which exists to avoid crossings — buys nothing. `actual` +project for FFI, and composition, which exists to avoid crossings, buys nothing. `actual` implementations of composed operations should just do the work directly. The same is true in the other direction: an upcall from Python into Kotlin is a plain function @@ -23,13 +23,13 @@ call, not a runtime transition. The upcall cost that shapes the JVM design does `UpcallEntry.kt` is the entry point Python calls in. It does **not** use the `@CName` + `ctypes.CDLL(None)` route the design doc predicted, because neither half of that survives on iOS: the `Python.framework` here has no `_ctypes` (no `lib-dynload` at all), and a `@CName` alias is -never emitted into a Kotlin/Native framework or test executable — only into the androidNative +never emitted into a Kotlin/Native framework or test executable, only into the androidNative `.so`. Measurements are in `docs/design/upcall.md`. So an entry point that Python must be able to call needs a `PyMethodDef` with a `staticCFunction` in `ml_meth`. Two rules come with that, and both have crashed this repo before: -- The `PyMethodDef` and its `ml_name` must outlive every callable built from it — `nativeHeap`, +- The `PyMethodDef` and its `ml_name` must outlive every callable built from it, `nativeHeap`, never freed. A `PyCFunction` object stores the `PyMethodDef *`; it does not copy it. - An entry point reached from C takes its own `PyGILState_Ensure` **unconditionally**. `withGIL`'s depth counter records scopes Kotlin opened, and C is free to have dropped the GIL inside one. diff --git a/python-multiplatform/src/wasmJsMain/README.md b/python-multiplatform/src/wasmJsMain/README.md index f3d136f40..0aa8edd73 100644 --- a/python-multiplatform/src/wasmJsMain/README.md +++ b/python-multiplatform/src/wasmJsMain/README.md @@ -1,4 +1,4 @@ -# wasmJsMain — rules +# wasmJsMain, rules Kotlin/Wasm reaching CPython 3.14 built for `wasm32-emscripten`, through `@WasmImport` against a **shared linear memory**. Not a JavaScript bridge: there is no JS frame in either the call path or @@ -42,7 +42,7 @@ It is the only allocator Kotlin/Wasm offers, and it assumes it owns the whole me grew the memory to two pages and handed back `0x0`. On the memory we share with Emscripten that is the null page and the static data region, so the first use corrupts CPython's heap. -This is this target's equivalent of desktop's "always `invokeExact`" — the one rule that is not a +This is this target's equivalent of desktop's "always `invokeExact`", the one rule that is not a preference. `Pointer(addr)` is the way in. Its constructor is public and `loadByte`/`storeByte`/`loadInt` @@ -56,7 +56,7 @@ Emscripten factory. **Do not remove those two lines**, and do not restore the pr lifetime of the process. Emscripten decides whether to use JavaScript Promise Integration by **runtime feature detection**, -not at build time — CPython's link line has no `-sJSPI`. When it detects JSPI it turns the blocking +not at build time, CPython's link line has no `-sJSPI`. When it detects JSPI it turns the blocking syscalls into suspending imports, and it installs the matching `WebAssembly.promising` wrapper on exactly one export: `main`. @@ -74,17 +74,17 @@ cause, and the wasm suite dies as *"process exited unexpectedly"* rather than as It cost this repo two sections of `docs/design/upcall.md` (§9.5, §14.4) to misdiagnose as "wasm cannot do `asyncio`". It could not do `asyncio` because `selectors.py` calls -`select.poll().poll(0)` at import time to pick its `DefaultSelector` — which is also why `import +`select.poll().poll(0)` at import time to pick its `DefaultSelector`, which is also why `import select` was fine and `import selectors` was not. §15 has the measurement. Measured, same `python.wasm`, `select.poll().poll(0)`: Node 22 (no JSPI) returns `[]`; Node 26 (JSPI on by default, and the Gradle runner's version) raises `SuspendError`. There is no V8 flag to turn -JSPI off on Node 26 — it is shipped, not experimental — so suppressing the feature detection is the +JSPI off on Node 26, it is shipped, not experimental, so suppressing the feature detection is the only lever. The synchronous path is the one this library wants anyway: every call arrives from Kotlin as a plain synchronous wasm call, so a blocking syscall has to actually block. **The known cost, recorded rather than hidden: this mutates a host intrinsic globally.** Under the -Node test runner that is contained — nothing else in the process wants JSPI, and the 344-test wasm +Node test runner that is contained, nothing else in the process wants JSPI, and the 344-test wasm suite is the evidence. On a browser page shared with *another* wasm module that uses JSPI, deleting these would break that module. The honest fix for such a page is an Emscripten build that does not feature-detect, not a narrower delete, because `__maybe_poll_async` re-reads @@ -98,11 +98,11 @@ a browser wasm target and it runs (ROADMAP §10). Same browser, Chromium 150: | a page that does not load the app | `function` | | the app's page, 20 s after load | `undefined` | -So the delete is real rather than a no-op — this browser ships JSPI and the property is gone +So the delete is real rather than a no-op, this browser ships JSPI and the property is gone page-wide. Nothing on that page broke: Compose came up (`canvases=1`), skiko rendered, Kotlin/Wasm ran. CPython, Kotlin/Wasm and skiko are three wasm modules on one page and **none of the other two uses JSPI**, which is the whole reason it is survivable. The warning is unchanged in force; what has -changed is that its scope is now observed — it costs nothing until a page loads a JSPI consumer. +changed is that its scope is now observed, it costs nothing until a page loads a JSPI consumer. ## The memory is shared because Kotlin *imports* it @@ -111,13 +111,13 @@ defining one. 2.4.10 and earlier exported one instead, which could not be made t Emscripten memory import and a Kotlin `@WasmImport` need each other's instantiation to come first. A wasm memory import accepts any memory whose limits sit inside its own, and an unbounded import -accepts everything — so `intrinsics.memory` takes Emscripten's `Module.wasmMemory` unchanged. +accepts everything, so `intrinsics.memory` takes Emscripten's `Module.wasmMemory` unchanged. **The whole data-path integration is that one substitution**, done in `build.gradle.kts` against the generated `*.import-object.mjs`. Emscripten is built exactly as it would be alone: no `-sIMPORTED_MEMORY`, no binary patching. (There is now a second substitution, on the generated *entry* module, and it exists for upcalls -rather than for the memory — see "Upcalls work" below. This section used to say "the whole +rather than for the memory, see "Upcalls work" below. This section used to say "the whole integration" without qualification, and that stopped being true when `ProxyTypeFactory` was wired up.) @@ -132,17 +132,17 @@ underneath Kotlin. A JS `TypedArray` view detaches when that happens; a wasm mem ## `Py_ssize_t` is 32-bit here, and it is the only ABI divergence `EmbedAPI.kt` types `Py_ssize_t` as Kotlin `Long`, which is right on every other target. On wasm32 -it is `i32`. **Fourteen functions are affected** — `PyList_New/Size/GetItem/SetItem/Insert`, +it is `i32`. **Fourteen functions are affected**, `PyList_New/Size/GetItem/SetItem/Insert`, `PyTuple_New/Size/GetItem/GetSlice/SetItem`, `PyDict_Size`, `PySet_Size`, `PyObject_Size`, -`PyObject_Length` — and `EmbedAPI.wasmJs.kt` converts at the boundary through two named functions +`PyObject_Length`, and `EmbedAPI.wasmJs.kt` converts at the boundary through two named functions rather than inline casts, because the two directions fail in opposite ways: - `Int.pySsizeToLong()` **sign-extends**. `-1` is the error return of nearly every function in the group, and the unsigned widening that is *correct* for pointers here would turn it into - 4294967295 — a value that passes `if (n < 0)` and is then used as a length. + 4294967295, a value that passes `if (n < 0)` and is then used as a length. - `Long.toPySsize()` **range-checks instead of truncating**. `PyList_New(0x1_0000_0000)` would otherwise become `PyList_New(0)` and hand back an empty list: a wrong answer rather than a - failure. Nothing outside `Int` can be legitimate — wasm32's whole address space is 4 GiB. + failure. Nothing outside `Int` can be legitimate, wasm32's whole address space is 4 GiB. ### Two guards, because the two halves fail differently @@ -161,7 +161,7 @@ breaking one declaration produces: This is the same shape as `generateDesktopReachabilityMetadata`: derived from the artefact that decides, on every run, never from a checked-in copy of the answer. It also asserts that every -`external fun` in the file was parsed — an earlier version required an explicit return type and so +`external fun` in the file was parsed, an earlier version required an explicit return type and so silently skipped the three `Unit`-returning declarations, which is precisely the "green test measuring its own scope" failure ROADMAP §2 records. @@ -187,7 +187,7 @@ Measured, both routes ending with a Kotlin `String` built from the same C addres | `UTF8ToString` through JS | 125 | 3 827 | `decodeToString()` costs about 13 ns/byte on long input against 0.2 ns/byte on short. `Wasm` -therefore scans once — deciding length and ASCII-ness together — and decodes into a `CharArray` +therefore scans once, deciding length and ASCII-ness together, and decodes into a `CharArray` either way. **Do not assume a JS crossing costs a string copy.** Kotlin/Wasm compiles with @@ -212,14 +212,14 @@ against the real interpreter, reading one global 200,000 times: | one crossing, direct `@WasmImport` | **2.9** | 75% comes off through pure-Kotlin optimisation. A composed shim could merge the two remaining calls -and save 2.9 ns. Same verdict ROADMAP §6 reached for desktop, with a wider margin — and it removes +and save 2.9 ns. Same verdict ROADMAP §6 reached for desktop, with a wider margin, and it removes a second build pipeline. ## String arguments: two routes, and the reason each is cheap is *not* the reason on Android Every `actual` taking a `String` used to `malloc`, encode, copy and `free` per call. All 59 argument positions across 49 functions now go through one of two routes, chosen per argument the -way `desktopMain` and `androidMain` choose — and, checked against them, with the identical verdict +way `desktopMain` and `androidMain` choose, and, checked against them, with the identical verdict on all 59: Wasm.internedUtf8 repeated identifiers: attribute, method, module, type names, dict keys @@ -233,8 +233,8 @@ iterations, `WasmMarshallingOverheadTest`: | `malloc` + `encodeToByteArray` + copy + `free` (what this target used to do) | **82.7** | | | `malloc` + `free` alone | 13.0 | | | `encodeToByteArray` alone | 59.2 | | -| `Wasm.scratchUtf8` — reused buffer, direct write | **30.6** | **157.0** | -| `Wasm.internedUtf8` — cache hit | **22.2** | **26.4** | +| `Wasm.scratchUtf8`, reused buffer, direct write | **30.6** | **157.0** | +| `Wasm.internedUtf8`, cache hit | **22.2** | **26.4** | And end to end, every row back to back in one run so that only the marshalling differs: @@ -251,15 +251,15 @@ Three things in there, two of them against what the other platforms would predic - **The expensive part was the intermediate `ByteArray`, not the encode.** `encodeToByteArray()` plus a copy is 59.2 ns; writing the same bytes straight into linear memory is what makes scratch 30.6 ns. A WasmGC array allocation per C API call was most of the cost, and shared memory is what - lets it go — the destination is CPython's own heap, so there is no staging buffer. That is the + lets it go, the destination is CPython's own heap, so there is no staging buffer. That is the *opposite* of the original design note's claim that shared memory removes a string copy. It removes the allocation. - **Interning's value is entirely in the string length, and the crossover is high.** For a - 7-character name it is worth 8 ns over scratch — nothing like Android's 15x (2238 → 148 ns). + 7-character name it is worth 8 ns over scratch, nothing like Android's 15x (2238 → 148 ns). For a 54-character one it is worth **6x** (157.0 → 26.4), because a cache hit is flat in the length and an encode is linear in it. Both are true, and reporting only the short case would have argued interning is barely worth having. -- **The end-to-end gap is smaller than the standalone gap**, because the rest of +- **The end-to-end gap is smaller than the standalone gap**: because the rest of `PyObject_GetAttrString` is CPython's own work. 258.0 → 229.2 ns through the `actual`. One negative result, recorded because the obvious reading of it is wrong: `BenchmarkTest`'s shared @@ -269,7 +269,7 @@ per-call difference of ~29 ns; the in-run A/B rows above are the measurement, an figure is not usable for a change this size in either direction. **Do not read `scratchUtf8`'s address as durable.** Four slots, rotating; the fifth call overwrites -the first. Four is chosen against the surface — the most any function here stages at once is three +the first. Four is chosen against the surface, the most any function here stages at once is three (`PyErr_WarnExplicit`, `PyImport_ExecCodeModuleWithPathnames`). A re-entrant call that marshals four more strings while these are live would overwrite them, exactly as on desktop. `allocUtf8`/`freeUtf8` remains for buffers the caller owns, and `ProxyTypeFactory`'s type name is one. @@ -282,12 +282,12 @@ exports. What the build *does* need is `wasmExports,wasmMemory` added to `-sEXPORTED_RUNTIME_METHODS`; without them the exports are in the binary but unreachable from JS, so there is nothing to hand `@WasmImport`. Neither setting is in PEP 783's ABI-sensitive list. -## Reclamation is automatic, through JS — and it is the only `js(…)` in this source set +## Reclamation is automatic, through JS, and it is the only `js(…)` in this source set The Kotlin/Wasm **stdlib** has no finalisation hook. That much was always true, and was checked directly against `kotlin-stdlib-wasm-js-2.4.20-Beta2.klib`: no `FinalizationRegistry`, no `WeakRef`, -no `Cleaner`. The conclusion drawn from it — that a `PyObject` dropped without `close()` must leak -here — was wrong. The *host* has all three, and `registerCleaner` now reaches them. +no `Cleaner`. The conclusion drawn from it, that a `PyObject` dropped without `close()` must leak +here, was wrong. The *host* has all three, and `registerCleaner` now reaches them. `PyAutoCloseable.wasmJs.kt` hands the cleaner to a JS `FinalizationRegistry` as a `JsReference`, with a small `CleanupState` (pointer, decref action, done flag) as the held value. The weakly @@ -314,7 +314,7 @@ after one macrotask WeakRef CLEARED registry callbacks 200 A `WeakRef` keeps its target alive for the job that created it, and the registry callback is delivered as a *task*. `FinalizationRegistry.prototype.cleanupSome()`, which would have made it -synchronous, has been removed from V8 — `--harmony-weak-refs-with-cleanup-some` is rejected as an +synchronous, has been removed from V8, `--harmony-weak-refs-with-cleanup-some` is rejected as an unrecognised flag, and `setFlagsFromString` reports the same. **So there is no synchronous drain on this platform, at all.** `commonTest`'s `collectorTest` exists for that: on every other target it is a blocking loop, and here it is a chain of host turns. `GCLeakTest`'s three cases pass on this @@ -326,15 +326,15 @@ flag to the wasmJs test task's `nodeJsArgs`. Where it is absent, `forceGC()` deg the bounded loop gives up rather than pretending. **This file is the only `js(…)` in `wasmJsMain`, and it has to be.** Everything else here is -`@WasmImport`, which is a wasm-to-wasm call with no JavaScript frame — but `@WasmImport` carries +`@WasmImport`, which is a wasm-to-wasm call with no JavaScript frame, but `@WasmImport` carries primitives only, and what has to cross here is a *reference* to the Kotlin object whose reachability is the whole question. There is no handle-table trick that avoids it. The cost is confined to construction and destruction; nothing on the C API call path goes through it: | ns / op | | |---|---| -| `registerCleaner` + `close()` — the whole hook | **88.6** | -| `toJsReference()` alone — the externref crossing | 44.1 | +| `registerCleaner` + `close()`, the whole hook | **88.6** | +| `toJsReference()` alone, the externref crossing | 44.1 | (50 000 iterations each, after an equal warm-up. Reading the *first* row recorded without a warm-up gave 300 ns and made the second look three times cheaper than a superset of itself.) @@ -346,7 +346,7 @@ route and `outstanding` is a leak count rather than a live-object count. One property this target has that the threaded ones do not: **the callback cannot arrive inside a Python call.** JS tasks run only once the stack has unwound and every call into CPython here is -synchronous, so the decref never re-enters the interpreter from within another call — the hazard +synchronous, so the decref never re-enters the interpreter from within another call, the hazard ROADMAP §1 spent a section on. ## Upcalls work, and cycles are collected @@ -358,8 +358,8 @@ ROADMAP §1 spent a section on. leave the heap type's refcount exactly where it started. The call path has no JavaScript in it. `@WasmExport` produces a raw wasm export; -`WebAssembly.Table.prototype.set` accepts it into CPython's own `__indirect_function_table` — a -funcref is a funcref whatever instance produced it — and the index **is** the C function pointer. +`WebAssembly.Table.prototype.set` accepts it into CPython's own `__indirect_function_table`, a +funcref is a funcref whatever instance produced it, and the index **is** the C function pointer. CPython reaches Kotlin through `call_indirect` at 3.1 ns, against 10.9 ns for the `addFunction`-plus-JS-closure route the original design specified. @@ -368,22 +368,22 @@ CPython reaches Kotlin through `call_indirect` at 3.1 ns, against 10.9 ns for th **1. The executable module must declare the trampolines. The library cannot.** `@WasmExport` is honoured only in the compilation that produces the `.wasm`. Measured, not inferred: the identical annotation on the identical function exports from `wasmJsTest` and does not from -`wasmJsMain`, whose klib is linked in — the test binary's export section came out holding +`wasmJsMain`, whose klib is linked in, the test binary's export section came out holding `startUnitTests` and nothing else. So an application has to write three delegating lines itself; see `ProxyTypeExportNames`, and `wasmJsTest/.../ProxyTypeExports.kt`, which is that file. Having the Gradle plugin generate it is the obvious next step and is not done. **2. Registration is a JS startup step, so the build patches the entry module.** A table index cannot be obtained from inside Kotlin, and `WebAssembly.Table.set` needs the funcref -out of `wasmInstance.exports`. `cpython.mjs` cannot fetch that itself — it is imported *by* Kotlin's +out of `wasmInstance.exports`. `cpython.mjs` cannot fetch that itself, it is imported *by* Kotlin's import object, so importing the entry module back would be an ES cycle across a top-level await. The `doFirst` in `build.gradle.kts` therefore appends `pmpSetKotlinExports(exports)` to the generated entry module. That is a **second** substitution; this file used to say the integration was one. **3. Kotlin/Wasm has no `call_indirect`, so calling a C function *pointer* goes through JS.** `tp_traverse` is handed a `visitproc` and `tp_dealloc` has to reach its type's `tp_free`; both are -table indices. `pmpCallVisit`/`pmpCallFree` in `cpython.mjs` do `table.get(i)(...)`. Both are cold — -inside a cyclic collection, or a proxy's deallocation — and neither is on the downcall path or on +table indices. `pmpCallVisit`/`pmpCallFree` in `cpython.mjs` do `table.get(i)(...)`. Both are cold, +inside a cyclic collection, or a proxy's deallocation, and neither is on the downcall path or on the path CPython uses to *enter* Kotlin. **`call_indirect` is statically typed and does not coerce.** A `(i32) -> i32` export called through a @@ -393,8 +393,8 @@ signatures are written out in `ProxyType`'s documentation next to the C ones. Prefer slots CPython invokes directly (`PyType_FromSpec`) over `PyMethodDef` entries: the latter go through `_PyEM_TrampolineCall`, whose wasm fast path never installs on this toolchain pair (an -upstream defect — the `EM_JS` initialiser needs `wasmTable`/`wasmMemory` before they exist and the -`LinkError` is swallowed by a bare `catch`). The cost is symmetric, though — every C extension pays +upstream defect, the `EM_JS` initialiser needs `wasmTable`/`wasmMemory` before they exist and the +`LinkError` is swallowed by a bare `catch`). The cost is symmetric, though, every C extension pays the same JS frame, measured at 14 ns over `abs()`. `wasmJsTest/.../WasmCallTrampolineTest` pins both halves of this. It registers a four-parameter @@ -412,31 +412,31 @@ Kotlin as a bare `JsException` with nothing in it. `cpython.mjs` reads C strings ## A browser bundle needs four things this library must hand it -Node and a browser differ in exactly one place — the **filesystem** — and in three build steps. All +Node and a browser differ in exactly one place, the **filesystem**, and in three build steps. All four are the library's to supply; ROADMAP §10 has the reasoning for why none of them is the consumer's problem to discover. `stageWasmBrowserRuntime` in `build.gradle.kts` is the task. 1. **`cpython.mjs` must be in the consumer's webpack context.** The *generated import object* of any module that links this library carries `import * as ... from './cpython.mjs'`, and nothing - propagates the file — it is not in the wasmJs klib's resources either. Missing, webpack fails with + propagates the file, it is not in the wasmJs klib's resources either. Missing, webpack fails with `Module not found: Error: Can't resolve './cpython.mjs'` and that is the whole error. 2. **`python.mjs` and `python.wasm` must sit next to the page.** `cpython.mjs` loads the glue with - `import(/* webpackIgnore: true */ "./python.mjs")` — dynamic so that `node:fs` is never resolved + `import(/* webpackIgnore: true */ "./python.mjs")`, dynamic so that `node:fs` is never resolved in a web bundle, and webpack-ignored because Emscripten's 567 KB of glue branches on `require`, `node:fs` and `import.meta.url` at runtime and must not be bundled. 3. **The standard library travels as `python3..zip`.** There is no NODEFS in a browser. `cpython.mjs` fetches the zip into MEMFS at `/lib/python3.zip` during `preRun`, under `addRunDependency`, and sets **no `thisProgram`** so that `sys.prefix` stays `/`. That is what CPython's own `Tools/wasm/emscripten/web_example/python.worker.mjs` does. -4. **Two substitutions on the compile-sync output**, between the sync and webpack: +4. **Two substitutions on the compile-sync output**: between the sync and webpack: `intrinsics.memory` → Emscripten's `wasmMemory`, and `pmpSetKotlinExports(exports)` into the - generated entry module. **The second goes *before* `exports._start()`, not appended after it** — + generated entry module. **The second goes *before* `exports._start()`, not appended after it**, `_start()` is Kotlin `main()`, and an appended handoff runs after the application has finished. Appending was correct for this module's own test bundle, whose entry module has no `_start()` call, which is why it survived until an executable existed. **That last sentence is also why no test guards it.** `wasmJsBrowserTest` runs the *test* bundle, - and reverting the placement produces a byte-identical file there — checked, and all ten browser + and reverting the placement produces a byte-identical file there, checked, and all ten browser tests stayed green. `patchKotlinWasmOutputForCPython` therefore asserts the ordering as a postcondition and refuses to write the file; `:sample`'s copy does the same, and that is the only build in the repository whose bundle can trip it. @@ -452,7 +452,7 @@ webpack, the absence of a filesystem, or a host intrinsic. 4.5 s warm (16 s if t has to be recompiled and re-bundled). It needs a Chromium-family browser and **skips with a message** when there is none, the same way a missing interpreter does; `CHROME_BIN` overrides the probe list in `build.gradle.kts`. `karma.config.d/cpython.js` serves the interpreter to -karma — `webpackCopy` for the glue (the bundle's directory is a fresh temp path every run) and a +karma, `webpackCopy` for the glue (the bundle's directory is a fresh temp path every run) and a proxy for the document-relative stdlib zip. Needs a CPython Emscripten build. The default location is `/.caches/wasm-runtime`, which @@ -465,10 +465,10 @@ interpreter reported `PYEMSCRIPTEN_PLATFORM_VERSION = None`. **An unpacked `python-multiplatform-wasm-runtime` zip works just as well, and that is what CI is wired to use.** What the tests need is not the CPython build tree but the five files -`stageWasmBrowserRuntime` stages out of it — `python.wasm`, `python.mjs`, the stdlib zip and the two +`stageWasmBrowserRuntime` stages out of it, `python.wasm`, `python.mjs`, the stdlib zip and the two glue modules. That was not true until the standard library stopped arriving through NODEFS: under Node, `sys.path[0]` was `/lib/python314.zip`, a file nothing had ever created, and the stdlib was -really coming from entry 1 — the CPython **source checkout** beside the build directory, which no +really coming from entry 1, the CPython **source checkout** beside the build directory, which no artefact carries. `cpython.mjs` now installs the staged zip into MEMFS on both hosts, so unzip python-multiplatform-wasm-runtime-.zip -d /tmp/rt @@ -476,23 +476,23 @@ artefact carries. `cpython.mjs` now installs the staged zip into MEMFS on both h runs the whole suite (344/0/0, measured) against nothing but a published artefact. -`-PrequireWasmRuntime=true` turns every one of those skips — the interpreter, and for the browser task -the absence of a Chromium-family browser — into a failure naming what was missing. Pass it whenever +`-PrequireWasmRuntime=true` turns every one of those skips, the interpreter, and for the browser task +the absence of a Chromium-family browser, into a failure naming what was missing. Pass it whenever a *caller* asked for the suite, so that a skip cannot be reported to them as success over zero tests. `.github/workflows/wasm.yml` passes it; without it that workflow reported green having run nothing, and did for months. `verifyWasmAbiSignatures` runs first, automatically, and skips the same way. Run it alone when -`bindings.kt` changes — it is a second or two and it turns "0 tests ran, `LinkError`" into a line +`bindings.kt` changes, it is a second or two and it turns "0 tests ran, `LinkError`" into a line naming the function. Current state, re-measured rather than carried over from the paragraph above: **403 tests, 0 failed, 0 skipped** (`wasmJsNodeTest`), including the three `GCLeakTest` cases and the four -`WasmFinalizationTest` cases — see "Reclamation is automatic, through JS" above for how those pass. +`WasmFinalizationTest` cases, see "Reclamation is automatic, through JS" above for how those pass. The "214 tests, 3 failed" this line used to read was the state before that section's fix landed and had drifted out of step with the "344/0/0" already recorded a few paragraphs up; both are stale now -that the suite has grown further, which is expected — re-run `wasmJsNodeTest` rather than trusting +that the suite has grown further, which is expected, re-run `wasmJsNodeTest` rather than trusting either number. `emsdk` is pinned to **5.0.3**, which is what `pyemscripten_2026_0` specifies. Installing another -version replaces `~/emsdk/upstream` **in place** — it is a global setting, not per-project. +version replaces `~/emsdk/upstream` **in place**, it is a global setting, not per-project. diff --git a/python-multiplatform/src/wasmJsMain/kotlin/python/multiplatform/env/PythonPayload.wasmJs.kt b/python-multiplatform/src/wasmJsMain/kotlin/python/multiplatform/env/PythonPayload.wasmJs.kt index e43ade2d6..04f98d087 100644 --- a/python-multiplatform/src/wasmJsMain/kotlin/python/multiplatform/env/PythonPayload.wasmJs.kt +++ b/python-multiplatform/src/wasmJsMain/kotlin/python/multiplatform/env/PythonPayload.wasmJs.kt @@ -6,8 +6,8 @@ package python.multiplatform.env * The standard library reaches this target by being preloaded into Emscripten's virtual filesystem * at *build* time (see `wasmJsMain/README.md` and `cpython.mjs`), which is also the only way a * consumer's Python could get here: the payload would have to be baked into that same image by the - * bundling step, not discovered at run time. Nothing in `toolchain` produces a wasm bundle today — - * `PythonStagingPlatform` has three destinations and wasm is not one of them — so returning an + * bundling step, not discovered at run time. Nothing in `toolchain` produces a wasm bundle today, + * `PythonStagingPlatform` has three destinations and wasm is not one of them, so returning an * empty list is the honest answer rather than a stub awaiting a directory that no build writes. */ internal actual fun discoverStagedPayloadRoots(): List = emptyList() diff --git a/python-multiplatform/src/wasmJsTest/kotlin/python/native/ffi/emscripten/WasmSharedMemoryGrowthTest.kt b/python-multiplatform/src/wasmJsTest/kotlin/python/native/ffi/emscripten/WasmSharedMemoryGrowthTest.kt index d7a3c039c..a0202cf2a 100644 --- a/python-multiplatform/src/wasmJsTest/kotlin/python/native/ffi/emscripten/WasmSharedMemoryGrowthTest.kt +++ b/python-multiplatform/src/wasmJsTest/kotlin/python/native/ffi/emscripten/WasmSharedMemoryGrowthTest.kt @@ -17,7 +17,7 @@ import python.native.ffi.Wasm * CPython links `-sALLOW_MEMORY_GROWTH -sINITIAL_MEMORY=20971520`, so growth is not hypothetical: * it happens the first time anything allocates past the initial 20 MiB. A JS `TypedArray` view * detaches when the buffer grows; Kotlin holds the memory as a wasm *import* and does not. The - * whole data path of this target — every `char*` read and every C string Kotlin writes — rests on + * whole data path of this target, every `char*` read and every C string Kotlin writes, rests on * that, and until this test it was proved only by the standalone `wasm-experiment/` (Tests C and * D, now in git history), which no build ran. * diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 510d18a76..c16bd4af8 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -217,7 +217,7 @@ fun patchWasmOutputForCPython(dir: File, modulePrefix: String) { // first browser run found. `_start()` is Kotlin `main()`; the library's own test bundle has // no such call (its runner starts the tests from outside), so appending had always been // enough and every executable bundle would have registered its upcall entry point one line - // too late. The symptom is `pmpRegisterUpcall returned -1` — `kotlinExports` still null — + // too late. The symptom is `pmpRegisterUpcall returned -1`, `kotlinExports` still null, // for an application whose sections 1-4 all worked. val startCall = "exports._start();" val note = "// Added by :sample's build: upcall registration needs the raw wasm exports,\n" + diff --git a/sample/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/demo/MainActivity.kt b/sample/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/demo/MainActivity.kt index bcbf3db63..80a09fc23 100644 --- a/sample/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/demo/MainActivity.kt +++ b/sample/src/androidMain/kotlin/org/thisisthepy/python/multiplatform/demo/MainActivity.kt @@ -15,8 +15,8 @@ private const val TAG = "PythonDemo" * `Py_Initialize`: CPython's stdlib ships inside the library module's assets and has to be * unpacked to a readable directory, and `PYTHONHOME` has to name that directory's prefix. * - * That was ~55 lines here — an ABI lookup, a version-derived stdlib path, a recursive - * `AssetManager` copy, and an "already unpacked?" probe — all of it duplicated in the external + * That was ~55 lines here, an ABI lookup, a version-derived stdlib path, a recursive + * `AssetManager` copy, and an "already unpacked?" probe, all of it duplicated in the external * consumer app and in the library's own instrumentation fixture, and all of it wrong about the * same thing (a copy interrupted partway through looked complete to the probe). It is * [PythonBootstrap] now, shipped in the AAR, and the host app's share is one call. diff --git a/sample/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/demo/ui/App.kt b/sample/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/demo/ui/App.kt index a920e8a48..6f356abc2 100644 --- a/sample/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/demo/ui/App.kt +++ b/sample/src/commonMain/kotlin/org/thisisthepy/python/multiplatform/demo/ui/App.kt @@ -71,7 +71,7 @@ fun App() { /** 1. The interpreter is really up, and it is the build's configured one. */ @Composable private fun RuntimeSection() { - DemoCard("1 — embedded interpreter") { + DemoCard("1, embedded interpreter") { val summary = remember { runCatching { PythonDemo.runtimeSummary() }.getOrElse { "not started: ${it.message}" } } Mono(summary) } @@ -80,7 +80,7 @@ private fun RuntimeSection() { /** 2. Kotlin builds a Python object, Python computes with it, Kotlin reads the result back. */ @Composable private fun EvaluateSection() { - DemoCard("2 — object model round trip") { + DemoCard("2, object model round trip") { var expression by remember { mutableStateOf(PythonDemo.DEFAULT_EXPRESSION) } var result by remember { mutableStateOf("") } @@ -99,7 +99,7 @@ private fun EvaluateSection() { /** 3. The direction that had no example at all until now: Python calling Kotlin. */ @Composable private fun UpcallSection() { - DemoCard("3 — Python calls Kotlin (upcall)") { + DemoCard("3, Python calls Kotlin (upcall)") { var presses by remember { mutableStateOf(0) } var answer by remember { mutableStateOf("") } @@ -121,7 +121,7 @@ private fun UpcallSection() { /** 4. Proof the KSP processor ran, read off the runtime table rather than off the build log. */ @Composable private fun TableSection() { - DemoCard("4 — generated function table") { + DemoCard("4, generated function table") { Mono(UpcallDemo.tableSummary()) if (UpcallDemo.available) { Mono( @@ -141,7 +141,7 @@ private fun TableSection() { */ @Composable private fun ClassProxySection() { - DemoCard("5 — Python drives a Kotlin class") { + DemoCard("5, Python drives a Kotlin class") { var result by remember { mutableStateOf("") } Mono(remember { PythonDemo.proxyInstallReport() }) @@ -154,7 +154,7 @@ private fun ClassProxySection() { /** 6. The companion object, reached through the class and never through an instance. */ @Composable private fun StaticSurfaceSection() { - DemoCard("6 — companion members on the metaclass") { + DemoCard("6, companion members on the metaclass") { var result by remember { mutableStateOf("") } Text("Greeter.built = 100; Greeter.forget(); Greeter.PUNCTUATION = '?'") @@ -172,7 +172,7 @@ private fun StaticSurfaceSection() { */ @Composable private fun AwaitSection() { - DemoCard("7 — await over a suspend fun") { + DemoCard("7, await over a suspend fun") { // Neither button below may run on wasmJs: PythonDemo.awaitFastPath() and // PythonDemo.awaitSuspending() both reach `import asyncio`, which traps this wasm // instance rather than raising (docs/design/upcall.md §5.8) -- there would be no @@ -186,7 +186,7 @@ private fun AwaitSection() { var fast by remember { mutableStateOf("") } var slow by remember { mutableStateOf("") } - Text("await g.greetNow(1) and await g.greetLater(2) — same call site, two paths underneath.") + Text("await g.greetNow(1) and await g.greetLater(2), same call site, two paths underneath.") Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Button(onClick = { fast = PythonDemo.awaitFastPath() }) { Text("fast path") } Spacer(Modifier.width(4.dp))