diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..fb6efa505 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "permissions": {} +} diff --git a/.github/workflows/android-native.yml b/.github/workflows/android-native.yml new file mode 100644 index 000000000..6ff4ff985 --- /dev/null +++ b/.github/workflows/android-native.yml @@ -0,0 +1,40 @@ +name: Android Native CI + +on: + push: + branches: [ "develop", "main" ] + pull_request: + branches: [ "develop", "main" ] + +concurrency: + # A newer push to the same ref supersedes this run; frees the shared macOS runners. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + compile: + name: Compile Android Native + # ubuntu-latest is sufficient and faster for cross-compiling to Android Native + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + cache: 'gradle' + + - name: Cache Konan + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Compile Android Native Arm64 + # Compile Android Native which includes commonMain + nativeMain + artMain + run: ./gradlew :python-multiplatform:compileKotlinAndroidNativeArm64 --console=plain diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 000000000..67c9c2c7f --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,46 @@ +name: Desktop CI + +on: + push: + branches: [ "develop", "main" ] + pull_request: + branches: [ "develop", "main" ] + +concurrency: + # A newer push to the same ref supersedes this run; frees the shared macOS runners. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Desktop Tests + # macOS runner is used to compile for iOS and is also good for Desktop. + # We use ubuntu, macos, and windows to cover the major desktop platforms. + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + # This automatically caches ~/.gradle/caches and ~/.gradle/wrapper + cache: 'gradle' + + - name: Cache Konan + uses: actions/cache@v4 + with: + # Cache Kotlin Native compiler and dependencies + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Run Desktop Tests + # Using --console=plain so logs are readable in GitHub Actions + run: ./gradlew :python-multiplatform:desktopTest --console=plain diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 000000000..0bd46c48c --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,40 @@ +name: iOS CI + +on: + push: + branches: [ "develop", "main" ] + pull_request: + branches: [ "develop", "main" ] + +concurrency: + # A newer push to the same ref supersedes this run; frees the shared macOS runners. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + compile: + name: Compile iOS Simulator Arm64 + # macos-latest is required since iOS compilation needs Xcode + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + cache: 'gradle' + + - name: Cache Konan + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Compile iOS Simulator Arm64 + # Compile iOS Simulator which includes commonMain + nativeMain + iosMain + run: ./gradlew :python-multiplatform:compileKotlinIosSimulatorArm64 --console=plain diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 000000000..f8cb97f9f --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,30 @@ +name: Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/guide + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release-sync.yml b/.github/workflows/release-sync.yml new file mode 100644 index 000000000..746a0b9de --- /dev/null +++ b/.github/workflows/release-sync.yml @@ -0,0 +1,52 @@ +name: Release sync + +# `release` is generated here, not maintained: every push to develop rebuilds it +# as one commit on top of that develop commit (develop minus the Markdown files +# that must not reach main), force-pushes it, and keeps one release -> main PR open. +on: + push: + branches: [develop] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Sync release branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Not used as a base (release is regenerated); the script reads the + # remote sha itself for --force-with-lease. + bash tools/release/sync-release.sh --source "${GITHUB_SHA}" --push + + - name: Open or update PR release -> main + env: + # Needs "Allow GitHub Actions to create and approve pull requests" in the + # repository's Actions settings. RELEASE_PR_TOKEN is optional. + GH_TOKEN: ${{ secrets.RELEASE_PR_TOKEN || github.token }} + run: | + set -eu + if ! git rev-parse -q --verify refs/heads/release >/dev/null; then exit 0; fi + if [ "$(git rev-parse release^{tree})" = "$(git rev-parse origin/main^{tree} 2>/dev/null || true)" ]; then + echo "release already equals main; nothing to PR"; exit 0 + fi + title="Release: merge release into main" + body="Automated sync of \`develop\` (${GITHUB_SHA::7}) into \`release\`. Markdown outside README.md and docs/ subfolders is removed by tools/release/sync-release.sh." + existing="$(gh pr list --head release --base main --state open --json number --jq '.[0].number // empty')" + if [ -n "$existing" ]; then + gh pr edit "$existing" --title "$title" --body "$body" + else + gh pr create --head release --base main --title "$title" --body "$body" + fi diff --git a/.github/workflows/stubs.yml b/.github/workflows/stubs.yml new file mode 100644 index 000000000..b6a7a785d --- /dev/null +++ b/.github/workflows/stubs.yml @@ -0,0 +1,149 @@ +name: Kotlin-name stubs + +# Issue #32: pythonx-compose consumes the binder's Kotlin-name `.pyi` stubs for the real Compose jars +# but cannot run this repository's Gradle build. This job runs the stub task over the Compose version the +# build resolves (`compose-plugin` in gradle/libs.versions.toml) and publishes the result. +# +# - every push to develop and every pull request: the stubs are generated, type-checked against +# tools/stubs/consumer.py with mypy (issue #31), and uploaded as the workflow artifact `kotlin-stubs`; +# - every `v*` tag: the same stubs are also attached to the tag's GitHub release as `kotlin-stubs.zip`. + +on: + push: + branches: [ "develop" ] + tags: [ "v*" ] + pull_request: + branches: [ "develop" ] + paths: + - "python-multiplatform-gradle-plugin/**" + - "ksp-fixtures/compose/**" + - "tools/stubs/**" + - "gradle/libs.versions.toml" + - ".github/workflows/stubs.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: stubs-${{ github.ref }} + cancel-in-progress: true + +jobs: + stubs: + name: Generate stubs + runs-on: ubuntu-latest + permissions: + contents: write # attaching the stubs to a tag's release + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + cache: 'gradle' + + - name: Setup Python (for mypy) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Generate the stubs over the Compose jars + run: ./gradlew :ksp-fixtures:compose:generatePythonStubs --console=plain + + - name: Type-check a consumer against them (mypy) + run: tools/stubs/check-stubs.sh ksp-fixtures/compose/build/generated/pythonStubs/desktopMain + + - name: Assemble the artifact + run: | + set -euo pipefail + compose_version="$(sed -nE 's/^compose-plugin = "([^"]+)".*/\1/p' gradle/libs.versions.toml)" + test -n "$compose_version" + out=build/kotlin-stubs + rm -rf "$out" + mkdir -p "$out" + cp -R ksp-fixtures/compose/build/generated/pythonStubs/desktopMain/. "$out/" + cat > "$out/README.md" <\`, or the + IDE's "stub path" / interpreter paths). It is checked with mypy against + \`tools/stubs/consumer.py\` in the commit above. + README + (cd build && zip -qr kotlin-stubs.zip kotlin-stubs) + + - name: Upload the stubs + uses: actions/upload-artifact@v4 + with: + name: kotlin-stubs + path: build/kotlin-stubs + if-no-files-found: error + + - name: Attach the stubs to the tag's release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 \ + || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "Kotlin-name stubs for the Compose version this tag builds against." + gh release upload "$GITHUB_REF_NAME" build/kotlin-stubs.zip --clobber + + # Issue #44: the stub layout is case-unique, so the stubs generate, extract and type-check on a + # case-insensitive filesystem too (`graphics.Shadow` the class and `graphics.shadow` the package). + stubs-macos: + name: Generate stubs (macOS, case-insensitive) + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + cache: 'gradle' + + - name: Setup Python (for mypy) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Generate the stubs over the Compose jars + run: ./gradlew :ksp-fixtures:compose:generatePythonStubs --console=plain + + - name: No two stub paths are equal under case folding + run: | + python3 - ksp-fixtures/compose/build/generated/pythonStubs/desktopMain <<'PY' + import os, sys + seen = {} + for root, dirs, files in os.walk(sys.argv[1]): + for name in dirs + files: + rel = os.path.relpath(os.path.join(root, name), sys.argv[1]) + seen.setdefault(rel.casefold(), []).append(rel) + clashes = [v for v in seen.values() if len(v) > 1] + print("clashes:", clashes) + sys.exit(1 if clashes else 0) + PY + + - name: Type-check a consumer against them (mypy) + run: tools/stubs/check-stubs.sh ksp-fixtures/compose/build/generated/pythonStubs/desktopMain diff --git a/.github/workflows/typedpython.yml b/.github/workflows/typedpython.yml new file mode 100644 index 000000000..c03f0ea4d --- /dev/null +++ b/.github/workflows/typedpython.yml @@ -0,0 +1,127 @@ +name: TypedPython CI + +# The TypedPython gate and compiler are Python (python-multiplatform-ksp/src/{main,test}/python). +# Gradle does not run them; this workflow does. +on: + push: + branches: [ "develop", "main" ] + paths: [ "python-multiplatform-ksp/**", "ksp-fixtures/app/**", ".github/workflows/typedpython.yml" ] + pull_request: + branches: [ "develop", "main" ] + paths: [ "python-multiplatform-ksp/**", "ksp-fixtures/app/**", ".github/workflows/typedpython.yml" ] + +concurrency: + # A newer push to the same ref supersedes this run; frees the shared macOS runners. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: TypedPython tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + defaults: + run: + working-directory: python-multiplatform-ksp + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Create the environment + # pyrefly is pinned exactly: the gate reads its versioned type-report format. + run: | + uv venv .venv --python 3.13 + uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest + + - name: Behaviour tests + run: .venv/bin/python -m pytest -q -m "not measurement" + + - name: Measurements (recorded, sanity bounds only) + # A shared runner is not an idle machine (AGENTS.md §8): read ratios, not absolute times. + run: .venv/bin/python -m pytest -q -s -m measurement + + sanitizers: + # SPEC N-8: ASan/UBSan runs are the safety net outside the proof. Every extension TypedPython + # builds in these tests is compiled with -fsanitize=address,undefined (TP_SANITIZE=1, see + # conftest.py) and the interpreter runs with the ASan runtime preloaded; any report fails the run. + # Linux only: macOS refuses to load the ASan runtime into the framework Python (the cgen + # sanitizer test has a launcher-based macOS path of its own). + name: TypedPython tests under ASan + UBSan (ubuntu-latest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: python-multiplatform-ksp + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Create the environment + run: | + uv venv .venv --python 3.13 + uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest + + - name: Relax ASLR entropy (ASan shadow mapping on newer kernels) + run: sudo sysctl -w vm.mmap_rnd_bits=28 || true + + - name: Self-check, the sanitizers must catch planted faults + # Builds a deliberately faulty extension (heap out-of-bounds read, signed overflow) and + # REQUIRES both reports. If this step passes, the setup below can detect faults. + run: .venv/bin/python tools/sanitizers.py selfcheck + + - name: cgen, runtime and pipeline tests, sanitized + # detect_leaks=0: CPython itself leaks at exit. LD_PRELOAD is applied to this one command + # only (tools/sanitizers.py env prints it); the gate's pyrefly runs without it. + run: | + env $(.venv/bin/python tools/sanitizers.py env) \ + .venv/bin/python -m pytest -q -m "not measurement" \ + src/test/python/test_cgen.py src/test/python/test_runtime.py src/test/python/test_pipeline.py + + app-fixture: + # #45: a TypedPython-compiled module calling Kotlin through the upcall table, in the app + # fixture's desktop JVM. buildTypedPythonBridge needs a host Python with pyrefly; without one the + # four TypedPythonKotlinCallTest tests skip. Locally that is allowed; here a skip fails the job. + # macOS only: elsewhere the JVM does not load libpython with global symbols, so the + # dynamic_lookup extension cannot resolve the C API (ksp-fixtures/app/build.gradle.kts). + name: TypedPython from Kotlin, app fixture (macos-latest) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + cache: 'gradle' + + - uses: astral-sh/setup-uv@v5 + + - name: Create the TypedPython host environment + working-directory: python-multiplatform-ksp + run: | + uv venv .venv --python 3.13 + uv pip install --python .venv/bin/python "pyrefly==1.3.2" pytest + + - name: App fixture tests + run: | + rm -rf ksp-fixtures/app/build/test-results/desktopTest + ./gradlew :ksp-fixtures:app:desktopTest --rerun --console=plain \ + -PtypedpythonPython="$PWD/python-multiplatform-ksp/.venv/bin/python" + + - name: The TypedPython tests ran (none skipped) + run: | + python3 - <<'PY' + import glob, sys, xml.etree.ElementTree as ET + cases = [c for p in glob.glob("ksp-fixtures/app/build/test-results/desktopTest/*.xml") + for c in ET.parse(p).getroot().iter("testcase") + if c.get("classname") == "fixture.app.TypedPythonKotlinCallTest"] + skipped = [c.get("name") for c in cases if c.find("skipped") is not None] + print(f"TypedPythonKotlinCallTest: {len(cases)} test(s), {len(skipped)} skipped") + if not cases or skipped: + print("skipped: " + ", ".join(skipped) if skipped else "no TypedPythonKotlinCallTest results") + sys.exit(1) + PY diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml new file mode 100644 index 000000000..a800e5dcd --- /dev/null +++ b/.github/workflows/wasm.yml @@ -0,0 +1,177 @@ +name: WasmJS CI + +# Two jobs, because the runner can do one of these two things unconditionally and the other only +# when it has been given an interpreter. +# +# The single job this replaces asked for `wasmJsNodeTest` on a runner with no CPython Emscripten +# build. Every wasm task is guarded by `onlyIf { .exists() }`, so the task SKIPped and +# the job reported **green having run zero tests** -- a tick that measured nothing, which is worse +# than a red one because nobody goes looking. See `wasmRuntimePresent` in +# python-multiplatform/build.gradle.kts. +# +# `compile` is what a runner can always do honestly, and it is named for exactly that. +# `test` runs the real suite, and runs only when there is a runtime to run it against. + +on: + push: + branches: [ "develop", "main" ] + pull_request: + branches: [ "develop", "main" ] + +concurrency: + # A newer push to the same ref supersedes this run; frees the shared macOS runners. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + compile: + name: WasmJS compile (no interpreter) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + cache: 'gradle' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Cache Konan + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-konan- + + # No test task here, deliberately. Compiling `wasmJsMain` and `wasmJsTest` needs no + # interpreter, so this is coverage the runner can actually deliver; asking for a test task it + # cannot run is what produced the empty green tick. + - name: Compile wasmJs main and test sources + run: ./gradlew :python-multiplatform:compileKotlinWasmJs :python-multiplatform:wasmJsTestClasses --console=plain + + # Says out loud what the green tick above does and does not cover, so that a reader of this + # workflow's result does not have to infer it. Drops away the moment the runtime URL exists. + - name: Say what did not run + if: vars.WASM_RUNTIME_URL == '' + run: | + { + echo "### wasmJs tests did not run" + echo + echo "This job compiled \`wasmJsMain\` and \`wasmJsTest\`. **No wasm test was executed.**" + echo + echo "Running them needs a CPython Emscripten build, which this repository publishes as" + echo "\`io.github.thisisthepy:python-multiplatform-wasm-runtime\` (a 7 MB zip built by" + echo "\`:python-multiplatform:wasmBrowserRuntimeZip\`) but has never pushed to a remote" + echo "repository -- it exists only in the maintainer's \`mavenLocal\`." + echo + echo "Set the repository variable \`WASM_RUNTIME_URL\` to a URL that serves that zip" + echo "(a GitHub release asset is enough) and the \`test\` job below will run the real" + echo "suite: 344 Node tests and 10 browser tests." + } >> "$GITHUB_STEP_SUMMARY" + echo "::warning title=wasmJs tests did not run::No CPython Emscripten runtime is available to this runner; set the WASM_RUNTIME_URL repository variable. This job compiled only." + + test: + name: WasmJS Node + browser tests + runs-on: ubuntu-latest + # The whole point of the guard: this job exists only when it can do its job. A job that does not + # run shows up grey in the checks list, which is a different claim from a green one. + if: vars.WASM_RUNTIME_URL != '' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + cache: 'gradle' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Cache Konan + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-konan- + + # The five files this unpacks are the whole interpreter as far as the test tasks are + # concerned -- python.wasm, python.mjs, the stdlib zip and the two glue modules. That is a + # property of `cpython.mjs`'s boot rather than an accident: the Node route used to reach the + # standard library through NODEFS against a CPython *source checkout*, which no artefact + # carries, and now installs the staged zip into MEMFS exactly as the browser route does. + - name: Fetch the CPython Emscripten runtime + run: | + set -euo pipefail + curl -fsSL "${{ vars.WASM_RUNTIME_URL }}" -o "$RUNNER_TEMP/wasm-runtime.zip" + mkdir -p "$RUNNER_TEMP/wasm-runtime" + unzip -q "$RUNNER_TEMP/wasm-runtime.zip" -d "$RUNNER_TEMP/wasm-runtime" + for f in python.wasm python.mjs; do + test -f "$RUNNER_TEMP/wasm-runtime/$f" \ + || { echo "::error::$f is not in the runtime zip served by WASM_RUNTIME_URL"; exit 1; } + done + ls -l "$RUNNER_TEMP/wasm-runtime" + + # `WasmCompiledWheelTest` imports a real compiled pyemscripten_2026_0 wheel. Under + # `-PrequireWasmRuntime` a missing wheel is a failure, not a skip, so fetch them first: by URL, + # each accepted only against the sha256 the script pins. + - name: Fetch the pinned wasm wheels + run: bash tools/wasm/build-cpython.sh wheels + + # `-PrequireWasmRuntime` is the second half of the guard, and it is what makes this job unable + # to repeat the original defect: if either task would skip -- no interpreter, or no + # Chromium-family browser for karma -- the build fails naming what was missing, instead of + # reporting success having run nothing. + - name: Run wasmJs tests + run: | + ./gradlew \ + :python-multiplatform:wasmJsNodeTest \ + :python-multiplatform:wasmJsBrowserTest \ + -PwasmPythonDir="$RUNNER_TEMP/wasm-runtime" \ + -PrequireWasmRuntime=true \ + --console=plain + + # Belt to the guard's braces, and it guards a different failure: a task that *runs* but + # reports nothing. A wasm `LinkError` at instantiation takes out the whole module and is + # reported as zero tests rather than as a failure, which is the shape ROADMAP §10 records for + # a missing stdlib zip in the browser ("404, zero tests reported"). + # + # The floors are the baselines ROADMAP §10 records, so they fail when coverage *shrinks* and + # stay quiet when it grows. + - name: Assert the suites were not empty + if: always() + run: | + python3 - <<'PY' + import glob, sys, xml.etree.ElementTree as ET + floors = {"wasmJsNodeTest": 344, "wasmJsBrowserTest": 10} + bad = False + for task, floor in floors.items(): + files = glob.glob(f"python-multiplatform/build/test-results/{task}/*.xml") + total = sum(int(ET.parse(f).getroot().get("tests", 0)) for f in files) + print(f"{task}: {total} tests in {len(files)} result files (floor {floor})") + if total < floor: + print(f"::error::{task} reported {total} tests, below its recorded baseline of {floor}") + bad = True + sys.exit(1 if bad else 0) + PY + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: wasm-test-results + path: python-multiplatform/build/test-results/ + if-no-files-found: error diff --git a/.gitignore b/.gitignore index d3429d555..670457d97 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,12 @@ google-services.json # Android Profiling *.hprof + +# subagent worktrees live inside the repo +.worktrees/ + +# repo-local caches: emsdk, the CPython wasm build and its staged runtime (tools/wasm/build-cpython.sh) +.caches/ + +# scratch space for agents and scripts; delete when done +.tmp/ diff --git a/README.md b/README.md index 38c0e6662..b4a7106f8 100644 --- a/README.md +++ b/README.md @@ -1,228 +1,232 @@ -# Python Multiplatform +English | [한국어](docs/locale/README_ko.md) -![Build](https://github.com/thisisthepy/toolchain/workflows/Build/badge.svg) -[![Version](https://img.shields.io/jetbrains/plugin/v/MARKETPLACE_ID.svg)](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID) -[![Downloads](https://img.shields.io/jetbrains/plugin/d/MARKETPLACE_ID.svg)](https://plugins.jetbrains.com/plugin/MARKETPLACE_ID) +
+# 🐍 python-multiplatform -### Description +**Real CPython inside Kotlin Multiplatform — Kotlin calls Python, Python calls Kotlin, on every target.** -A multiplatform solution to use Python with Kotlin interoperably. +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Kotlin Multiplatform](https://img.shields.io/badge/Kotlin-Multiplatform-7F52FF.svg?logo=kotlin&logoColor=white)](https://kotlinlang.org/docs/multiplatform.html) +[![CPython](https://img.shields.io/badge/CPython-3.14-3776AB.svg?logo=python&logoColor=white)](https://www.python.org/) +[![Platforms](https://img.shields.io/badge/platforms-JVM%20%7C%20Android%20%7C%20iOS%20%7C%20Android%20native%20%7C%20wasm%20(exp.)-lightgrey.svg)](#-platforms) -Thanks to many contributors who develop dependent packages for this python-kotlin library production. +[Guide](https://thisisthepy.github.io/python-multiplatform/) · +[Design notes](docs/design/) · +[Roadmap](docs/roadmap/ROADMAP.md) +
-#### Supporting multiplatforms: - -- Android (arm64, arm32, x86, x86_64) with [Kivy Android ToolChain](https://github.com/thisisthepy/toolchain-android) -- iOS (arm64) with [Kivy ios Toolchain](https://github.com/thisisthepy/toolchain-ios) -- masOS (universal) with [Python Standalone Builds](https://github.com/indygreg/python-build-standalone) -- Linux (x86_64) with [Python Standalone Builds](https://github.com/indygreg/python-build-standalone) -- Windows (x86_64) with [Python Standalone Builds](https://github.com/indygreg/python-build-standalone) -- WASM - Not yet supported. - - -> [!NOTE] -> ** Since Xcode only runs on macOS, you need macOS to build this repo for iOS. - - -### Template ToDo list ✨ -- [x] Bring python embed API for Kotlin/JVM targets (Windows, Linux, macOS, Android). -- [x] Bring python embed API for Kotlin/Native targets (iOS). -- [x] Python interop API (Binder) for Kotlin side. -- [ ] Kotlin interop API (Binder) for Python side. - -___ - -## Build Manually 🛠️ - -#### (1) Clone this repo - -- RC version - - - git clone https://github.com/thisisthepy/python-multiplatform-mobile PythonMultiplatformMobile - -- dev version - - - git clone https://github.com/thisisthepy/python-multiplatform-mobile@develop PythonMultiplatformMobile - -- specific release version - - - git clone https://github.com/thisisthepy/python-multiplatform-mobile@python3.13 PythonMultiplatformMobile +--- +## 💡 Why -#### (2) Build gradle project +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: -This is a Kotlin Multiplatform project targeting Android, iOS, Web, Desktop. +- 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, + so it works where reflection does not: Kotlin/Native and GraalVM native images. -* `/composeApp` is for code that will be shared across your Compose Multiplatform applications. - It contains several subfolders: - - `commonMain` is for code that’s common for all targets. - - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. - For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, - `iosMain` would be the right folder for such calls. +It is CPython itself, not a re-implementation, so C extensions keep working. -* `/iosApp` contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform, - you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project. +## ✨ Features +- 🔌 **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` / + `PySet` / `PyTuple` implementing Kotlin collection interfaces, modules, callables, conversion. +- 🚀 **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 + bindings and `.pyi` stubs, with default-argument omission and value-class support. +- ♻️ **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 + real runs ([cost table](docs/investigations/cost-table.md)). -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html), -[Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/#compose-multiplatform), -[Kotlin/Wasm](https://kotl.in/wasm/)… +## 🚀 Quick start -We would appreciate your feedback on Compose/Web and Kotlin/Wasm in the public Slack channel [#compose-web](https://slack-chats.kotlinlang.org/c/compose-web). -If you face any issues, please report them on [GitHub](https://github.com/JetBrains/compose-multiplatform/issues). +> **Not published yet.** No release is on Maven Central or JitPack today. Build from source (below); +> coordinates will be announced when a release exists. -You can open the web application by running the `:composeApp:wasmJsBrowserDevelopmentRun` Gradle task. +```bash +git clone https://github.com/thisisthepy/python-multiplatform +cd python-multiplatform +./gradlew :sample:run # desktop demo app +``` +### Kotlin → Python ---- +```kotlin +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.types.basic.PyInt +import python.multiplatform.ffi.types.collections.PyList -## Use Pre-Built Package 🧰 +fun main() { + Python3.initialize() -#### (1) Maven Repo (Release only) + // A real Python list of real Python ints, published into __main__. + val numbers = PyList.fromList(listOf(2L, 3L, 5L, 7L, 11L).map { PyInt.from(it) }) + Python3.import("__main__").setAttr("kotlin_numbers", numbers) -In your project build.gradle.kts + val globals = Python3.import("__main__").dict + val result: PyObject = Python3.eval("sum(kotlin_numbers) * 2", 258 /* Py_eval_input */, globals, globals) + println("${result.Type.name}: $result") // int: 56 - implementation("io.github.thisisthepy:python-multiplatform:0.0.1") + Python3.exec("print('hello from python')") + // No close() anywhere: each wrapper releases its reference when it is collected. +} +``` -#### (2) Jitpack (for Pre-release) +### Python → Kotlin -In your project settings.gradle.kts +Any `public` Kotlin declaration is exposed (opt out with `@PythonInternal`): - pluginManagement { - repositories { - google { - mavenContent { - includeGroupAndSubgroups("androidx") - includeGroupAndSubgroups("com.android") - includeGroupAndSubgroups("com.google") - } - } - mavenCentral() - gradlePluginPortal() - - maven { - setUrl("https://jitpack.io") // Add this line! - } - } - } +```kotlin +package org.thisisthepy.python.multiplatform.demo.bindings +class Greeter(subject: String) { + var subject: String = subject + var greetings: Long = 0 + private set -In your project build.gradle.kts + fun greet(times: Long): String { /* ... */ } + suspend fun greetNow(times: Long): String = greet(times) +} +``` - implementation("com.github.thisisthepy:python-multiplatform-mobile:0.0.1") +```python +from org.thisisthepy.python.multiplatform.demo.bindings import Greeter +g = Greeter('Kotlin') # constructs the Kotlin object +g.greet(2) # calls the Kotlin method +g.subject = 'Python' # property setter +g.greetings = 99 # AttributeError: the Kotlin setter is private -> [!TIP] -> Some tips +async def main(): + return await g.greetNow(1) # a Kotlin suspend fun, awaited +``` ---- +Both snippets come from the [`sample/`](sample/) app. The Kotlin package name is the Python module +name, unchanged. -## Usage 📑 +### Platform setup -In your main method, +
+Desktop — apply the Gradle plugin so PYTHONHOME has a stdlib ```kotlin +plugins { + kotlin("jvm") + application + id("io.github.thisisthepy.python.multiplatform.bindings") version "" +} +``` -object PythonIntegration { - val python = Python3Library() - - @JvmStatic - fun main(args: Array) { +`stagePythonHome` downloads the matching CPython build, verifies it against the release's +`SHA256SUMS`, caches it machine-wide and sets `PYTHONHOME` on `run` and `test`. A `PYTHONHOME` you set +yourself is never touched; disable staging with `pythonBindings { stagePythonHome.set(false) }`. +A Compose Desktop app packaged with `createDistributable` (or a `package*` task) carries the staged +stdlib and `libpython` in its resources and starts without `PYTHONHOME` +(`pythonBindings { packagePythonHome.set(false) }` turns that off); any other packaging can name its +prefix with `-Dpython.multiplatform.home=...`. See `docs/platforms/desktop-packaged-app.md`. +
- python!!.Py_Initialize() // Run python interprepter +
+Android — call PythonBootstrap.initialize - if (python!!.Py_IsInitialized() == 0) { - throw RuntimeException("Failed to initialize Python") - } - - val module = py!!.PyImport_ImportModule(moduleName) // Module import - if (module == null) { - throw RuntimeException("Failed to import module: $moduleName") - } +```kotlin +import python.multiplatform.env.PythonBootstrap - callFunction(mathModule, "pow", 2.0, 3.0) +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + PythonBootstrap.initialize(this) // unpacks the stdlib once, then starts CPython + } +} +``` - val func = py!!.PyObject_GetAttrString(module, funcName) +The stdlib ships in the APK and is unpacked to app-private storage on first launch +(measured 237–480 ms on API 26–36), then skipped by a stamp check (4–28 ms). Idempotent. +
+ +## 🧭 Architecture at a glance + +```mermaid +flowchart LR + subgraph Kotlin + K[Your Kotlin code] --> OM[Object model
PyObject · PyList · PyDict …] + OM --> FFI["EmbedAPI (expect)
~330 Stable ABI functions"] + T[Generated function table
KSP · Gradle artifact walker] + end + subgraph CPython + P[Your Python code] --> PX[Generated proxies
Greeter · Modifier …] + end + FFI -- "downcall
Panama · JNI · cinterop · wasm" --> CPython + PX -- "upcall
one entry point per platform" --> T + T --> K +``` - if (func == null) { - throw java.lang.RuntimeException("Failed to get function: $funcName") - } +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/). - val pyArgs = py!!.PyTuple_New(args.size) - for (i in args.indices) { - val arg = convertJavaToPython(args[i]) - println("The type of variable is ${arg::class.simpleName}") - py!!.PyTuple_SetItem(pyArgs, i, arg) - } +## 🌍 Platforms - val result = py!!.PyObject_CallObject(func, pyArgs) +| Platform | Downcall | Upcall | Status | +|---|---|---|---| +| Desktop JVM (macOS) | Panama FFM | FFM upcall stub | ✅ full suite, GraalVM native image verified | +| Desktop JVM (Linux, Windows) | Panama FFM | FFM upcall stub | 🟡 wired, never run | +| Android | JNI (`RegisterNatives`) | JNI | ✅ device-tested | +| iOS | cinterop | `@CName` | 🟡 shared suite on simulator | +| Android native | cinterop | `@CName` | 🟡 shared suite on device | +| wasmJs (browser, Node) | Emscripten CPython | `@WasmExport` | 🧪 experimental | - if (result != null) { - println(funcName + " result: " + convertPythonToJava(result)) - py!!.Py_DecRef(result) - } +## 📊 Status - py!!.Py_DecRef(pyArgs) - py!!.Py_DecRef(func) +Honest summary — see the [roadmap](docs/roadmap/ROADMAP.md) for what is left to do. - python!!.Py_DecRef(mathModule) +| Area | State | +|---|---| +| Interpreter lifecycle, object model, downcall surface | ✅ implemented | +| Object lifetime and cross-boundary cycle collection | ✅ desktop · 🟡 other targets | +| Upcalls (classes, properties, `suspend`, cancellation) | ✅ desktop · 🟡 other targets | +| Binding prebuilt jars, Compose from Python | ✅ desktop · ⏳ Android / iOS / wasm | +| Binding Kotlin/Native klibs | 🟡 scanner only | +| `.pyi` stub generation | 🟡 partial | +| Free-threaded CPython (3.14t, desktop only, opt-in `-PpythonFreeThreaded=true`) | 🟡 partial | - if (python!!.Py_IsInitialized() != 0) { - python!!.Py_Finalize() - } - } +## 🧱 The ecosystem - private fun convertJavaToPython(obj: Any): Pointer { - if (obj is Int || obj is Long) { - println("obj is converted to PyLong: $obj") - return py!!.PyLong_FromLong((obj as Number).toLong()) - } else if (obj is Float || obj is Double) { - println("obj is converted to PyFloat: $obj") - return py!!.PyFloat_FromDouble((obj as Number).toDouble()) - } else if (obj is String) { - println("obj is converted to String: $obj") - return py!!.PyUnicode_FromString(obj) - } - throw UnsupportedOperationException("Unsupported type: " + obj.javaClass) - } +| Repository | Role | +|---|---| +| **python-multiplatform** | The language boundary (this repository) | +| [pythonx-compose](https://github.com/thisisthepy/pythonx-compose) | Pythonic packages over Compose, built on this binder | +| [toolchain](https://github.com/thisisthepy/toolchain) | Gradle vocabulary for building Python apps | +| [pypackpack](https://github.com/thisisthepy/pypackpack) | Python acquisition, dependency resolution, bundling | +| [torchnative](https://github.com/thisisthepy/torchnative) | The real PyTorch ecosystem on device | +| [Gemstone](https://github.com/LogitAI/Gemstone) | Independent project planning to adopt python-multiplatform | - private fun convertPythonToJava(pyObj: Pointer): Any { - // This is a simplistic conversion. In a real-world scenario, you'd need more type checking. - val result1 = py!!.PyLong_AsLong(pyObj) - if (py!!.PyErr_Occurred() == null) { - return result1 - } - py!!.PyErr_Clear() - - val result2 = py!!.PyFloat_AsDouble(pyObj) - if (py!!.PyErr_Occurred() == null) { - return result2 - } - py!!.PyErr_Clear() - - val result3 = py!!.PyUnicode_AsUTF8(pyObj) - if (py!!.PyErr_Occurred() == null) { - return result3 - } - py!!.PyErr_Clear() - - throw UnsupportedOperationException("Unsupported type") - } +## 📖 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 +- 🗺 [Roadmap](docs/roadmap/ROADMAP.md) +- 🇰🇷 [README in Korean](docs/locale/README_ko.md) -``` - -> [!IMPORTANT] -> Somethig important +## 🤝 Contributing ---- +This project runs on intent-based, spec-driven, test-driven development: the intent bounds the spec, +the spec drives the tests, and tests are written red before the code. Read the +[contributing section of the guide](https://thisisthepy.github.io/python-multiplatform/#contributing) +before opening a pull request. Building iOS targets requires macOS with Xcode. -## Stargazers over time 🌟 +## 📄 License -[![Stargazers over time](https://starchart.cc/thisisthepy/python-multiplatform-mobile.svg?variant=adaptive)](https://starchart.cc/thisisthepy/python-multiplatform-mobile) +[MIT](LICENSE) © 2024 thisisthepy diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 000000000..6aa5c565d --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1,5 @@ +# Raw Gradle output for each run: large, and reconstructable by re-running. The JSON and TSV next +# to it are the artefact -- they are *not* ignored, because a rendered table without the cut it was +# rendered from is exactly the un-auditable number this whole harness exists to stop. +results/logs/ +__pycache__/ diff --git a/benchmarks/cost-table.sh b/benchmarks/cost-table.sh new file mode 100755 index 000000000..598623e37 --- /dev/null +++ b/benchmarks/cost-table.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# The one command that cuts docs/investigations/cost-table.md. +# +# ./benchmarks/cost-table.sh # all six targets, once each, then render +# ./benchmarks/cost-table.sh --runs 3 # three runs each, rendered as min-max +# ./benchmarks/cost-table.sh --targets desktop # one target +# ./benchmarks/cost-table.sh render # re-render the newest JSON, run nothing +# +# Prerequisites are checked below rather than assumed, because every one of them has silently +# produced a wrong or empty table at least once in this repo. +# +# This is a script and not a Gradle task deliberately: the task would have to invoke `./gradlew` +# for each target from inside a running build, and a nested Gradle invocation on the same project +# directory deadlocks on the outer build's caches rather than failing cleanly. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(dirname "$here")" + +: "${ANDROID_HOME:=${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}" +export ANDROID_HOME + +if [ ! -x "$ANDROID_HOME/platform-tools/adb" ]; then + echo "note: no adb at $ANDROID_HOME/platform-tools/adb -- the two ART targets and" >&2 + echo " androidNativeArm64 will be reported as skipped, not silently omitted." >&2 +fi + +python3 --version >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 1; } + +exec python3 "$here/cost_table.py" "$@" diff --git a/benchmarks/cost_table.py b/benchmarks/cost_table.py new file mode 100644 index 000000000..a854c36c4 --- /dev/null +++ b/benchmarks/cost_table.py @@ -0,0 +1,1132 @@ +#!/usr/bin/env python3 +""" +Cuts the cost tables in `docs/investigations/cost-table.md` for every target, in one command. + +Why this exists +--------------- +`docs/design/upcall.md` and `docs/design/downcall-design.md` both carry cost tables, and both have been +invalidated at least once by numbers that were transcribed by hand from a run whose conditions were +not written down next to them. The two failures were: + + 1. a number copied into a table with no record of the warmup it was taken at (the 3 000-warmup + round -- every figure in it turned out to be a measure of how warm the host JIT happened to be), + 2. a number taken on a machine that had other work on it (the same commit read 672 and 1076 ns in + one bisect, and the round had to be thrown away). + +So this script does two things a person doing it by hand does not reliably do: it records the +conditions in the same artefact as the numbers, and it refuses to measure a busy machine. + +It is *not* a Gradle task on purpose +------------------------------------ +The obvious shape is `./gradlew costTable`, and it does not work. The task would have to run +`./gradlew :python-multiplatform:desktopTest` for each target, and a nested Gradle invocation on the +same project directory blocks on the outer build's file-hash and execution-history locks -- it +deadlocks rather than failing. So the entry point is `benchmarks/cost-table.sh`, which drives Gradle +from outside any build. + +Usage +----- + ./benchmarks/cost-table.sh # collect every default target, then render + ./benchmarks/cost-table.sh --targets desktop # one target + ./benchmarks/cost-table.sh --runs 3 # three runs, rendered as min-max + python3 benchmarks/cost_table.py render # re-render from the newest JSON, no runs + +Run `--help` for the rest. +""" + +from __future__ import annotations + +import argparse +import datetime +import glob +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from typing import Any + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RESULTS_DIR = os.path.join(REPO, "benchmarks", "results") +DOC = os.path.join(REPO, "docs", "investigations", "cost-table.md") +MARK_BEGIN = "" +MARK_END = "" + +MODULE_BUILD = os.path.join(REPO, "python-multiplatform", "build") +BENCHMARK_TEST_SRC = os.path.join( + REPO, "python-multiplatform", "src", "commonTest", "kotlin", + "python", "multiplatform", "overhead", "BenchmarkTest.kt", +) + +# The 1-minute load average above which collection refuses to run. Eight cores here; anything at or +# above 4 means at least half the machine belongs to someone else, and the numbers below it are +# already the noisiest thing in this repo's history. +LOAD_CEILING = 4.0 + + +# ===================================================================================== +# Targets +# ===================================================================================== + +@dataclass +class Target: + name: str + task: str + #: "gradle-xml" -- JUnit XML under build/test-results/, benchmark output in + #: "agp-logcat" -- AGP's androidTest-results; counts in its XML, benchmark output only in the + #: per-test logcat files it writes beside it (its XML has no -- + #: checked, 251 testcases and 0 system-out nodes) + kind: str + results: str + #: AVD this target must run on, or None for a host target. + avd: str | None = None + #: Included in the default set. The 7th target below is real but off by default. + default: bool = True + notes: str = "" + + +TARGETS: list[Target] = [ + Target( + "desktop", ":python-multiplatform:desktopTest", "gradle-xml", + "python-multiplatform/build/test-results/desktopTest", + ), + Target( + "iosSimulatorArm64", ":python-multiplatform:iosSimulatorArm64Test", "gradle-xml", + "python-multiplatform/build/test-results/iosSimulatorArm64Test", + notes="boots a simulator through simctl; needs Xcode licence acceptance", + ), + Target( + "wasmJs", ":python-multiplatform:wasmJsNodeTest", "gradle-xml", + "python-multiplatform/build/test-results/wasmJsNodeTest", + notes="runs under Node", + ), + Target( + "androidNativeArm64", ":python-multiplatform:androidNativeArm64Test", "gradle-xml", + "python-multiplatform/build/test-results/androidNativeArm64Test", + avd="pmp_api36", + notes="pushes the test binary to the device; the task writes the JUnit XML itself", + ), + Target( + "artApi26", ":python-multiplatform:connectedDebugAndroidTest", "agp-logcat", + "python-multiplatform/build/outputs/androidTest-results/connected/debug", + avd="pmp_api26", + ), + Target( + "artApi36", ":python-multiplatform:connectedDebugAndroidTest", "agp-logcat", + "python-multiplatform/build/outputs/androidTest-results/connected/debug", + avd="pmp_api36", + ), + # Not in the default six, but the docs' upcall table has an androidNative row per API level, so + # the second one has to be reachable by name. + Target( + "androidNativeArm64Api26", ":python-multiplatform:androidNativeArm64Test", "gradle-xml", + "python-multiplatform/build/test-results/androidNativeArm64Test", + avd="pmp_api26", default=False, + ), +] + +BY_NAME = {t.name: t for t in TARGETS} +DEFAULT_TARGETS = [t.name for t in TARGETS if t.default] + + +# ===================================================================================== +# Parsing the benchmark output +# ===================================================================================== + +# `Benchmark.printReport()`: "PyLong_FromLongLong | 100000 | 124.70" +REPORT_ROW = re.compile(r"^(?P\S.*?)\s+\|\s+(?P\d+)\s+\|\s+(?P-?[\d.]+)\s*$") +REPORT_HEAD = re.compile(r"^Operation\s+\|\s+Iterations\s+\|\s+ns / op\s*$") + +# The block reports: "--- Upcall boundary cost: MacOS 15.5 (arm64) / JVM 21.0.8 ---" +BLOCK_TITLE = re.compile(r"^-{3} (?P.+?) -{3}\s*$") +RULE = re.compile(r"^-{10,}\s*$") +# "iterations per loop: 10000, warmup: 100000" / "iterations per row: 10000 x 3 (best taken), warmup: 5000; ..." +CONDITION_LINE = re.compile(r"^\s*iterations\b.*warmup\s*[:=]", re.IGNORECASE) +# " upcall to Kotlin 510.31 ns" | " ...ratio 3.71x" +# +# The separator is *not* required, because in the output as it stands there frequently is none: +# `UpcallBoundaryCostTest.report()` pads its labels to 54 columns and five of its labels are longer +# than that, so the value is printed hard against the label -- +# +# " PyObject_CallObject on a Python def (downcall, same shape)137.06 ns" +# +# and a parser that insists on whitespace silently drops exactly the five downcall rows the +# downcall table is made of. (That is a defect in the test's formatting, reported rather than fixed +# here -- fixing it would edit a `commonTest` file, and this has to read output that already +# exists in any case.) The value is therefore anchored on the trailing unit and the lookbehind +# keeps a lazy label from eating the value's leading digits: without it, "...each) 110.61 ns" +# parses as label "...each) 1", value 10.61. +_VALUE = r"(?<![\d.])(?P<value>-?\d+(?:\.\d+)?)" +METRIC_NS = re.compile(rf"^\s{{2,}}(?P<label>.*?){_VALUE}\s*(?P<unit>ns|us|ms)\s*$") +METRIC_X = re.compile(rf"^\s{{2,}}(?P<label>.*?){_VALUE}x\s*$") + +# logcat: "08-12 22:33:41.484 5710 5724 I System.out: PyLong_FromLongLong | 100000 | 1250.80" +LOGCAT = re.compile( + r"^\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\s+\d+\s+\d+\s+[VDIWEFS]\s+System\.out:\s?(?P<rest>.*)$" +) + + +def normalise(label: str) -> str: + """A metric key that survives the padding and the leading '...' continuation markers.""" + return re.sub(r"\s+", " ", label.strip().lstrip(".").strip()).lower() + + +def parse_benchmark_reports(lines: list[str]) -> list[dict[str, Any]]: + """Rows printed by `Benchmark.printReport()`. One dict per row.""" + rows: list[dict[str, Any]] = [] + inside = False + for line in lines: + if REPORT_HEAD.match(line): + inside = True + continue + if not inside: + continue + if RULE.match(line): + continue + m = REPORT_ROW.match(line) + if m: + rows.append({ + "source": "Benchmark.printReport", + "label": m.group("name").strip(), + "key": normalise(m.group("name")), + "iterations": int(m.group("iters")), + "value": float(m.group("ns")), + "unit": "ns", + }) + elif line.strip() == "": + inside = False + return rows + + +def parse_blocks(lines: list[str]) -> list[dict[str, Any]]: + """ + The `--- Title: platform ---` reports (`UpcallBoundaryCostTest`, `GeneratedProxyCostTest`). + + Deliberately generic rather than a list of known labels: a test that adds a row should show up + in the JSON without this script being edited, and the renderer -- not the parser -- is where the + doc tables choose which labels they want. + """ + blocks: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + section = "" + for line in lines: + m = BLOCK_TITLE.match(line) + if m: + title = m.group("title") + # "Upcall boundary cost: MacOS 15.5 (arm64) / JVM 21.0.8" -- the tail is the platform's + # own account of itself, which is the most trustworthy device identifier available. + name, _, plat = title.partition(": ") + current = { + "block": name.strip(), + "platform": plat.strip() or None, + "conditions": {}, + "metrics": [], + } + blocks.append(current) + section = "" + continue + if current is None: + continue + if RULE.match(line): + current = None + continue + if CONDITION_LINE.match(line): + for key, value in re.findall(r"([A-Za-z][A-Za-z ]*?)\s*:\s*([^,;]+)", line): + current["conditions"][key.strip()] = value.strip() + continue + for pattern, unit in ((METRIC_NS, None), (METRIC_X, "x")): + m2 = pattern.match(line) + if m2: + current["metrics"].append({ + "source": current["block"], + "section": section, + "label": m2.group("label").strip(), + "key": normalise(m2.group("label")), + "value": float(m2.group("value")), + "unit": unit or m2.groupdict().get("unit", "ns"), + }) + break + else: + stripped = line.strip() + # An un-indented, un-numbered line inside a block is that block's section heading. + if stripped and not line.startswith(" "): + section = stripped + return blocks + + +def read_benchmark_test_warmups() -> dict[str, int]: + """ + `BenchmarkTest` does not print the warmup it used, so it is read out of the source at the commit + being measured. Quoting a benchmark without its warmup is exactly what invalidated the last two + tables, so this is not optional -- if it cannot be read, that fact is recorded rather than + guessed. + """ + out: dict[str, int] = {} + try: + text = open(BENCHMARK_TEST_SRC, encoding="utf-8").read() + except OSError as exc: + return {"error": str(exc)} # type: ignore[dict-item] + for name in ("WARMUP", "WARMUP_BULK"): + m = re.search(rf"const val {name}\s*=\s*([\d_]+)", text) + if m: + out[name] = int(m.group(1).replace("_", "")) + return out + + +# ===================================================================================== +# Reading a target's results off disk +# ===================================================================================== + +def _count(xml_files: list[str]) -> dict[str, int]: + tests = failures = skipped = errors = 0 + for f in xml_files: + try: + root = ET.parse(f).getroot() + except ET.ParseError: + continue + roots = [root] if root.tag == "testsuite" else root.findall(".//testsuite") + for suite in roots: + tests += int(suite.attrib.get("tests", 0)) + failures += int(suite.attrib.get("failures", 0)) + skipped += int(suite.attrib.get("skipped", 0)) + errors += int(suite.attrib.get("errors", 0)) + return {"tests": tests, "failures": failures, "skipped": skipped, "errors": errors} + + +def _stdout_from_xml(xml_files: list[str]) -> list[tuple[str, list[str]]]: + """ + (origin test class, its captured stdout), one entry per suite. + + Grouped by origin rather than concatenated because **the warmup is a property of the test class, + not of the target**: `BenchmarkTest` warms 100 000, `reflection/UpcallOverheadTest` takes + `Benchmark.run`'s 1 000 default, `GeneratedProxyCostTest` warms 5 000. All three print through + the same `Benchmark.printReport`, so a flat list of rows would put three different warmups in + one column under one warmup caption -- which is the exact error that invalidated this table + once already. + """ + groups: list[tuple[str, list[str]]] = [] + for f in sorted(xml_files): + try: + root = ET.parse(f).getroot() + except ET.ParseError: + continue + default = os.path.basename(f).removeprefix("TEST-").removesuffix(".xml") + for suite in ([root] if root.tag == "testsuite" else root.findall(".//testsuite")): + lines: list[str] = [] + for node in suite.iter(): + if node.tag in ("system-out", "system-err") and node.text: + lines.extend(node.text.splitlines()) + if lines: + groups.append((suite.attrib.get("name") or default, lines)) + return groups + + +def harvest(target: Target) -> dict[str, Any]: + """Everything readable out of one finished run of one target.""" + root = os.path.join(REPO, target.results) + if target.kind == "gradle-xml": + xml_files = sorted(glob.glob(os.path.join(root, "**", "*.xml"), recursive=True)) + groups = _stdout_from_xml(xml_files) + devices = [] + else: # agp-logcat + # AGP names both its XML and its per-device directory after the *device* + # ("pmp_api26(AVD) - 8.0.0"), and it writes one of each per attached device. ANDROID_SERIAL + # is supposed to leave exactly one, but if it ever does not, reading both would silently + # mix two emulators' numbers into one target's row -- so the AVD is matched explicitly. + def mine(path: str) -> bool: + return target.avd is None or os.path.basename(path).startswith( + ("TEST-" + target.avd, target.avd)) + + xml_files = sorted(f for f in glob.glob(os.path.join(root, "TEST-*.xml")) if mine(f)) + devices = sorted( + d for d in glob.glob(os.path.join(root, "*")) if os.path.isdir(d) and mine(d) + ) + groups = [] + for device in devices: + for f in sorted(glob.glob(os.path.join(device, "logcat-*.txt"))): + # "logcat-<fully.qualified.Class>-<method>.txt"; neither part may contain a dash. + stem = os.path.basename(f).removeprefix("logcat-").removesuffix(".txt") + origin = stem.rsplit("-", 1)[0] + lines = [] + with open(f, encoding="utf-8", errors="replace") as fh: + for raw in fh: + m = LOGCAT.match(raw.rstrip("\n")) + if m: + lines.append(m.group("rest")) + if lines: + # One file is one test; terminate it so a block cannot run past its own test. + groups.append((origin, lines + [""])) + + rows: list[dict[str, Any]] = [] + blocks: list[dict[str, Any]] = [] + for origin, lines in groups: + for row in parse_benchmark_reports(lines): + row["origin"] = origin + rows.append(row) + for block in parse_blocks(lines): + block["origin"] = origin + blocks.append(block) + return { + "suite": _count(xml_files), + "resultFiles": len(xml_files), + "deviceDirs": [os.path.basename(d) for d in devices], + "rows": rows, + "blocks": blocks, + } + + +# ===================================================================================== +# Devices +# ===================================================================================== + +def adb_path() -> str | None: + home = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") + if home: + candidate = os.path.join(home, "platform-tools", "adb") + if os.path.isfile(candidate): + return candidate + return shutil.which("adb") + + +def attached_devices() -> dict[str, str]: + """serial -> AVD name (or the serial again, for a physical device).""" + adb = adb_path() + if not adb: + return {} + try: + out = subprocess.run([adb, "devices"], capture_output=True, text=True, timeout=30).stdout + except (OSError, subprocess.SubprocessError): + return {} + found: dict[str, str] = {} + for line in out.splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[1] == "device": + serial = parts[0] + name = serial + try: + r = subprocess.run([adb, "-s", serial, "emu", "avd", "name"], + capture_output=True, text=True, timeout=30) + first = r.stdout.strip().splitlines() + if first and first[0].strip() and first[0].strip() != "KO": + name = first[0].strip() + except (OSError, subprocess.SubprocessError): + pass + found[serial] = name + return found + + +def device_props(serial: str) -> dict[str, str]: + adb = adb_path() + props = {} + if not adb: + return props + for prop in ("ro.build.version.sdk", "ro.build.version.release", + "ro.product.cpu.abi", "ro.product.model"): + try: + r = subprocess.run([adb, "-s", serial, "shell", "getprop", prop], + capture_output=True, text=True, timeout=30) + props[prop] = r.stdout.strip() + except (OSError, subprocess.SubprocessError): + pass + return props + + +def booted_simulators() -> list[str]: + if not shutil.which("xcrun"): + return [] + try: + r = subprocess.run(["xcrun", "simctl", "list", "devices", "booted"], + capture_output=True, text=True, timeout=60) + except (OSError, subprocess.SubprocessError): + return [] + return [ln.strip() for ln in r.stdout.splitlines() if ln.strip().startswith(("iPhone", "iPad"))] + + +# ===================================================================================== +# Host conditions +# ===================================================================================== + +def loadavg() -> list[float]: + try: + return [round(v, 2) for v in os.getloadavg()] + except OSError: + return [] + + +def git(*args: str) -> str: + try: + return subprocess.run(["git", "-C", REPO, *args], capture_output=True, + text=True, timeout=60).stdout.strip() + except (OSError, subprocess.SubprocessError): + return "" + + +def host_facts() -> dict[str, Any]: + def sysctl(key: str) -> str: + try: + return subprocess.run(["sysctl", "-n", key], capture_output=True, + text=True, timeout=30).stdout.strip() + except (OSError, subprocess.SubprocessError): + return "" + + return { + "os": f"{platform.system()} {platform.release()}", + "machine": platform.machine(), + "cpu": sysctl("machdep.cpu.brand_string") or platform.processor(), + "cores": os.cpu_count(), + "cpuBrandCores": sysctl("hw.ncpu"), + } + + +# ===================================================================================== +# Collect +# ===================================================================================== + +def clean_results(target: Target) -> None: + """ + AGENTS.md: a crashed run leaves the previous run's XML behind and it gets counted as this run's. + The same trap applies to the parsed numbers, and worse -- a stale XML yields a *plausible* + benchmark row from a different commit. + """ + root = os.path.join(REPO, target.results) + if os.path.isdir(root): + shutil.rmtree(root) + + +def run_target(target: Target, run_index: int, args: argparse.Namespace) -> dict[str, Any]: + stamp = time.strftime("%Y%m%d-%H%M%S") + log = os.path.join(RESULTS_DIR, "logs", f"{target.name}-run{run_index}-{stamp}.log") + os.makedirs(os.path.dirname(log), exist_ok=True) + + record: dict[str, Any] = { + "target": target.name, + "task": target.task, + "run": run_index, + "requiresDevice": target.avd is not None, + "notes": target.notes, + "log": os.path.relpath(log, REPO), + } + + env = dict(os.environ) + if target.avd: + devices = attached_devices() + serial = next((s for s, name in devices.items() if name == target.avd), None) + if serial is None: + record.update({ + "status": "skipped", + "reason": ( + f"AVD '{target.avd}' is not attached. " + f"Attached now: {', '.join(f'{s}={n}' for s, n in devices.items()) or 'none'}." + ), + "hint": ( + f"$ANDROID_HOME/emulator/emulator -avd {target.avd} " + f"-no-snapshot-save -no-boot-anim & # then re-run this target" + ), + }) + # Loud, not silent: an absent emulator has to be visible in the run's own output, or + # the rendered table looks like the target was measured and came out blank. + print(f" !! SKIPPED {target.name}: {record['reason']}", flush=True) + print(f" to fix: {record['hint']}", flush=True) + return record + record["device"] = {"serial": serial, "avd": target.avd, "props": device_props(serial)} + env["ANDROID_SERIAL"] = serial + + if target.kind == "agp-logcat": + # AGP writes one subdirectory per attached device. Pinning ANDROID_SERIAL is what makes + # "one emulator at a time" true, and clearing the parent is what stops the other emulator's + # directory from a previous run being read as this one's. + clean_results(target) + else: + clean_results(target) + + command = [ + os.path.join(REPO, "gradlew"), target.task, + "--console=plain", + # `--rerun` and not `--tests`: narrowing the suite changes what is being measured (this + # repo lost a bisect round to exactly that), and without `--rerun` Gradle reports the + # previous run's XML as UP-TO-DATE. + "--rerun", + ] + if target.avd and target.task.endswith("androidNativeArm64Test"): + command.append(f"-PandroidNativeTestSerial={record['device']['serial']}") + + record["command"] = " ".join(command) + record["loadBefore"] = loadavg() + simulators_before = set(booted_simulators()) if "ios" in target.name.lower() else set() + + print(f" -> {target.name}: {' '.join(command)}", flush=True) + started = time.time() + with open(log, "w", encoding="utf-8") as fh: + proc = subprocess.run(command, cwd=REPO, env=env, stdout=fh, + stderr=subprocess.STDOUT, text=True) + record["wallSeconds"] = round(time.time() - started, 1) + record["exitCode"] = proc.returncode + record["loadAfter"] = loadavg() + + if simulators_before is not None and "ios" in target.name.lower(): + after = booted_simulators() + record["simulators"] = { + "bootedAfter": after, + "newlyBooted": [s for s in after if s not in simulators_before], + } + + record.update(harvest(target)) + suite = record.get("suite", {}) + + # `ANDROID_SERIAL` is what pins `connectedDebugAndroidTest` to one emulator, and if AGP ever + # stops honouring it the failure is silent: a second device's directory appears and half this + # target's rows come from the wrong API level. Checked rather than trusted. + if target.kind == "agp-logcat": + found = record.get("deviceDirs", []) + if len(found) != 1: + record["deviceDirWarning"] = ( + f"expected exactly one result directory for AVD '{target.avd}', found " + f"{found or 'none'} -- ANDROID_SERIAL may not have pinned the run" + ) + print(f" !! {record['deviceDirWarning']}", flush=True) + record["status"] = "ok" if proc.returncode == 0 else "failed" + if not record["rows"] and not record["blocks"]: + record["status"] = "no-benchmark-output" + record["reason"] = ( + "the task finished but no benchmark output was found in its results; " + f"see {record['log']}" + ) + print( + f" exit={proc.returncode} {record['wallSeconds']}s " + f"tests={suite.get('tests')} failures={suite.get('failures')} " + f"skipped={suite.get('skipped')} rows={len(record['rows'])} blocks={len(record['blocks'])}", + flush=True, + ) + return record + + +def collect(args: argparse.Namespace) -> str: + names = args.targets or DEFAULT_TARGETS + unknown = [n for n in names if n not in BY_NAME] + if unknown: + sys.exit(f"unknown target(s): {', '.join(unknown)}\nknown: {', '.join(BY_NAME)}") + + load = loadavg() + guard = "ok" + if load and load[0] > args.load_ceiling: + if not args.allow_busy: + sys.exit( + f"1-minute load average is {load[0]}, ceiling is {args.load_ceiling}.\n" + "This machine has other work on it and the numbers would be a measure of that " + "work. In this repo the same commit has read 672 and 1076 ns under load and a\n" + "whole bisect round had to be discarded.\n" + "Wait for the machine to go quiet, or pass --allow-busy to record the run as " + "contaminated." + ) + guard = "overridden" + print(f"!! collecting at load {load[0]} (> {args.load_ceiling}) -- " + "every number in this run is marked contaminated", flush=True) + + os.makedirs(RESULTS_DIR, exist_ok=True) + report: dict[str, Any] = { + "schema": 2, + "capturedAt": datetime.datetime.now().astimezone().isoformat(timespec="seconds"), + "commit": git("rev-parse", "HEAD"), + "commitSubject": git("log", "-1", "--pretty=%s"), + "branch": git("rev-parse", "--abbrev-ref", "HEAD"), + "workingTreeDirty": bool(git("status", "--porcelain")), + "worktree": REPO, + "host": host_facts(), + "loadAtStart": load, + "loadGuard": guard, + "loadCeiling": args.load_ceiling, + "contaminated": guard == "overridden", + "runsRequested": args.runs, + "targetsRequested": names, + "benchmarkTestWarmup": read_benchmark_test_warmups(), + "runs": [], + } + + # Sequential, always, and device targets one emulator at a time. Two targets measured at once + # measure each other; there are two emulators attached and only one may be driven at a time. + for run_index in range(1, args.runs + 1): + for name in names: + print(f"[{run_index}/{args.runs}] {name}", flush=True) + report["runs"].append(run_target(BY_NAME[name], run_index, args)) + + report["loadAtEnd"] = loadavg() + out = args.out or os.path.join( + RESULTS_DIR, f"cost-table-{time.strftime('%Y%m%d-%H%M%S')}.json") + with open(out, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, sort_keys=False) + fh.write("\n") + write_tsv(report, os.path.splitext(out)[0] + ".tsv") + print(f"\nwrote {os.path.relpath(out, REPO)}", flush=True) + return out + + +def write_tsv(report: dict[str, Any], path: str) -> None: + """One row per measured quantity. Long format, so `sort`/`join`/`awk` all work on it.""" + columns = ["target", "run", "status", "origin", "source", "section", "label", + "value", "unit", "iterations", "platform", "commit", "contaminated"] + with open(path, "w", encoding="utf-8") as fh: + fh.write("\t".join(columns) + "\n") + for run in report["runs"]: + base = { + "target": run["target"], + "run": run["run"], + "status": run.get("status", ""), + "commit": report["commit"][:12], + "contaminated": str(report["contaminated"]).lower(), + } + for row in run.get("rows", []): + fh.write("\t".join(str(x) for x in [ + base["target"], base["run"], base["status"], row.get("origin", ""), + row["source"], "", + row["label"], row["value"], row["unit"], row.get("iterations", ""), + "", base["commit"], base["contaminated"], + ]) + "\n") + for block in run.get("blocks", []): + for metric in block["metrics"]: + fh.write("\t".join(str(x) for x in [ + base["target"], base["run"], base["status"], block.get("origin", ""), + metric["source"], + metric["section"], metric["label"], metric["value"], metric["unit"], + block["conditions"].get("iterations per loop", ""), + block.get("platform") or "", base["commit"], base["contaminated"], + ]) + "\n") + + +# ===================================================================================== +# Render +# ===================================================================================== + +UPCALL_BLOCK = "Upcall boundary cost" +REPORT = "Benchmark.printReport" + +#: The test class each doc table is entitled to read. Rows printed through the same +#: `Benchmark.printReport` by a *different* class were warmed differently and are not comparable +#: with these, so they are shown separately with their origin named. +ORIGIN_BENCHMARK_TEST = "overhead.BenchmarkTest" + +UPCALL_COLUMNS = [ + ("upcall", "upcall to kotlin"), + ("downcall, same shape", "pyobject_callobject on a python def (downcall, same shape)"), + ("trampoline alone", "upcalltrampoline.invoke from kotlin (no shim, no boundary)"), + ("upcall / downcall", "upcall / downcall of the same shape"), + ("upcall / trampoline", "upcall / trampoline alone, gil held (what the shim adds)"), +] + +DOWNCALL_COLUMNS = [ + ("empty `withPython` scope", UPCALL_BLOCK, "python3.withpython { } with nothing in it, for scale"), + ("`Py_IncRef` + `Py_DecRef`", UPCALL_BLOCK, "py_incref + py_decref (2 downcalls, a gil scope each)"), + ("`PyObject_CallObject`", UPCALL_BLOCK, "pyobject_callobject on a python def (downcall, same shape)"), + ("`PyUnicode_FromString`, 8 chars", REPORT, "pyunicode_fromstring (8 chars)"), +] + +CONTROL_COLUMNS = [ + ("empty Python loop", "empty python loop"), + ("pure-Python callee", "pure-python callee, same shape"), + ("GIL-held trampoline", "the same, with the gil already held by the caller"), +] + + +def _samples(runs: list[dict[str, Any]], target: str, source: str, key: str, + origin: str | None = None) -> list[float]: + values: list[float] = [] + for run in runs: + if run["target"] != target: + continue + if source == REPORT: + values += [r["value"] for r in run.get("rows", []) + if r["key"] == key and (origin is None or origin in r.get("origin", ""))] + else: + for block in run.get("blocks", []): + if block["block"] == source and (origin is None or origin in block.get("origin", "")): + values += [m["value"] for m in block["metrics"] if m["key"] == key] + return values + + +def _fmt_range(values: list[float], unit: str) -> str: + if not values: + return "—" + lo, hi = min(values), max(values) + suffix = "x" if unit == "x" else " ns" + if abs(hi - lo) < 0.005: + return f"{lo:.2f}{suffix}" + return f"{lo:.2f}–{hi:.2f}{suffix}" + + +def _status_of(runs: list[dict[str, Any]], target: str) -> tuple[str, str]: + mine = [r for r in runs if r["target"] == target] + if not mine: + return "not requested", "" + statuses = {r.get("status", "?") for r in mine} + reason = next((r.get("reason", "") for r in mine if r.get("reason")), "") + if statuses == {"ok"}: + return "ok", "" + return "/".join(sorted(statuses)), reason + + +def _table(header: list[str], rows: list[list[str]]) -> list[str]: + out = ["| " + " | ".join(header) + " |", + "|" + "|".join("---" for _ in header) + "|"] + out += ["| " + " | ".join(r) + " |" for r in rows] + return out + + +def render_markdown(report: dict[str, Any]) -> str: + runs = report["runs"] + targets = report.get("targetsRequested") or DEFAULT_TARGETS + lines: list[str] = [] + + 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')}`._") + lines.append("") + + if report.get("mixedCommits"): + lines += [ + "> ## ⚠ Rows in this table come from different commits", + ">", + "> Merged with `--allow-mixed` across " + + ", ".join(f"`{c[:12]}`" for c in report["mixedCommits"]) + + ". Rows from different trees are not comparable with each other; see the source list " + "below for which cut each target came from.", + "", + ] + + if report.get("contaminated"): + lines += [ + "> ## ⚠ These numbers are contaminated and must not be quoted", + ">", + f"> Collected at a 1-minute load average of **{report['loadAtStart'][0]}** with " + f"`--allow-busy`, against a ceiling of {report['loadCeiling']}. Under load this repo " + "has read 672 and 1076 ns for the same commit. Re-cut on a quiet machine before any " + "figure below is used for anything.", + "", + ] + + # ---- conditions ---------------------------------------------------------------- + lines += ["### Conditions", "", + "Every number below is a function of these. A cost table without them is a record of " + "whatever else was running.", ""] + host = report["host"] + warm = report.get("benchmarkTestWarmup", {}) + lines += [ + 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" + 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" + if len(report.get("sources") or []) > 1 else ""), + 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)", + "- **suite scope** full task, never `--tests`-filtered: narrowing the suite changes what is " + "being measured", + "", + ] + + sources = report.get("sources") or [] + if len(sources) > 1: + 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")), + str(s.get("loadAtStart")), "yes" if s.get("contaminated") else "no", + ", ".join(f"`{t}`" for t in (s.get("targets") or []))] for s in sources]) + lines.append("") + + rows = [] + for name in targets: + target = BY_NAME.get(name) + status, reason = _status_of(runs, name) + mine = [r for r in runs if r["target"] == name] + suite = mine[0].get("suite", {}) if mine else {} + platform_name = "" + conditions = "" + for run in mine: + for block in run.get("blocks", []): + # Every block that carries one reports the same thing -- the platform's own account + # of itself -- so any of them will do if the upcall block is missing, which is what + # a target that did not reach `UpcallBoundaryCostTest` looks like. + platform_name = platform_name or (block.get("platform") or "") + if block["block"] == UPCALL_BLOCK: + platform_name = block.get("platform") or platform_name + conditions = ", ".join(f"{k}: {v}" for k, v in block["conditions"].items()) + device = "" + for run in mine: + if run.get("device"): + props = run["device"].get("props", {}) + device = (f"`{run['device']['avd']}` ({run['device']['serial']}, " + f"API {props.get('ro.build.version.sdk', '?')}, " + f"{props.get('ro.product.cpu.abi', '?')})") + for sim in run.get("simulators", {}).get("bootedAfter", [])[:1]: + 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 "—") + 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 "—", + counts, + wall or "—", + conditions or "—", + ]) + lines += _table( + ["target", "task", "status", "device / simulator", "platform, self-reported", + "tests/fail/skip", "wall s", "loop counts"], rows) + lines.append("") + + notes = [(n, _status_of(runs, n)[1]) for n in targets] + 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.append("") + + # ---- upcall -------------------------------------------------------------------- + lines += ["### One upcall, per target", "", + "`UpcallBoundaryCostTest` (`commonTest`), all baselines measured in the same run as " + "the upcall they price.", ""] + rows = [] + for name in targets: + cells = [f"`{name}`"] + for _, key in UPCALL_COLUMNS: + values = _samples(runs, name, UPCALL_BLOCK, key) + unit = "x" if "/" in key else "ns" + cells.append(_fmt_range(values, unit)) + rows.append(cells) + 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 " + "boundary moved or because the whole host did:", ""] + rows = [] + for name in targets: + cells = [f"`{name}`"] + for _, key in CONTROL_COLUMNS: + cells.append(_fmt_range(_samples(runs, name, UPCALL_BLOCK, key), "ns")) + rows.append(cells) + lines += _table(["target"] + [c for c, _ in CONTROL_COLUMNS], rows) + lines.append("") + + # ---- downcall ------------------------------------------------------------------ + lines += ["### One downcall, per target", "", + "The first three columns are `UpcallBoundaryCostTest`'s own comparison basis read on " + "its own; the fourth is `BenchmarkTest`'s 8-character string row from the same suite " + "execution.", ""] + rows = [] + for name in targets: + cells = [f"`{name}`"] + for _, source, key in DOWNCALL_COLUMNS: + origin = ORIGIN_BENCHMARK_TEST if source == REPORT else None + cells.append(_fmt_range(_samples(runs, name, source, key, origin), "ns")) + rows.append(cells) + lines += _table(["target"] + [c for c, _, _ in DOWNCALL_COLUMNS], rows) + lines.append("") + + # ---- every BenchmarkTest row --------------------------------------------------- + def row_labels(match: bool) -> list[tuple[str, str, int, str]]: + out: list[tuple[str, str, int, str]] = [] + seen: set[tuple[str, str]] = set() + for run in runs: + for row in run.get("rows", []): + origin = row.get("origin", "") + if (ORIGIN_BENCHMARK_TEST in origin) != match: + continue + ident = (origin, row["key"]) + if ident not in seen: + seen.add(ident) + out.append((row["label"], row["key"], row.get("iterations", 0), origin)) + return out + + lines += ["### `BenchmarkTest`, every row", "", + "The whole of `overhead/BenchmarkTest`, one column per target. Iteration counts are " + "the test's own; the warmup is the `WARMUP` above for every row except the " + "≥4096-char string ones, which use `WARMUP_BULK`.", ""] + rows = [] + for label, key, iterations, _ in row_labels(True): + cells = [f"`{label}`", f"{iterations:,}"] + for name in targets: + cells.append(_fmt_range( + _samples(runs, name, REPORT, key, ORIGIN_BENCHMARK_TEST), "ns")) + rows.append(cells) + if rows: + lines += _table(["row", "iterations"] + [f"`{n}`" for n in targets], rows) + else: + lines.append("_No `BenchmarkTest` rows were captured in this cut._") + lines.append("") + + # ---- everything else that printed through the same harness --------------------- + others = row_labels(False) + if others: + lines += [ + "### Other benchmark rows, by the test that printed them", "", + "These also come through `Benchmark.printReport`, and they are **not comparable with " + "the table above or with each other**: each of these classes chooses its own warmup " + "(`Benchmark.run`'s default is 1 000; `GeneratedProxyCostTest` uses 5 000), and some " + "run on one target only. They are listed with their origin so a figure cannot be " + "lifted out of here as if it shared the conditions above.", "", + ] + rows = [] + for label, key, iterations, origin in others: + cells = [f"`{origin.rsplit('.', 1)[-1]}`", f"`{label}`", f"{iterations:,}"] + for name in targets: + cells.append(_fmt_range(_samples(runs, name, REPORT, key, origin), "ns")) + rows.append(cells) + lines += _table(["printed by", "row", "iterations"] + [f"`{n}`" for n in targets], rows) + lines.append("") + + return "\n".join(lines) + + +def merge(paths: list[str], allow_mixed: bool) -> dict[str, Any]: + """ + Several cuts into one table. + + This exists because of how the machine actually is: there are two emulators and only one may be + driven at a time, so a full six-target table is often assembled from a host-target cut and one + or two device cuts taken minutes or hours apart. Merging them is fine *if they are the same + commit* and a lie if they are not, so that is checked rather than assumed -- a table whose rows + come from different trees is precisely the failure `downcall-design.md` spent a section + (`Closed: repository drift, not run-to-run noise`) tracking down. + """ + reports = [] + for path in paths: + with open(path, encoding="utf-8") as fh: + report = json.load(fh) + report["_path"] = os.path.relpath(path, REPO) + reports.append(report) + + commits = {r.get("commit", "?") for r in reports} + if len(commits) > 1 and not allow_mixed: + listing = "\n".join(f" {r['_path']} {r.get('commit', '?')[:12]} {r.get('capturedAt')}" + for r in reports) + sys.exit("refusing to merge cuts from different commits into one table:\n" + listing + + "\nre-cut them at one commit, or pass --allow-mixed to render them with the " + "mismatch stated in the table.") + + base = dict(reports[0]) + base.pop("_path", None) + base["runs"] = [run for r in reports for run in r["runs"]] + order = {name: i for i, name in enumerate(t.name for t in TARGETS)} + requested = {name for r in reports for name in (r.get("targetsRequested") or [])} + base["targetsRequested"] = sorted(requested, key=lambda n: order.get(n, 99)) + base["contaminated"] = any(r.get("contaminated") for r in reports) + base["runsRequested"] = max(r.get("runsRequested", 1) for r in reports) + base["mixedCommits"] = sorted(commits) if len(commits) > 1 else None + base["sources"] = [ + {"file": r["_path"], "commit": r.get("commit", "?")[:12], "capturedAt": r.get("capturedAt"), + "loadAtStart": r.get("loadAtStart"), "contaminated": r.get("contaminated"), + "targets": r.get("targetsRequested")} + for r in reports + ] + base["sourceFile"] = ", ".join(os.path.basename(r["_path"]) for r in reports) + return base + + +def render(args: argparse.Namespace) -> None: + paths: list[str] = [] + for entry in (args.input or []): + paths += [p for p in entry.split(",") if p] + if not paths: + candidates = sorted(glob.glob(os.path.join(RESULTS_DIR, "cost-table-*.json"))) + if not candidates: + sys.exit(f"no collected results in {RESULTS_DIR}; run `collect` first") + paths = [candidates[-1]] + report = merge(paths, getattr(args, "allow_mixed", False)) + body = render_markdown(report) + + doc = args.doc + if not os.path.isfile(doc): + sys.exit(f"{doc} does not exist; it is the file this renders into") + text = open(doc, encoding="utf-8").read() + if MARK_BEGIN not in text or MARK_END not in text: + sys.exit(f"{doc} has no COST-TABLE:GENERATED markers; refusing to guess where to write") + head, _, rest = text.partition(MARK_BEGIN) + _, _, tail = rest.partition(MARK_END) + updated = f"{head}{MARK_BEGIN}\n\n{body}\n{MARK_END}{tail}" + if args.check: + if updated != text: + sys.exit(f"{os.path.relpath(doc, REPO)} is out of date with " + f"{report['sourceFile']}; re-run the renderer") + print(f"{os.path.relpath(doc, REPO)} is up to date with {report['sourceFile']}") + return + with open(doc, "w", encoding="utf-8") as fh: + fh.write(updated) + print(f"rendered {report['sourceFile']} -> {os.path.relpath(doc, REPO)}") + + +# ===================================================================================== + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command") + + def add_collect_args(p: argparse.ArgumentParser) -> None: + p.add_argument("--targets", type=lambda s: [x for x in s.split(",") if x], + help=f"comma-separated; default {','.join(DEFAULT_TARGETS)}. " + f"also available: androidNativeArm64Api26") + p.add_argument("--runs", type=int, default=1, + help="runs per target; ranges are rendered min–max over them (default 1)") + p.add_argument("--out", help="JSON output path (default benchmarks/results/cost-table-<ts>.json)") + p.add_argument("--allow-busy", action="store_true", + help="collect even above the load ceiling, marking the run contaminated") + p.add_argument("--load-ceiling", type=float, default=LOAD_CEILING, + help=f"1-minute load average above which collection refuses (default {LOAD_CEILING})") + + def add_render_args(p: argparse.ArgumentParser) -> None: + p.add_argument("--in", dest="input", action="append", + help="JSON to render; repeatable or comma-separated, and several cuts of " + "the same commit are merged (default: newest collected)") + p.add_argument("--allow-mixed", action="store_true", + help="merge cuts taken at different commits, stating the mismatch in the table") + p.add_argument("--doc", default=DOC, help=f"file to write into (default {os.path.relpath(DOC, REPO)})") + p.add_argument("--check", action="store_true", + help="exit non-zero if the doc is out of date instead of writing it") + + p_collect = sub.add_parser("collect", help="run the suites and record the numbers") + add_collect_args(p_collect) + + p_render = sub.add_parser("render", help="turn a collected JSON into the doc tables") + add_render_args(p_render) + + p_all = sub.add_parser("all", help="collect, then render (the default)") + add_collect_args(p_all) + add_render_args(p_all) + + # `all` is the default, so `--targets desktop` works without naming a subcommand. + argv = sys.argv[1:] + if argv and argv[0] in ("-h", "--help"): + parser.print_help() + return + if not argv or argv[0].startswith("-"): + argv = ["all"] + argv + args = parser.parse_args(argv) + + if args.command == "collect": + collect(args) + elif args.command == "render": + render(args) + else: + args.input = [collect(args)] + render(args) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/results/cut-api26.json b/benchmarks/results/cut-api26.json new file mode 100644 index 000000000..e96a494a0 --- /dev/null +++ b/benchmarks/results/cut-api26.json @@ -0,0 +1,2655 @@ +{ + "schema": 2, + "capturedAt": "2026-08-14T16:53:51+09:00", + "commit": "958c0082b294c423d4f1eace3022d0e83fe1e94b", + "commitSubject": "Merge branch 'work/objecttype' into develop", + "branch": "develop", + "workingTreeDirty": true, + "worktree": "/Volumes/macMini/thisisthepy/PythonMultiplatform", + "host": { + "os": "Darwin 25.5.0", + "machine": "arm64", + "cpu": "Apple M1", + "cores": 8, + "cpuBrandCores": "8" + }, + "loadAtStart": [ + 3.69, + 2.75, + 3.62 + ], + "loadGuard": "ok", + "loadCeiling": 4.0, + "contaminated": false, + "runsRequested": 3, + "targetsRequested": [ + "artApi26" + ], + "benchmarkTestWarmup": { + "WARMUP": 100000, + "WARMUP_BULK": 10000 + }, + "runs": [ + { + "target": "artApi26", + "task": ":python-multiplatform:connectedDebugAndroidTest", + "run": 1, + "requiresDevice": true, + "notes": "", + "log": "benchmarks/results/logs/artApi26-run1-20260814-165351.log", + "device": { + "serial": "emulator-5556", + "avd": "pmp_api26", + "props": { + "ro.build.version.sdk": "26", + "ro.build.version.release": "8.0.0", + "ro.product.cpu.abi": "arm64-v8a", + "ro.product.model": "Android SDK built for arm64" + } + }, + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:connectedDebugAndroidTest --console=plain --rerun", + "loadBefore": [ + 3.69, + 2.75, + 3.62 + ], + "wallSeconds": 153.8, + "exitCode": 1, + "loadAfter": [ + 2.02, + 2.61, + 3.44 + ], + "suite": { + "tests": 381, + "failures": 3, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 1, + "deviceDirs": [ + "pmp_api26(AVD) - 8.0.0" + ], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 1391.7, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 1230.39, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 653.66, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 3.7, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 18.37, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 19.98, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 1270.82, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 1321.38, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 1741.41, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 1404.0, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 4040.6, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 2503.43, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 50387.81, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 35140.57, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 1479421.62, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 10.18, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 35.8, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 4.14, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 16.53, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 74.27, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 46.84, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 46.57, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 79.61, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 40.43, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 44.14, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 84.5, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 842.02, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 797.35, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 1380.11, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 1833.82, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 1670.02, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 1603.4, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 1302.02, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 1354.17, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 3701.34, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 4117.91, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 798.54, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 1043.9, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 881.22, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 1395.41, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 870.29, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 1169.82, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 3.7, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 44.06, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 40.35, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 933.04, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 2602.72, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 1669.67, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 1221.47, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 124.15, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 1490.08, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.21, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 268.6, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 20.01, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 62.92, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 1235.37, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1215.36, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 1172.44, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 1177.35, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 1277.09, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 1803.44, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 1142.08, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 520.57, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 0.96, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 0.68, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.08, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + }, + { + "block": "Android upcall overhead (API 26, arm64-v8a)", + "platform": null, + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 20.01, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 60.98, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 1239.87, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1219.85, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 20.01, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 62.75, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "first upcall on the thread", + "key": "first upcall on the thread", + "value": 119916.0, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall to Kotlin (steady state)", + "key": "upcall to kotlin (steady state)", + "value": 1183.25, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1163.24, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Attach", + "label": "worker minus instrumentation thread", + "key": "worker minus instrumentation thread", + "value": -56.61, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 jni downcalls, a gil scope each)", + "value": 1171.1, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 1278.28, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "UpcallTrampoline.invoke from Kotlin (no C shim, no JNI)", + "key": "upcalltrampoline.invoke from kotlin (no c shim, no jni)", + "value": 1707.95, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / downcall of same shape", + "key": "upcall (instrumentation thread) / downcall of same shape", + "value": 0.96, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / downcall of same shape", + "key": "upcall (worker thread) / downcall of same shape", + "value": 0.92, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / trampoline alone", + "key": "upcall (instrumentation thread) / trampoline alone", + "value": 0.72, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / trampoline alone", + "key": "upcall (worker thread) / trampoline alone", + "value": 0.69, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallOverheadTest" + } + ], + "status": "failed" + }, + { + "target": "artApi26", + "task": ":python-multiplatform:connectedDebugAndroidTest", + "run": 2, + "requiresDevice": true, + "notes": "", + "log": "benchmarks/results/logs/artApi26-run2-20260814-165625.log", + "device": { + "serial": "emulator-5556", + "avd": "pmp_api26", + "props": { + "ro.build.version.sdk": "26", + "ro.build.version.release": "8.0.0", + "ro.product.cpu.abi": "arm64-v8a", + "ro.product.model": "Android SDK built for arm64" + } + }, + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:connectedDebugAndroidTest --console=plain --rerun", + "loadBefore": [ + 2.02, + 2.61, + 3.44 + ], + "wallSeconds": 149.1, + "exitCode": 1, + "loadAfter": [ + 1.6, + 2.26, + 3.16 + ], + "suite": { + "tests": 381, + "failures": 3, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 1, + "deviceDirs": [ + "pmp_api26(AVD) - 8.0.0" + ], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 1484.84, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 1222.29, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 606.21, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 3.78, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 16.57, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 20.17, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 1197.08, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 1192.7, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 1745.09, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 1485.24, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 4034.62, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 2514.75, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 49856.68, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 34593.37, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 1485238.0, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 10.23, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 34.76, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 4.21, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 15.52, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 72.57, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 46.42, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 46.82, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 81.72, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 40.43, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 43.3, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 56.31, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 818.27, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 829.9, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 1351.9, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 1815.94, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 1647.61, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 1599.43, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 1345.64, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 1373.15, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 3690.5, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 4023.26, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 811.36, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 1068.53, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 873.49, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 1204.87, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 896.76, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 1168.65, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 2.87, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 15.87, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 13.0, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 995.97, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 2482.11, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 1486.13, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 1290.92, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 115.82, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 1509.61, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.16, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 218.68, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 20.04, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 56.1, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 1196.52, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1176.47, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 1140.41, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 1189.74, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 1295.79, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 1863.97, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 1135.93, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 540.11, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 0.92, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 0.64, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.05, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + }, + { + "block": "Android upcall overhead (API 26, arm64-v8a)", + "platform": null, + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 20.03, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 51.68, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 1184.87, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1164.84, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 20.01, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 51.67, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "first upcall on the thread", + "key": "first upcall on the thread", + "value": 81167.0, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall to Kotlin (steady state)", + "key": "upcall to kotlin (steady state)", + "value": 1197.14, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1177.13, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Attach", + "label": "worker minus instrumentation thread", + "key": "worker minus instrumentation thread", + "value": 12.26, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 jni downcalls, a gil scope each)", + "value": 1230.33, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 1297.37, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "UpcallTrampoline.invoke from Kotlin (no C shim, no JNI)", + "key": "upcalltrampoline.invoke from kotlin (no c shim, no jni)", + "value": 1812.02, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / downcall of same shape", + "key": "upcall (instrumentation thread) / downcall of same shape", + "value": 0.91, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / downcall of same shape", + "key": "upcall (worker thread) / downcall of same shape", + "value": 0.92, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / trampoline alone", + "key": "upcall (instrumentation thread) / trampoline alone", + "value": 0.65, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / trampoline alone", + "key": "upcall (worker thread) / trampoline alone", + "value": 0.66, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallOverheadTest" + } + ], + "status": "failed" + }, + { + "target": "artApi26", + "task": ":python-multiplatform:connectedDebugAndroidTest", + "run": 3, + "requiresDevice": true, + "notes": "", + "log": "benchmarks/results/logs/artApi26-run3-20260814-165854.log", + "device": { + "serial": "emulator-5556", + "avd": "pmp_api26", + "props": { + "ro.build.version.sdk": "26", + "ro.build.version.release": "8.0.0", + "ro.product.cpu.abi": "arm64-v8a", + "ro.product.model": "Android SDK built for arm64" + } + }, + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:connectedDebugAndroidTest --console=plain --rerun", + "loadBefore": [ + 1.6, + 2.26, + 3.16 + ], + "wallSeconds": 148.3, + "exitCode": 1, + "loadAfter": [ + 1.79, + 2.09, + 2.94 + ], + "suite": { + "tests": 381, + "failures": 3, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 1, + "deviceDirs": [ + "pmp_api26(AVD) - 8.0.0" + ], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 1351.16, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 1154.5, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 572.33, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 3.8, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 16.62, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 20.2, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 1152.69, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 1147.2, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 1691.66, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 1418.8, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 3967.62, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 2484.13, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 49560.37, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 36591.68, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 1475619.82, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 9.31, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 37.7, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 4.16, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 18.37, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 74.31, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 46.57, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 45.97, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 80.83, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 40.44, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 43.29, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 56.73, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 802.89, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 817.36, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 1333.54, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 1782.78, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 1550.12, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 1535.76, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 1302.15, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 1496.81, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 3720.56, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 3952.55, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 788.07, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 1059.48, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 861.19, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 1207.12, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 873.07, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 1198.65, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 2.85, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 16.29, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 13.43, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 922.0, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 2502.51, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 1580.5, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 1200.7, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 118.88, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 1515.19, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.26, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 314.48, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 20.08, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 65.66, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 1272.28, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1252.19, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 1206.62, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 1137.97, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 1253.41, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 1800.86, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 1073.4, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 506.19, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 1.01, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 0.7, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.18, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + }, + { + "block": "Android upcall overhead (API 26, arm64-v8a)", + "platform": null, + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 20.08, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 65.62, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 1160.42, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1140.33, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 20.01, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 66.0, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "first upcall on the thread", + "key": "first upcall on the thread", + "value": 79542.0, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall to Kotlin (steady state)", + "key": "upcall to kotlin (steady state)", + "value": 1176.18, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1156.16, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Attach", + "label": "worker minus instrumentation thread", + "key": "worker minus instrumentation thread", + "value": 15.75, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 jni downcalls, a gil scope each)", + "value": 1156.68, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 1261.37, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "UpcallTrampoline.invoke from Kotlin (no C shim, no JNI)", + "key": "upcalltrampoline.invoke from kotlin (no c shim, no jni)", + "value": 1831.66, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / downcall of same shape", + "key": "upcall (instrumentation thread) / downcall of same shape", + "value": 0.91, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / downcall of same shape", + "key": "upcall (worker thread) / downcall of same shape", + "value": 0.93, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / trampoline alone", + "key": "upcall (instrumentation thread) / trampoline alone", + "value": 0.63, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 26, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / trampoline alone", + "key": "upcall (worker thread) / trampoline alone", + "value": 0.64, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallOverheadTest" + } + ], + "status": "failed" + } + ], + "loadAtEnd": [ + 1.79, + 2.09, + 2.94 + ] +} diff --git a/benchmarks/results/cut-api26.tsv b/benchmarks/results/cut-api26.tsv new file mode 100644 index 000000000..6f959d2d4 --- /dev/null +++ b/benchmarks/results/cut-api26.tsv @@ -0,0 +1,250 @@ +target run status origin source section label value unit iterations platform commit contaminated +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 1391.7 ns 10000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 1230.39 ns 100000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 653.66 ns 100000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 3.7 ns 1000000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 18.37 ns 1000000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 19.98 ns 1000000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 1270.82 ns 1000000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 1321.38 ns 100000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 1741.41 ns 100000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 1404.0 ns 10000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 4040.6 ns 10000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 2503.43 ns 10000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 50387.81 ns 10000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 35140.57 ns 10000 958c0082b294 false +artApi26 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 1479421.62 ns 10000 958c0082b294 false +artApi26 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 10.18 ns 1000000 958c0082b294 false +artApi26 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 35.8 ns 1000000 958c0082b294 false +artApi26 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 4.14 ns 1000000 958c0082b294 false +artApi26 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 16.53 ns 1000000 958c0082b294 false +artApi26 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 74.27 ns 500000 958c0082b294 false +artApi26 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 46.84 ns 1000000 958c0082b294 false +artApi26 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 46.57 ns 1000000 958c0082b294 false +artApi26 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 79.61 ns 1000000 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 40.43 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 44.14 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 84.5 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 842.02 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 797.35 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 1380.11 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 1833.82 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 1670.02 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 1603.4 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 1302.02 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 1354.17 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 3701.34 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 4117.91 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 798.54 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 1043.9 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 881.22 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 1395.41 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 870.29 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 1169.82 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 3.7 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 44.06 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 40.35 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 933.04 us Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 2602.72 us Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 1669.67 us Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 1221.47 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 124.15 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 1490.08 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.21 x Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 268.6 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 20.01 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 62.92 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 1235.37 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 1215.36 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 1172.44 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 1177.35 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 1277.09 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 1803.44 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 1142.08 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 520.57 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 0.96 x 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 0.68 x 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.08 x 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) empty Python loop 20.01 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) pure-Python callee 60.98 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall to Kotlin 1239.87 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall net of loop 1219.85 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) empty Python loop 20.01 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) pure-Python callee 62.75 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) first upcall on the thread 119916.0 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall to Kotlin (steady state) 1183.25 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall net of loop 1163.24 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Attach worker minus instrumentation thread -56.61 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Comparison basis, same run Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each) 1171.1 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Comparison basis, same run PyObject_CallObject on a Python def (downcall, same shape) 1278.28 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Comparison basis, same run UpcallTrampoline.invoke from Kotlin (no C shim, no JNI) 1707.95 ns 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (instrumentation thread) / downcall of same shape 0.96 x 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (worker thread) / downcall of same shape 0.92 x 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (instrumentation thread) / trampoline alone 0.72 x 10000 958c0082b294 false +artApi26 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (worker thread) / trampoline alone 0.69 x 10000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 1484.84 ns 10000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 1222.29 ns 100000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 606.21 ns 100000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 3.78 ns 1000000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 16.57 ns 1000000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 20.17 ns 1000000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 1197.08 ns 1000000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 1192.7 ns 100000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 1745.09 ns 100000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 1485.24 ns 10000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 4034.62 ns 10000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 2514.75 ns 10000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 49856.68 ns 10000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 34593.37 ns 10000 958c0082b294 false +artApi26 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 1485238.0 ns 10000 958c0082b294 false +artApi26 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 10.23 ns 1000000 958c0082b294 false +artApi26 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 34.76 ns 1000000 958c0082b294 false +artApi26 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 4.21 ns 1000000 958c0082b294 false +artApi26 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 15.52 ns 1000000 958c0082b294 false +artApi26 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 72.57 ns 500000 958c0082b294 false +artApi26 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 46.42 ns 1000000 958c0082b294 false +artApi26 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 46.82 ns 1000000 958c0082b294 false +artApi26 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 81.72 ns 1000000 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 40.43 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 43.3 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 56.31 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 818.27 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 829.9 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 1351.9 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 1815.94 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 1647.61 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 1599.43 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 1345.64 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 1373.15 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 3690.5 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 4023.26 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 811.36 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 1068.53 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 873.49 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 1204.87 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 896.76 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 1168.65 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 2.87 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 15.87 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 13.0 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 995.97 us Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 2482.11 us Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 1486.13 us Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 1290.92 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 115.82 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 1509.61 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.16 x Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 218.68 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 20.04 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 56.1 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 1196.52 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 1176.47 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 1140.41 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 1189.74 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 1295.79 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 1863.97 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 1135.93 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 540.11 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 0.92 x 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 0.64 x 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.05 x 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) empty Python loop 20.03 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) pure-Python callee 51.68 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall to Kotlin 1184.87 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall net of loop 1164.84 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) empty Python loop 20.01 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) pure-Python callee 51.67 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) first upcall on the thread 81167.0 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall to Kotlin (steady state) 1197.14 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall net of loop 1177.13 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Attach worker minus instrumentation thread 12.26 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Comparison basis, same run Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each) 1230.33 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Comparison basis, same run PyObject_CallObject on a Python def (downcall, same shape) 1297.37 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Comparison basis, same run UpcallTrampoline.invoke from Kotlin (no C shim, no JNI) 1812.02 ns 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (instrumentation thread) / downcall of same shape 0.91 x 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (worker thread) / downcall of same shape 0.92 x 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (instrumentation thread) / trampoline alone 0.65 x 10000 958c0082b294 false +artApi26 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (worker thread) / trampoline alone 0.66 x 10000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 1351.16 ns 10000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 1154.5 ns 100000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 572.33 ns 100000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 3.8 ns 1000000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 16.62 ns 1000000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 20.2 ns 1000000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 1152.69 ns 1000000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 1147.2 ns 100000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 1691.66 ns 100000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 1418.8 ns 10000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 3967.62 ns 10000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 2484.13 ns 10000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 49560.37 ns 10000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 36591.68 ns 10000 958c0082b294 false +artApi26 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 1475619.82 ns 10000 958c0082b294 false +artApi26 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 9.31 ns 1000000 958c0082b294 false +artApi26 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 37.7 ns 1000000 958c0082b294 false +artApi26 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 4.16 ns 1000000 958c0082b294 false +artApi26 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 18.37 ns 1000000 958c0082b294 false +artApi26 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 74.31 ns 500000 958c0082b294 false +artApi26 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 46.57 ns 1000000 958c0082b294 false +artApi26 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 45.97 ns 1000000 958c0082b294 false +artApi26 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 80.83 ns 1000000 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 40.44 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 43.29 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 56.73 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 802.89 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 817.36 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 1333.54 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 1782.78 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 1550.12 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 1535.76 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 1302.15 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 1496.81 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 3720.56 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 3952.55 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 788.07 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 1059.48 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 861.19 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 1207.12 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 873.07 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 1198.65 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 2.85 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 16.29 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 13.43 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 922.0 us Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 2502.51 us Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 1580.5 us Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 1200.7 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 118.88 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 1515.19 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.26 x Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 314.48 ns Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 20.08 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 65.66 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 1272.28 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 1252.19 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 1206.62 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 1137.97 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 1253.41 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 1800.86 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 1073.4 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 506.19 ns 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 1.01 x 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 0.7 x 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.18 x 10000 Android 8.0.0 (SDK 26, aarch64) / ART VM 0.9 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) empty Python loop 20.08 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) pure-Python callee 65.62 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall to Kotlin 1160.42 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall net of loop 1140.33 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) empty Python loop 20.01 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) pure-Python callee 66.0 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) first upcall on the thread 79542.0 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall to Kotlin (steady state) 1176.18 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall net of loop 1156.16 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Attach worker minus instrumentation thread 15.75 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Comparison basis, same run Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each) 1156.68 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Comparison basis, same run PyObject_CallObject on a Python def (downcall, same shape) 1261.37 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Comparison basis, same run UpcallTrampoline.invoke from Kotlin (no C shim, no JNI) 1831.66 ns 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (instrumentation thread) / downcall of same shape 0.91 x 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (worker thread) / downcall of same shape 0.93 x 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (instrumentation thread) / trampoline alone 0.63 x 10000 958c0082b294 false +artApi26 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 26, arm64-v8a) Ratios upcall (worker thread) / trampoline alone 0.64 x 10000 958c0082b294 false diff --git a/benchmarks/results/cut-api36.json b/benchmarks/results/cut-api36.json new file mode 100644 index 000000000..1a76b9f3c --- /dev/null +++ b/benchmarks/results/cut-api36.json @@ -0,0 +1,2655 @@ +{ + "schema": 2, + "capturedAt": "2026-08-14T16:43:04+09:00", + "commit": "958c0082b294c423d4f1eace3022d0e83fe1e94b", + "commitSubject": "Merge branch 'work/objecttype' into develop", + "branch": "develop", + "workingTreeDirty": true, + "worktree": "/Volumes/macMini/thisisthepy/PythonMultiplatform", + "host": { + "os": "Darwin 25.5.0", + "machine": "arm64", + "cpu": "Apple M1", + "cores": 8, + "cpuBrandCores": "8" + }, + "loadAtStart": [ + 1.42, + 2.39, + 4.24 + ], + "loadGuard": "ok", + "loadCeiling": 4.0, + "contaminated": false, + "runsRequested": 3, + "targetsRequested": [ + "artApi36" + ], + "benchmarkTestWarmup": { + "WARMUP": 100000, + "WARMUP_BULK": 10000 + }, + "runs": [ + { + "target": "artApi36", + "task": ":python-multiplatform:connectedDebugAndroidTest", + "run": 1, + "requiresDevice": true, + "notes": "", + "log": "benchmarks/results/logs/artApi36-run1-20260814-164304.log", + "device": { + "serial": "emulator-5554", + "avd": "pmp_api36", + "props": { + "ro.build.version.sdk": "36", + "ro.build.version.release": "16", + "ro.product.cpu.abi": "arm64-v8a", + "ro.product.model": "Android SDK built for arm64" + } + }, + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:connectedDebugAndroidTest --console=plain --rerun", + "loadBefore": [ + 1.42, + 2.39, + 4.24 + ], + "wallSeconds": 262.9, + "exitCode": 1, + "loadAfter": [ + 2.13, + 3.45, + 4.36 + ], + "suite": { + "tests": 381, + "failures": 2, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 1, + "deviceDirs": [ + "pmp_api36(AVD) - 16" + ], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 1114.5, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 727.81, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 308.04, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 6.27, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 21.93, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 27.37, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 612.36, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 597.37, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 1188.08, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 1013.04, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 3603.98, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 4613.3, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 45067.76, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 119098.99, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 1327069.03, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 20.1, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 60.44, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 13.79, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 35.15, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 133.37, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 292.52, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 79.88, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 374.13, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 44.52, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 55.6, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 62.48, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 829.62, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 825.37, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 1532.11, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 1921.37, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 1428.58, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 1458.88, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 1321.97, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 1230.15, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 3473.0, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 3650.37, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 812.25, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 906.97, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 857.65, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 995.03, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 968.49, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 1070.02, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 11.07, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 17.96, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 6.88, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 911.82, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 2524.93, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 1613.1, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 2154.07, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 128.3, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 1616.72, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 0.75, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": -537.34, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 17.22, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 65.3, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 1003.38, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 986.15, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 938.07, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 597.74, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 838.6, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 1100.87, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 911.95, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 286.34, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 1.19, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 0.91, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.1, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + }, + { + "block": "Android upcall overhead (API 36, arm64-v8a)", + "platform": null, + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 17.22, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 64.35, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 1101.2, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1083.97, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 17.39, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 64.35, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "first upcall on the thread", + "key": "first upcall on the thread", + "value": 42667.0, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall to Kotlin (steady state)", + "key": "upcall to kotlin (steady state)", + "value": 1103.2, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1085.79, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Attach", + "label": "worker minus instrumentation thread", + "key": "worker minus instrumentation thread", + "value": 1.99, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 jni downcalls, a gil scope each)", + "value": 587.95, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 721.01, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "UpcallTrampoline.invoke from Kotlin (no C shim, no JNI)", + "key": "upcalltrampoline.invoke from kotlin (no c shim, no jni)", + "value": 1125.08, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / downcall of same shape", + "key": "upcall (instrumentation thread) / downcall of same shape", + "value": 1.52, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / downcall of same shape", + "key": "upcall (worker thread) / downcall of same shape", + "value": 1.53, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / trampoline alone", + "key": "upcall (instrumentation thread) / trampoline alone", + "value": 0.97, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / trampoline alone", + "key": "upcall (worker thread) / trampoline alone", + "value": 0.98, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallOverheadTest" + } + ], + "status": "failed" + }, + { + "target": "artApi36", + "task": ":python-multiplatform:connectedDebugAndroidTest", + "run": 2, + "requiresDevice": true, + "notes": "", + "log": "benchmarks/results/logs/artApi36-run2-20260814-164727.log", + "device": { + "serial": "emulator-5554", + "avd": "pmp_api36", + "props": { + "ro.build.version.sdk": "36", + "ro.build.version.release": "16", + "ro.product.cpu.abi": "arm64-v8a", + "ro.product.model": "Android SDK built for arm64" + } + }, + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:connectedDebugAndroidTest --console=plain --rerun", + "loadBefore": [ + 2.13, + 3.45, + 4.36 + ], + "wallSeconds": 148.7, + "exitCode": 1, + "loadAfter": [ + 2.19, + 2.91, + 3.99 + ], + "suite": { + "tests": 381, + "failures": 2, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 1, + "deviceDirs": [ + "pmp_api36(AVD) - 16" + ], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 920.67, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 738.93, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 302.96, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 6.1, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 21.71, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 26.57, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 614.5, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 603.22, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 1225.27, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 1075.05, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 3380.46, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 4555.52, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 43808.32, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 116678.44, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 1311447.53, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 18.86, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 60.83, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 14.49, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 35.99, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 132.04, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 293.64, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 80.12, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 376.68, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 37.63, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 41.31, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 53.49, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 857.51, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 834.54, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 1583.76, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 2021.79, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 1517.15, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 1516.02, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 1367.86, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 1302.54, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 3440.69, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 3648.71, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 832.15, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 921.35, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 899.5, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 1064.98, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 1086.08, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 1117.65, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 3.68, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 15.85, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 12.17, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 913.07, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 2457.25, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 1544.18, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 2007.42, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 116.42, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 1657.57, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 0.82, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": -349.84, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 18.14, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 62.27, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 1154.1, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1135.95, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 1091.82, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 608.46, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 804.27, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 1139.28, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 892.93, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 286.17, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 1.43, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 1.01, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.29, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + }, + { + "block": "Android upcall overhead (API 36, arm64-v8a)", + "platform": null, + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 17.22, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 60.33, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 1184.94, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1167.71, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 17.22, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 58.58, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "first upcall on the thread", + "key": "first upcall on the thread", + "value": 41917.0, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall to Kotlin (steady state)", + "key": "upcall to kotlin (steady state)", + "value": 1189.9, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 1172.68, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Attach", + "label": "worker minus instrumentation thread", + "key": "worker minus instrumentation thread", + "value": 4.96, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 jni downcalls, a gil scope each)", + "value": 594.43, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 1055.35, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "UpcallTrampoline.invoke from Kotlin (no C shim, no JNI)", + "key": "upcalltrampoline.invoke from kotlin (no c shim, no jni)", + "value": 1135.7, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / downcall of same shape", + "key": "upcall (instrumentation thread) / downcall of same shape", + "value": 1.12, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / downcall of same shape", + "key": "upcall (worker thread) / downcall of same shape", + "value": 1.12, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / trampoline alone", + "key": "upcall (instrumentation thread) / trampoline alone", + "value": 1.04, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / trampoline alone", + "key": "upcall (worker thread) / trampoline alone", + "value": 1.04, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallOverheadTest" + } + ], + "status": "failed" + }, + { + "target": "artApi36", + "task": ":python-multiplatform:connectedDebugAndroidTest", + "run": 3, + "requiresDevice": true, + "notes": "", + "log": "benchmarks/results/logs/artApi36-run3-20260814-164956.log", + "device": { + "serial": "emulator-5554", + "avd": "pmp_api36", + "props": { + "ro.build.version.sdk": "36", + "ro.build.version.release": "16", + "ro.product.cpu.abi": "arm64-v8a", + "ro.product.model": "Android SDK built for arm64" + } + }, + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:connectedDebugAndroidTest --console=plain --rerun", + "loadBefore": [ + 2.19, + 2.91, + 3.99 + ], + "wallSeconds": 146.7, + "exitCode": 1, + "loadAfter": [ + 1.52, + 2.49, + 3.65 + ], + "suite": { + "tests": 381, + "failures": 2, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 1, + "deviceDirs": [ + "pmp_api36(AVD) - 16" + ], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 981.67, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 720.19, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 300.14, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 5.98, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 21.37, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 27.24, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 599.46, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 593.3, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 1194.18, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 1197.93, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 3431.15, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 4683.17, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 43690.8, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 116198.67, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 1304492.4, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 19.07, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 61.46, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 14.69, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 35.07, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 132.25, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 295.97, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 78.46, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 376.54, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 37.7, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 41.48, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 53.48, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 754.78, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 786.96, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 1467.88, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 1854.75, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 1362.72, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 1371.24, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 1299.89, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 1182.75, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 3353.67, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 3580.56, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 761.4, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 892.23, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 835.96, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 973.68, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 977.05, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 1066.92, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 3.77, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 15.77, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 12.0, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 862.0, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 2534.83, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 1672.83, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 2064.29, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 112.28, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 1520.21, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 0.73, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": -544.08, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Android 16 (SDK 36, aarch64) / ART VM 0.9", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 17.22, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 58.4, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 995.61, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 978.39, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 937.21, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 606.29, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 838.15, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 1167.17, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 873.48, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 283.9, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 1.18, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 0.85, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.13, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + }, + { + "block": "Android upcall overhead (API 36, arm64-v8a)", + "platform": null, + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 17.69, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 66.85, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 980.15, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, instrumentation thread (ART already knows it)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 962.46, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 17.5, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "pure-Python callee", + "key": "pure-python callee", + "value": 77.69, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "first upcall on the thread", + "key": "first upcall on the thread", + "value": 504917.0, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall to Kotlin (steady state)", + "key": "upcall to kotlin (steady state)", + "value": 1002.55, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Python-driven, threading.Thread (a pthread ART has never seen)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 985.05, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Attach", + "label": "worker minus instrumentation thread", + "key": "worker minus instrumentation thread", + "value": 22.39, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 jni downcalls, a gil scope each)", + "value": 594.02, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 828.97, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Comparison basis, same run", + "label": "UpcallTrampoline.invoke from Kotlin (no C shim, no JNI)", + "key": "upcalltrampoline.invoke from kotlin (no c shim, no jni)", + "value": 1165.89, + "unit": "ns" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / downcall of same shape", + "key": "upcall (instrumentation thread) / downcall of same shape", + "value": 1.18, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / downcall of same shape", + "key": "upcall (worker thread) / downcall of same shape", + "value": 1.2, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (instrumentation thread) / trampoline alone", + "key": "upcall (instrumentation thread) / trampoline alone", + "value": 0.84, + "unit": "x" + }, + { + "source": "Android upcall overhead (API 36, arm64-v8a)", + "section": "Ratios", + "label": "upcall (worker thread) / trampoline alone", + "key": "upcall (worker thread) / trampoline alone", + "value": 0.85, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallOverheadTest" + } + ], + "status": "failed" + } + ], + "loadAtEnd": [ + 1.52, + 2.49, + 3.65 + ] +} diff --git a/benchmarks/results/cut-api36.tsv b/benchmarks/results/cut-api36.tsv new file mode 100644 index 000000000..a00208f69 --- /dev/null +++ b/benchmarks/results/cut-api36.tsv @@ -0,0 +1,250 @@ +target run status origin source section label value unit iterations platform commit contaminated +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 1114.5 ns 10000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 727.81 ns 100000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 308.04 ns 100000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 6.27 ns 1000000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 21.93 ns 1000000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 27.37 ns 1000000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 612.36 ns 1000000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 597.37 ns 100000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 1188.08 ns 100000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 1013.04 ns 10000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 3603.98 ns 10000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 4613.3 ns 10000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 45067.76 ns 10000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 119098.99 ns 10000 958c0082b294 false +artApi36 1 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 1327069.03 ns 10000 958c0082b294 false +artApi36 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 20.1 ns 1000000 958c0082b294 false +artApi36 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 60.44 ns 1000000 958c0082b294 false +artApi36 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 13.79 ns 1000000 958c0082b294 false +artApi36 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 35.15 ns 1000000 958c0082b294 false +artApi36 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 133.37 ns 500000 958c0082b294 false +artApi36 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 292.52 ns 1000000 958c0082b294 false +artApi36 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 79.88 ns 1000000 958c0082b294 false +artApi36 1 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 374.13 ns 1000000 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 44.52 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 55.6 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 62.48 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 829.62 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 825.37 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 1532.11 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 1921.37 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 1428.58 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 1458.88 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 1321.97 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 1230.15 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 3473.0 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 3650.37 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 812.25 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 906.97 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 857.65 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 995.03 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 968.49 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 1070.02 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 11.07 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 17.96 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 6.88 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 911.82 us Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 2524.93 us Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 1613.1 us Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 2154.07 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 128.3 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 1616.72 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 0.75 x Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds -537.34 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 17.22 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 65.3 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 1003.38 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 986.15 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 938.07 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 597.74 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 838.6 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 1100.87 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 911.95 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 286.34 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 1.19 x 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 0.91 x 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.1 x 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) empty Python loop 17.22 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) pure-Python callee 64.35 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall to Kotlin 1101.2 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall net of loop 1083.97 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) empty Python loop 17.39 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) pure-Python callee 64.35 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) first upcall on the thread 42667.0 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall to Kotlin (steady state) 1103.2 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall net of loop 1085.79 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Attach worker minus instrumentation thread 1.99 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Comparison basis, same run Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each) 587.95 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Comparison basis, same run PyObject_CallObject on a Python def (downcall, same shape) 721.01 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Comparison basis, same run UpcallTrampoline.invoke from Kotlin (no C shim, no JNI) 1125.08 ns 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (instrumentation thread) / downcall of same shape 1.52 x 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (worker thread) / downcall of same shape 1.53 x 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (instrumentation thread) / trampoline alone 0.97 x 10000 958c0082b294 false +artApi36 1 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (worker thread) / trampoline alone 0.98 x 10000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 920.67 ns 10000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 738.93 ns 100000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 302.96 ns 100000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 6.1 ns 1000000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 21.71 ns 1000000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 26.57 ns 1000000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 614.5 ns 1000000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 603.22 ns 100000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 1225.27 ns 100000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 1075.05 ns 10000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 3380.46 ns 10000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 4555.52 ns 10000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 43808.32 ns 10000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 116678.44 ns 10000 958c0082b294 false +artApi36 2 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 1311447.53 ns 10000 958c0082b294 false +artApi36 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 18.86 ns 1000000 958c0082b294 false +artApi36 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 60.83 ns 1000000 958c0082b294 false +artApi36 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 14.49 ns 1000000 958c0082b294 false +artApi36 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 35.99 ns 1000000 958c0082b294 false +artApi36 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 132.04 ns 500000 958c0082b294 false +artApi36 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 293.64 ns 1000000 958c0082b294 false +artApi36 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 80.12 ns 1000000 958c0082b294 false +artApi36 2 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 376.68 ns 1000000 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 37.63 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 41.31 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 53.49 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 857.51 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 834.54 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 1583.76 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 2021.79 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 1517.15 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 1516.02 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 1367.86 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 1302.54 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 3440.69 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 3648.71 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 832.15 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 921.35 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 899.5 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 1064.98 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 1086.08 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 1117.65 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 3.68 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 15.85 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 12.17 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 913.07 us Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 2457.25 us Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 1544.18 us Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 2007.42 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 116.42 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 1657.57 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 0.82 x Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds -349.84 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 18.14 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 62.27 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 1154.1 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 1135.95 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 1091.82 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 608.46 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 804.27 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 1139.28 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 892.93 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 286.17 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 1.43 x 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 1.01 x 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.29 x 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) empty Python loop 17.22 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) pure-Python callee 60.33 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall to Kotlin 1184.94 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall net of loop 1167.71 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) empty Python loop 17.22 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) pure-Python callee 58.58 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) first upcall on the thread 41917.0 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall to Kotlin (steady state) 1189.9 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall net of loop 1172.68 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Attach worker minus instrumentation thread 4.96 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Comparison basis, same run Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each) 594.43 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Comparison basis, same run PyObject_CallObject on a Python def (downcall, same shape) 1055.35 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Comparison basis, same run UpcallTrampoline.invoke from Kotlin (no C shim, no JNI) 1135.7 ns 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (instrumentation thread) / downcall of same shape 1.12 x 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (worker thread) / downcall of same shape 1.12 x 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (instrumentation thread) / trampoline alone 1.04 x 10000 958c0082b294 false +artApi36 2 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (worker thread) / trampoline alone 1.04 x 10000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 981.67 ns 10000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 720.19 ns 100000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 300.14 ns 100000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 5.98 ns 1000000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 21.37 ns 1000000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 27.24 ns 1000000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 599.46 ns 1000000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 593.3 ns 100000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 1194.18 ns 100000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 1197.93 ns 10000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 3431.15 ns 10000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 4683.17 ns 10000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 43690.8 ns 10000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 116198.67 ns 10000 958c0082b294 false +artApi36 3 failed python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 1304492.4 ns 10000 958c0082b294 false +artApi36 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 19.07 ns 1000000 958c0082b294 false +artApi36 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 61.46 ns 1000000 958c0082b294 false +artApi36 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 14.69 ns 1000000 958c0082b294 false +artApi36 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 35.07 ns 1000000 958c0082b294 false +artApi36 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 132.25 ns 500000 958c0082b294 false +artApi36 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 295.97 ns 1000000 958c0082b294 false +artApi36 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 78.46 ns 1000000 958c0082b294 false +artApi36 3 failed python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 376.54 ns 1000000 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 37.7 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 41.48 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 53.48 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 754.78 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 786.96 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 1467.88 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 1854.75 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 1362.72 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 1371.24 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 1299.89 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 1182.75 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 3353.67 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 3580.56 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 761.4 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 892.23 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 835.96 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 973.68 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 977.05 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 1066.92 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 3.77 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 15.77 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 12.0 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 862.0 us Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 2534.83 us Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 1672.83 us Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 2064.29 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 112.28 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 1520.21 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 0.73 x Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds -544.08 ns Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 17.22 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 58.4 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 995.61 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 978.39 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 937.21 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 606.29 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 838.15 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 1167.17 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 873.48 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 283.9 ns 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 1.18 x 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 0.85 x 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.13 x 10000 Android 16 (SDK 36, aarch64) / ART VM 0.9 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) empty Python loop 17.69 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) pure-Python callee 66.85 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall to Kotlin 980.15 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, instrumentation thread (ART already knows it) upcall net of loop 962.46 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) empty Python loop 17.5 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) pure-Python callee 77.69 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) first upcall on the thread 504917.0 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall to Kotlin (steady state) 1002.55 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Python-driven, threading.Thread (a pthread ART has never seen) upcall net of loop 985.05 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Attach worker minus instrumentation thread 22.39 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Comparison basis, same run Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each) 594.02 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Comparison basis, same run PyObject_CallObject on a Python def (downcall, same shape) 828.97 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Comparison basis, same run UpcallTrampoline.invoke from Kotlin (no C shim, no JNI) 1165.89 ns 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (instrumentation thread) / downcall of same shape 1.18 x 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (worker thread) / downcall of same shape 1.2 x 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (instrumentation thread) / trampoline alone 0.84 x 10000 958c0082b294 false +artApi36 3 failed python.native.ffi.UpcallOverheadTest Android upcall overhead (API 36, arm64-v8a) Ratios upcall (worker thread) / trampoline alone 0.85 x 10000 958c0082b294 false diff --git a/benchmarks/results/cut-hostless.json b/benchmarks/results/cut-hostless.json new file mode 100644 index 000000000..622c5eeca --- /dev/null +++ b/benchmarks/results/cut-hostless.json @@ -0,0 +1,9003 @@ +{ + "schema": 2, + "capturedAt": "2026-08-14T16:31:30+09:00", + "commit": "958c0082b294c423d4f1eace3022d0e83fe1e94b", + "commitSubject": "Merge branch 'work/objecttype' into develop", + "branch": "develop", + "workingTreeDirty": false, + "worktree": "/Volumes/macMini/thisisthepy/PythonMultiplatform", + "host": { + "os": "Darwin 25.5.0", + "machine": "arm64", + "cpu": "Apple M1", + "cores": 8, + "cpuBrandCores": "8" + }, + "loadAtStart": [ + 1.97, + 2.29, + 5.92 + ], + "loadGuard": "ok", + "loadCeiling": 4.0, + "contaminated": false, + "runsRequested": 3, + "targetsRequested": [ + "desktop", + "iosSimulatorArm64", + "wasmJs", + "androidNativeArm64" + ], + "benchmarkTestWarmup": { + "WARMUP": 100000, + "WARMUP_BULK": 10000 + }, + "runs": [ + { + "target": "desktop", + "task": ":python-multiplatform:desktopTest", + "run": 1, + "requiresDevice": false, + "notes": "", + "log": "benchmarks/results/logs/desktop-run1-20260814-163130.log", + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:desktopTest --console=plain --rerun", + "loadBefore": [ + 1.97, + 2.29, + 5.92 + ], + "wallSeconds": 30.0, + "exitCode": 0, + "loadAfter": [ + 2.03, + 2.29, + 5.79 + ], + "suite": { + "tests": 396, + "failures": 0, + "skipped": 1, + "errors": 0 + }, + "resultFiles": 61, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 197.55, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 126.99, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 58.11, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 112.59, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 187.13, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 80.95, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 263.88, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 266.06, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 2152.41, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 5345.68, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 2.27, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 2.28, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 2.27, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 112.07, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 201.47, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 9.29, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 32.95, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 9.96, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 12.89, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 14.33, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 6.52, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 8.91, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 2.95, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 20.26, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 24.51, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 31.02, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 496.79, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 508.24, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 825.38, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 991.92, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 554.49, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 579.14, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 542.09, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 551.29, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 565.98, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 604.35, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 493.54, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 494.28, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 522.68, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 541.04, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 527.6, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 542.92, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 4.24, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 10.75, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 6.5, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 229.48, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 1111.52, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 882.03, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 622.75, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 67.0, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 633.54, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.01, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 10.79, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 8.67, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 25.99, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 576.45, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 567.77, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 550.45, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 250.48, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 261.73, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 170.44, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 89.09, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 51.78, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 2.2, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 3.38, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 6.46, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + } + ], + "status": "ok" + }, + { + "target": "iosSimulatorArm64", + "task": ":python-multiplatform:iosSimulatorArm64Test", + "run": 1, + "requiresDevice": false, + "notes": "boots a simulator through simctl; needs Xcode licence acceptance", + "log": "benchmarks/results/logs/iosSimulatorArm64-run1-20260814-163200.log", + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:iosSimulatorArm64Test --console=plain --rerun", + "loadBefore": [ + 2.03, + 2.29, + 5.79 + ], + "wallSeconds": 66.1, + "exitCode": 0, + "loadAfter": [ + 5.24, + 3.32, + 5.93 + ], + "simulators": { + "bootedAfter": [], + "newlyBooted": [] + }, + "suite": { + "tests": 350, + "failures": 0, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 51, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 70.8, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 57.03, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 66.2, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 1562.09, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 3490.55, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 1007.93, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 32067.15, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 4312.22, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 919985.55, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 109747.17, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 1549.84, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 771.76, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 1538.18, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 1942.96, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 3359.97, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 140.43, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 235.43, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 299.27, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 68.87, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 179.16, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 15.39, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 114.8, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 636.01, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 28.26, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 33.2, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 44.91, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 1837.13, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 1861.62, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 3632.54, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 3952.11, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 2926.4, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 2895.57, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 2511.52, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 2486.67, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 2878.2, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 2974.64, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 1886.37, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 2258.17, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 1953.49, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 2322.3, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 1964.06, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 2311.6, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 4.94, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 16.65, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 11.7, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 3071.79, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 84.72, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 3222.24, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.04, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 150.45, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 670.27, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 3592.26, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 2921.99, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 9.92, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 38.32, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 2312.97, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 2303.05, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 2274.65, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 1543.92, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 1655.4, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 2843.16, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 2020.43, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 723.85, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 1.39, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 0.81, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.14, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + } + ], + "status": "ok" + }, + { + "target": "wasmJs", + "task": ":python-multiplatform:wasmJsNodeTest", + "run": 1, + "requiresDevice": false, + "notes": "runs under Node", + "log": "benchmarks/results/logs/wasmJs-run1-20260814-163307.log", + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:wasmJsNodeTest --console=plain --rerun", + "loadBefore": [ + 5.24, + 3.32, + 5.93 + ], + "wallSeconds": 21.3, + "exitCode": 0, + "loadAfter": [ + 6.93, + 3.89, + 6.06 + ], + "suite": { + "tests": 364, + "failures": 0, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 50, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 14.98, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 14.92, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 6.75, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 48.68, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 102.79, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 57.0, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 759.26, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 438.88, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 30692.36, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 11486.64, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 59.17, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 27.08, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 44.91, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 266.86, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 193.1, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "cleaner: registerCleaner + close (the whole hook)", + "key": "cleaner: registercleaner + close (the whole hook)", + "iterations": 50000, + "value": 80.28, + "unit": "ns", + "origin": "python.multiplatform.ref.WasmFinalizationTest" + }, + { + "source": "Benchmark.printReport", + "label": "cleaner: toJsReference() alone (the externref crossing)", + "key": "cleaner: tojsreference() alone (the externref crossing)", + "iterations": 50000, + "value": 32.7, + "unit": "ns", + "origin": "python.multiplatform.ref.WasmFinalizationTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 36.62, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 42.96, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 51.37, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 8.98, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 24.06, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 4.63, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 8.16, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 60.23, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: malloc+encodeToByteArray+copy+free (old)", + "key": "marshal: malloc+encodetobytearray+copy+free (old)", + "iterations": 200000, + "value": 80.44, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: malloc+free only", + "key": "marshal: malloc+free only", + "iterations": 200000, + "value": 14.15, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: encodeToByteArray only", + "key": "marshal: encodetobytearray only", + "iterations": 200000, + "value": 52.39, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: Wasm.scratchUtf8", + "key": "marshal: wasm.scratchutf8", + "iterations": 200000, + "value": 29.48, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: Wasm.internedUtf8 (hit)", + "key": "marshal: wasm.internedutf8 (hit)", + "iterations": 200000, + "value": 21.61, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 54-char name: Wasm.scratchUtf8", + "key": "marshal 54-char name: wasm.scratchutf8", + "iterations": 200000, + "value": 152.15, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 54-char name: Wasm.internedUtf8 (hit)", + "key": "marshal 54-char name: wasm.internedutf8 (hit)", + "iterations": 200000, + "value": 25.88, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr: C string allocated per call (old)", + "key": "getattr: c string allocated per call (old)", + "iterations": 50000, + "value": 259.2, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr: interned C string (new)", + "key": "getattr: interned c string (new)", + "iterations": 50000, + "value": 200.06, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr via actual: C string per call (old)", + "key": "getattr via actual: c string per call (old)", + "iterations": 50000, + "value": 239.95, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr via actual: interned (new)", + "key": "getattr via actual: interned (new)", + "iterations": 50000, + "value": 205.26, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr: scratch C string", + "key": "getattr: scratch c string", + "iterations": 50000, + "value": 204.75, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 4000 B: malloc+encode+copy+free (old)", + "key": "marshal 4000 b: malloc+encode+copy+free (old)", + "iterations": 20000, + "value": 14866.91, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 4000 B: Wasm.scratchUtf8", + "key": "marshal 4000 b: wasm.scratchutf8", + "iterations": 20000, + "value": 9830.11, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Web emscripten (wasm32) / Wasm Kotlin/Wasm", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 37.26, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 42.94, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 57.15, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 298.31, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 312.46, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 691.22, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 1078.36, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 401.04, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 434.34, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 387.06, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 407.76, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 455.47, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 543.09, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 315.21, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 344.52, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 409.84, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 571.64, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 447.21, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 462.02, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 5.68, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 19.89, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 14.2, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Web emscripten (wasm32) / Wasm Kotlin/Wasm", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 238.24, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 2219.05, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 1980.8, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.WasmFinalizationTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Web emscripten (wasm32) / Wasm Kotlin/Wasm", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 15.34, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 49.22, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 310.72, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 295.38, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 261.5, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 46.98, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 100.95, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 164.88, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 129.83, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 25.15, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 3.07, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 1.88, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 2.39, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + } + ], + "status": "ok" + }, + { + "target": "androidNativeArm64", + "task": ":python-multiplatform:androidNativeArm64Test", + "run": 1, + "requiresDevice": true, + "notes": "pushes the test binary to the device; the task writes the JUnit XML itself", + "log": "benchmarks/results/logs/androidNativeArm64-run1-20260814-163328.log", + "device": { + "serial": "emulator-5554", + "avd": "pmp_api36", + "props": { + "ro.build.version.sdk": "36", + "ro.build.version.release": "16", + "ro.product.cpu.abi": "arm64-v8a", + "ro.product.model": "Android SDK built for arm64" + } + }, + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:androidNativeArm64Test --console=plain --rerun -PandroidNativeTestSerial=emulator-5554", + "loadBefore": [ + 6.93, + 3.89, + 6.06 + ], + "wallSeconds": 76.4, + "exitCode": 0, + "loadAfter": [ + 4.0, + 3.73, + 5.81 + ], + "suite": { + "tests": 353, + "failures": 0, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 51, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 70.72, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 84.13, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 95.58, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 2221.67, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 4706.72, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 1306.8, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 40737.47, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 5004.19, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 1171074.26, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 122217.03, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 2286.43, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 1209.32, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 2166.75, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 2703.14, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 4649.53, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 235.79, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 310.15, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 440.54, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 76.93, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 251.28, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 15.15, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 155.69, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 849.25, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 39.6, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 42.6, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 56.3, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 2873.96, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 2851.41, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 5580.55, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 6058.18, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 4364.48, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 4350.47, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 3756.44, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 3784.96, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 4206.93, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 4362.9, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 2743.95, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 3260.99, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 2823.78, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 3418.61, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 2854.26, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 3412.09, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 2.99, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 16.69, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 13.7, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 4313.36, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 111.14, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 4562.32, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.05, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 248.96, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 892.54, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 4262.77, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 3370.23, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 17.31, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 58.45, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 3443.23, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 3425.91, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 3384.78, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 2167.44, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 2365.61, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 4069.4, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 2857.59, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 1051.08, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 1.45, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 0.84, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.2, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + } + ], + "status": "ok" + }, + { + "target": "desktop", + "task": ":python-multiplatform:desktopTest", + "run": 2, + "requiresDevice": false, + "notes": "", + "log": "benchmarks/results/logs/desktop-run2-20260814-163445.log", + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:desktopTest --console=plain --rerun", + "loadBefore": [ + 4.0, + 3.73, + 5.81 + ], + "wallSeconds": 12.7, + "exitCode": 0, + "loadAfter": [ + 4.22, + 3.79, + 5.8 + ], + "suite": { + "tests": 396, + "failures": 0, + "skipped": 1, + "errors": 0 + }, + "resultFiles": 61, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 195.15, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 127.77, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 59.05, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 111.95, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 191.0, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 80.29, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 261.19, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 238.2, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 2082.4, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 5450.99, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 2.31, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 2.32, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 2.35, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 111.24, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 199.9, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 8.09, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 31.26, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 9.63, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 15.97, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 14.64, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 6.64, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 8.64, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 2.26, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 20.5, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 24.73, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 31.55, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 481.82, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 493.55, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 828.36, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 1004.19, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 542.77, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 565.18, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 524.04, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 537.45, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 546.79, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 591.13, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 469.81, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 491.57, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 511.58, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 550.56, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 506.29, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 551.82, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 4.22, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 11.04, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 6.82, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 266.41, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 1136.97, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 870.56, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 593.34, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 68.91, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 627.86, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.05, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 34.52, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 8.67, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 26.09, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 542.84, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 534.16, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 516.75, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 257.99, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 265.81, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 171.62, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 97.95, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 52.32, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 2.04, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 3.16, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 5.54, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + } + ], + "status": "ok" + }, + { + "target": "iosSimulatorArm64", + "task": ":python-multiplatform:iosSimulatorArm64Test", + "run": 2, + "requiresDevice": false, + "notes": "boots a simulator through simctl; needs Xcode licence acceptance", + "log": "benchmarks/results/logs/iosSimulatorArm64-run2-20260814-163458.log", + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:iosSimulatorArm64Test --console=plain --rerun", + "loadBefore": [ + 4.22, + 3.79, + 5.8 + ], + "wallSeconds": 36.7, + "exitCode": 0, + "loadAfter": [ + 3.17, + 3.58, + 5.64 + ], + "simulators": { + "bootedAfter": [], + "newlyBooted": [] + }, + "suite": { + "tests": 350, + "failures": 0, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 51, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 50.58, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 56.84, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 65.1, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 1525.59, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 3338.51, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 978.32, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 30954.59, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 4215.35, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 889250.68, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 107813.46, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 1528.38, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 766.84, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 1534.58, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 1927.48, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 3362.83, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 140.68, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 236.55, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 298.49, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 67.25, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 173.09, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 15.02, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 111.53, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 618.43, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 28.19, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 32.32, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 43.77, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 1816.24, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 1822.69, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 3544.44, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 3839.31, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 2858.65, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 2877.34, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 2466.5, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 2484.14, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 2809.5, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 2897.04, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 1830.55, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 2178.61, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 1887.52, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 2257.21, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 1887.72, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 2261.64, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 4.12, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 15.58, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 11.45, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 3026.97, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 84.76, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 3187.84, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.05, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 160.87, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 626.41, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 3293.42, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 2667.01, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 9.84, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 36.8, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 2259.28, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 2249.43, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 2222.48, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 1488.64, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 1585.46, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 2797.86, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 1948.58, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 701.18, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 1.42, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 0.8, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.15, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + } + ], + "status": "ok" + }, + { + "target": "wasmJs", + "task": ":python-multiplatform:wasmJsNodeTest", + "run": 2, + "requiresDevice": false, + "notes": "runs under Node", + "log": "benchmarks/results/logs/wasmJs-run2-20260814-163535.log", + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:wasmJsNodeTest --console=plain --rerun", + "loadBefore": [ + 3.17, + 3.58, + 5.64 + ], + "wallSeconds": 5.9, + "exitCode": 0, + "loadAfter": [ + 3.16, + 3.58, + 5.63 + ], + "suite": { + "tests": 364, + "failures": 0, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 50, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 14.42, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 14.45, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 6.35, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 47.5, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 101.55, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 58.53, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 753.78, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 433.79, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 30780.56, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 11369.47, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 57.5, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 26.96, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 44.48, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 278.76, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 187.7, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "cleaner: registerCleaner + close (the whole hook)", + "key": "cleaner: registercleaner + close (the whole hook)", + "iterations": 50000, + "value": 90.21, + "unit": "ns", + "origin": "python.multiplatform.ref.WasmFinalizationTest" + }, + { + "source": "Benchmark.printReport", + "label": "cleaner: toJsReference() alone (the externref crossing)", + "key": "cleaner: tojsreference() alone (the externref crossing)", + "iterations": 50000, + "value": 35.38, + "unit": "ns", + "origin": "python.multiplatform.ref.WasmFinalizationTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 35.7, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 41.63, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 49.09, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 8.66, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 23.16, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 4.57, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 7.73, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 57.7, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: malloc+encodeToByteArray+copy+free (old)", + "key": "marshal: malloc+encodetobytearray+copy+free (old)", + "iterations": 200000, + "value": 77.0, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: malloc+free only", + "key": "marshal: malloc+free only", + "iterations": 200000, + "value": 13.41, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: encodeToByteArray only", + "key": "marshal: encodetobytearray only", + "iterations": 200000, + "value": 50.1, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: Wasm.scratchUtf8", + "key": "marshal: wasm.scratchutf8", + "iterations": 200000, + "value": 28.44, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: Wasm.internedUtf8 (hit)", + "key": "marshal: wasm.internedutf8 (hit)", + "iterations": 200000, + "value": 20.71, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 54-char name: Wasm.scratchUtf8", + "key": "marshal 54-char name: wasm.scratchutf8", + "iterations": 200000, + "value": 145.85, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 54-char name: Wasm.internedUtf8 (hit)", + "key": "marshal 54-char name: wasm.internedutf8 (hit)", + "iterations": 200000, + "value": 24.96, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr: C string allocated per call (old)", + "key": "getattr: c string allocated per call (old)", + "iterations": 50000, + "value": 241.35, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr: interned C string (new)", + "key": "getattr: interned c string (new)", + "iterations": 50000, + "value": 186.85, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr via actual: C string per call (old)", + "key": "getattr via actual: c string per call (old)", + "iterations": 50000, + "value": 224.79, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr via actual: interned (new)", + "key": "getattr via actual: interned (new)", + "iterations": 50000, + "value": 191.19, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr: scratch C string", + "key": "getattr: scratch c string", + "iterations": 50000, + "value": 193.11, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 4000 B: malloc+encode+copy+free (old)", + "key": "marshal 4000 b: malloc+encode+copy+free (old)", + "iterations": 20000, + "value": 14310.89, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 4000 B: Wasm.scratchUtf8", + "key": "marshal 4000 b: wasm.scratchutf8", + "iterations": 20000, + "value": 9425.63, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Web emscripten (wasm32) / Wasm Kotlin/Wasm", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 38.46, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 42.73, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 56.78, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 296.88, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 313.83, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 695.6, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 1082.23, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 396.93, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 425.69, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 359.48, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 387.0, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 427.75, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 511.96, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 295.21, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 322.85, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 377.32, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 434.32, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 393.69, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 427.58, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 4.26, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 18.32, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 14.05, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Web emscripten (wasm32) / Wasm Kotlin/Wasm", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 189.51, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 2040.22, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 1850.71, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.WasmFinalizationTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Web emscripten (wasm32) / Wasm Kotlin/Wasm", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 14.74, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 48.43, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 303.3, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 288.56, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 254.87, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 45.23, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 96.53, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 151.05, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 123.15, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 25.0, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 3.14, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 2.0, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 2.46, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + } + ], + "status": "ok" + }, + { + "target": "androidNativeArm64", + "task": ":python-multiplatform:androidNativeArm64Test", + "run": 2, + "requiresDevice": true, + "notes": "pushes the test binary to the device; the task writes the JUnit XML itself", + "log": "benchmarks/results/logs/androidNativeArm64-run2-20260814-163541.log", + "device": { + "serial": "emulator-5554", + "avd": "pmp_api36", + "props": { + "ro.build.version.sdk": "36", + "ro.build.version.release": "16", + "ro.product.cpu.abi": "arm64-v8a", + "ro.product.model": "Android SDK built for arm64" + } + }, + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:androidNativeArm64Test --console=plain --rerun -PandroidNativeTestSerial=emulator-5554", + "loadBefore": [ + 3.16, + 3.58, + 5.63 + ], + "wallSeconds": 46.7, + "exitCode": 0, + "loadAfter": [ + 3.68, + 3.64, + 5.54 + ], + "suite": { + "tests": 353, + "failures": 0, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 51, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 69.11, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 83.98, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 95.75, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 2080.61, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 4419.55, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 1226.12, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 40420.42, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 4947.61, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 1182340.92, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 122345.69, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 2118.88, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 1044.72, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 2068.65, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 2636.53, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 4414.72, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 236.18, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 304.13, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 431.79, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 77.8, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 240.92, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 15.49, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 157.11, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 842.59, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 37.62, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 40.46, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 53.57, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 2666.4, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 2676.43, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 5142.89, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 5534.72, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 4266.05, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 4280.05, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 3659.93, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 3690.89, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 4122.98, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 4254.71, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 2679.07, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 3221.32, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 2752.9, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 3310.59, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 2755.05, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 3310.72, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 2.83, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 15.94, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 13.11, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 4274.61, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 107.09, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 4470.25, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.04, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 195.64, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 793.45, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 4192.05, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 3398.59, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 17.23, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 48.85, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 3382.66, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 3365.42, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 3333.8, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 2062.48, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 2194.26, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 4059.27, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 2839.36, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 1012.97, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 1.54, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 0.83, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.19, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + } + ], + "status": "ok" + }, + { + "target": "desktop", + "task": ":python-multiplatform:desktopTest", + "run": 3, + "requiresDevice": false, + "notes": "", + "log": "benchmarks/results/logs/desktop-run3-20260814-163627.log", + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:desktopTest --console=plain --rerun", + "loadBefore": [ + 3.68, + 3.64, + 5.54 + ], + "wallSeconds": 12.5, + "exitCode": 0, + "loadAfter": [ + 4.1, + 3.75, + 5.55 + ], + "suite": { + "tests": 396, + "failures": 0, + "skipped": 1, + "errors": 0 + }, + "resultFiles": 61, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 190.21, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 124.86, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 59.11, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 109.81, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 185.13, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 115.54, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 252.13, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 273.97, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 1867.14, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 5308.67, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 2.27, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 2.31, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 2.3, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 109.62, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 199.74, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 8.06, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 29.53, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 9.84, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 12.56, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 14.1, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 6.44, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 8.67, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 2.34, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 19.96, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 24.42, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 31.05, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 488.0, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 503.57, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 803.32, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 996.61, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 562.14, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 569.73, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 531.94, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 553.9, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 545.35, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 589.75, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 491.01, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 508.13, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 510.33, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 530.66, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 511.76, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 543.59, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 4.46, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 11.09, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 6.62, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 218.77, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 1081.05, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 862.27, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 602.4, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 66.59, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 628.67, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.04, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 26.26, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "MacOS 26.5.1 (aarch64) / JVM 21.0.12", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 8.68, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 25.77, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 546.67, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 537.99, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 520.9, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 251.02, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 260.27, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 247.42, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 85.52, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 52.6, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 2.1, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 2.2, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 6.39, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + } + ], + "status": "ok" + }, + { + "target": "iosSimulatorArm64", + "task": ":python-multiplatform:iosSimulatorArm64Test", + "run": 3, + "requiresDevice": false, + "notes": "boots a simulator through simctl; needs Xcode licence acceptance", + "log": "benchmarks/results/logs/iosSimulatorArm64-run3-20260814-163640.log", + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:iosSimulatorArm64Test --console=plain --rerun", + "loadBefore": [ + 4.1, + 3.75, + 5.55 + ], + "wallSeconds": 36.5, + "exitCode": 0, + "loadAfter": [ + 3.06, + 3.52, + 5.39 + ], + "simulators": { + "bootedAfter": [], + "newlyBooted": [] + }, + "suite": { + "tests": 350, + "failures": 0, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 51, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 50.6, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 57.14, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 65.13, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 1541.66, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 3382.62, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 976.86, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 31135.54, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 4219.35, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 893572.02, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 107941.9, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 1532.99, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 763.39, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 1516.04, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 1954.6, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 3367.64, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 140.5, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 235.86, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 298.53, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 67.48, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 173.52, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 15.02, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 112.24, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 617.84, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 28.19, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 32.35, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 43.89, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 1806.58, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 1825.87, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 3543.14, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 3846.61, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 2842.71, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 2856.91, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 2472.97, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 2495.96, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 2821.65, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 2895.42, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 1824.02, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 2170.03, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 1878.47, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 2252.48, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 1898.82, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 2254.04, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 4.15, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 15.69, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 11.54, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 3009.88, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 86.92, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 3177.15, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.05, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 167.27, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 638.23, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 3272.17, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 2633.94, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "iOS 26.2 (SDK 260200, arm64) / Native", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 10.17, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 37.72, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 2253.2, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 2243.03, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 2215.47, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 1496.68, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 1599.47, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 2794.42, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 1949.75, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 707.08, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 1.4, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 0.8, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.15, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + } + ], + "status": "ok" + }, + { + "target": "wasmJs", + "task": ":python-multiplatform:wasmJsNodeTest", + "run": 3, + "requiresDevice": false, + "notes": "runs under Node", + "log": "benchmarks/results/logs/wasmJs-run3-20260814-163717.log", + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:wasmJsNodeTest --console=plain --rerun", + "loadBefore": [ + 3.06, + 3.52, + 5.39 + ], + "wallSeconds": 5.7, + "exitCode": 0, + "loadAfter": [ + 2.9, + 3.48, + 5.37 + ], + "suite": { + "tests": 364, + "failures": 0, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 50, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 14.38, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 14.46, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 6.34, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 45.81, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 103.22, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 57.01, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 758.31, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 435.2, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 30560.16, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 11283.89, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 57.65, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 27.02, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 44.24, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 259.71, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 191.08, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "cleaner: registerCleaner + close (the whole hook)", + "key": "cleaner: registercleaner + close (the whole hook)", + "iterations": 50000, + "value": 77.7, + "unit": "ns", + "origin": "python.multiplatform.ref.WasmFinalizationTest" + }, + { + "source": "Benchmark.printReport", + "label": "cleaner: toJsReference() alone (the externref crossing)", + "key": "cleaner: tojsreference() alone (the externref crossing)", + "iterations": 50000, + "value": 33.57, + "unit": "ns", + "origin": "python.multiplatform.ref.WasmFinalizationTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 34.78, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 44.03, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 49.23, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 8.73, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 23.07, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 4.45, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 7.78, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 57.94, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: malloc+encodeToByteArray+copy+free (old)", + "key": "marshal: malloc+encodetobytearray+copy+free (old)", + "iterations": 200000, + "value": 76.95, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: malloc+free only", + "key": "marshal: malloc+free only", + "iterations": 200000, + "value": 13.5, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: encodeToByteArray only", + "key": "marshal: encodetobytearray only", + "iterations": 200000, + "value": 50.42, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: Wasm.scratchUtf8", + "key": "marshal: wasm.scratchutf8", + "iterations": 200000, + "value": 28.48, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal: Wasm.internedUtf8 (hit)", + "key": "marshal: wasm.internedutf8 (hit)", + "iterations": 200000, + "value": 21.71, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 54-char name: Wasm.scratchUtf8", + "key": "marshal 54-char name: wasm.scratchutf8", + "iterations": 200000, + "value": 145.61, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 54-char name: Wasm.internedUtf8 (hit)", + "key": "marshal 54-char name: wasm.internedutf8 (hit)", + "iterations": 200000, + "value": 24.73, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr: C string allocated per call (old)", + "key": "getattr: c string allocated per call (old)", + "iterations": 50000, + "value": 242.4, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr: interned C string (new)", + "key": "getattr: interned c string (new)", + "iterations": 50000, + "value": 187.64, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr via actual: C string per call (old)", + "key": "getattr via actual: c string per call (old)", + "iterations": 50000, + "value": 222.2, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr via actual: interned (new)", + "key": "getattr via actual: interned (new)", + "iterations": 50000, + "value": 193.03, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "getattr: scratch C string", + "key": "getattr: scratch c string", + "iterations": 50000, + "value": 192.62, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 4000 B: malloc+encode+copy+free (old)", + "key": "marshal 4000 b: malloc+encode+copy+free (old)", + "iterations": 20000, + "value": 14285.62, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "marshal 4000 B: Wasm.scratchUtf8", + "key": "marshal 4000 b: wasm.scratchutf8", + "iterations": 20000, + "value": 9420.6, + "unit": "ns", + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Web emscripten (wasm32) / Wasm Kotlin/Wasm", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 37.47, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 41.74, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 57.06, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 296.17, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 314.19, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 693.56, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 1079.33, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 403.25, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 430.66, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 361.2, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 383.43, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 429.79, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 514.4, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 294.77, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 324.03, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 378.71, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 436.1, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 393.86, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 431.77, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 4.27, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 19.59, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 15.32, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Web emscripten (wasm32) / Wasm Kotlin/Wasm", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 190.6, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 2044.08, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 1853.48, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.WasmFinalizationTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Web emscripten (wasm32) / Wasm Kotlin/Wasm", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 14.74, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 47.89, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 302.41, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 287.67, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 254.51, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 45.63, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 97.72, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 149.5, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 124.67, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 24.66, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 3.09, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 2.02, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 2.42, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.native.ffi.WasmMarshallingOverheadTest" + } + ], + "status": "ok" + }, + { + "target": "androidNativeArm64", + "task": ":python-multiplatform:androidNativeArm64Test", + "run": 3, + "requiresDevice": true, + "notes": "pushes the test binary to the device; the task writes the JUnit XML itself", + "log": "benchmarks/results/logs/androidNativeArm64-run3-20260814-163722.log", + "device": { + "serial": "emulator-5554", + "avd": "pmp_api36", + "props": { + "ro.build.version.sdk": "36", + "ro.build.version.release": "16", + "ro.product.cpu.abi": "arm64-v8a", + "ro.product.model": "Android SDK built for arm64" + } + }, + "command": "/Volumes/macMini/thisisthepy/PythonMultiplatform/gradlew :python-multiplatform:androidNativeArm64Test --console=plain --rerun -PandroidNativeTestSerial=emulator-5554", + "loadBefore": [ + 2.9, + 3.48, + 5.37 + ], + "wallSeconds": 46.3, + "exitCode": 0, + "loadAfter": [ + 2.47, + 3.28, + 5.19 + ], + "suite": { + "tests": 353, + "failures": 0, + "skipped": 0, + "errors": 0 + }, + "resultFiles": 51, + "deviceDirs": [], + "rows": [ + { + "source": "Benchmark.printReport", + "label": "NativePointer.toRawValue()", + "key": "nativepointer.torawvalue()", + "iterations": 1000000, + "value": 68.96, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "NativePointer.toAddressValue()", + "key": "nativepointer.toaddressvalue()", + "iterations": 1000000, + "value": 83.42, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "AddressValue.toNativePointer()", + "key": "addressvalue.tonativepointer()", + "iterations": 1000000, + "value": 95.53, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Py_IncRef/Py_DecRef", + "key": "py_incref/py_decref", + "iterations": 1000000, + "value": 2101.64, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8 chars)", + "key": "pyunicode_fromstring (8 chars)", + "iterations": 10000, + "value": 4437.56, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8 chars)", + "key": "pyunicode_asutf8 (8 chars)", + "iterations": 10000, + "value": 1243.76, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (256 chars)", + "key": "pyunicode_fromstring (256 chars)", + "iterations": 10000, + "value": 40303.97, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (256 chars)", + "key": "pyunicode_asutf8 (256 chars)", + "iterations": 10000, + "value": 4931.79, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_FromString (8192 chars)", + "key": "pyunicode_fromstring (8192 chars)", + "iterations": 10000, + "value": 1169590.51, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyUnicode_AsUTF8 (8192 chars)", + "key": "pyunicode_asutf8 (8192 chars)", + "iterations": 10000, + "value": 123729.16, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_FromLongLong", + "key": "pylong_fromlonglong", + "iterations": 100000, + "value": 2137.91, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyLong_AsLongLong", + "key": "pylong_aslonglong", + "iterations": 100000, + "value": 1066.52, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "Manual IncRef/DecRef", + "key": "manual incref/decref", + "iterations": 100000, + "value": 2063.26, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject wrapper creation", + "key": "pyobject wrapper creation", + "iterations": 100000, + "value": 2626.97, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "PyObject_GetAttrString", + "key": "pyobject_getattrstring", + "iterations": 10000, + "value": 4486.95, + "unit": "ns", + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.resolve(name) [200 entries]", + "key": "upcalltable.resolve(name) [200 entries]", + "iterations": 1000000, + "value": 242.4, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invoke(handle, args)", + "key": "upcalltable.invoke(handle, args)", + "iterations": 1000000, + "value": 303.76, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "UpcallTable.invokeByName(name, args)", + "key": "upcalltable.invokebyname(name, args)", + "iterations": 1000000, + "value": 430.23, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "arrayOf(1L) allocation + boxing", + "key": "arrayof(1l) allocation + boxing", + "iterations": 1000000, + "value": 77.71, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "invoke with a pre-built args array", + "key": "invoke with a pre-built args array", + "iterations": 1000000, + "value": 242.24, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "direct Kotlin call (floor)", + "key": "direct kotlin call (floor)", + "iterations": 1000000, + "value": 15.26, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.resolve(handle)", + "key": "handletable.resolve(handle)", + "iterations": 1000000, + "value": 157.77, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "source": "Benchmark.printReport", + "label": "HandleTable.register + release", + "key": "handletable.register + release", + "iterations": 500000, + "value": 849.92, + "unit": "ns", + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + } + ], + "blocks": [ + { + "block": "START OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "END OF GilParkingTest", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ffi.GilParkingTest" + }, + { + "block": "Generated proxy cost", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "empty Python function (the harness floor)", + "key": "empty python function (the harness floor)", + "value": 37.63, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.x pure Python, no boundary", + "key": "plain.x pure python, no boundary", + "value": 40.5, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "plain.p pure Python @property, no boundary", + "key": "plain.p pure python @property, no boundary", + "value": 53.47, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0", + "key": "_pm_invoke(h, ()) raw boundary, arity 0", + "value": 2655.5, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.ping() module function proxy", + "key": "demo.calc.ping() module function proxy", + "value": 2681.62, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "key": "_pm_release(_pm_invoke(h, (10,))) raw boundary + release", + "value": 5125.55, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter(10) constructor proxy", + "key": "counter(10) constructor proxy", + "value": 5577.53, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 5)) raw boundary", + "key": "_pm_invoke(h, (self, 5)) raw boundary", + "value": 4192.54, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.increment(5) instance method proxy", + "key": "c.increment(5) instance method proxy", + "value": 4228.37, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self,)) raw boundary", + "key": "_pm_invoke(h, (self,)) raw boundary", + "value": 3659.1, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.value property read (descriptor)", + "key": "c.value property read (descriptor)", + "value": 3670.61, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (self, 'x')) raw boundary", + "key": "_pm_invoke(h, (self, 'x')) raw boundary", + "value": 4078.47, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "c.label = 'x' property write (descriptor)", + "key": "c.label = 'x' property write (descriptor)", + "value": 4171.21, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "key": "_pm_invoke(h, ()) raw boundary, arity 0 again", + "value": 2701.15, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "_pm_invoke(h, (12,)) raw boundary", + "key": "_pm_invoke(h, (12,)) raw boundary", + "value": 3221.11, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created static read (metaclass)", + "key": "counter.created static read (metaclass)", + "value": 2757.78, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "Counter.created = 12 static write (metaclass)", + "key": "counter.created = 12 static write (metaclass)", + "value": 3331.42, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally top-level read (module type)", + "key": "demo.calc.tally top-level read (module type)", + "value": 2778.39, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Raw figures (timed inside Python, one shared loop)", + "label": "demo.calc.tally = 9 top-level write (module type)", + "key": "demo.calc.tally = 9 top-level write (module type)", + "value": 3316.83, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "plain attribute read", + "key": "plain attribute read", + "value": 2.86, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "@property read", + "key": "@property read", + "value": 15.83, + "unit": "ns" + }, + { + "source": "Generated proxy cost", + "section": "Pure-Python floors, net of the harness (no boundary anywhere in these)", + "label": "the descriptor itself costs", + "key": "the descriptor itself costs", + "value": 12.97, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy cost, await fast path", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations per row": "10000 x 3 (best taken)", + "warmup": "5000" + }, + "metrics": [ + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "key": "_pm_invoke(h, (21,)) inside the coroutine, no await", + "value": 4216.17, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await a pure-Python coroutine that never suspends", + "key": "await a pure-python coroutine that never suspends", + "value": 104.95, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await demo.calc.doubleNow(21) generated async def, fast path", + "key": "await demo.calc.doublenow(21) generated async def, fast path", + "value": 4428.08, + "unit": "ns" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "await proxy / raw boundary in the same loop", + "key": "await proxy / raw boundary in the same loop", + "value": 1.05, + "unit": "x" + }, + { + "source": "Generated proxy cost, await fast path", + "section": "", + "label": "what the async def wrapper adds", + "key": "what the async def wrapper adds", + "value": 211.9, + "unit": "ns" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Generated proxy install cost", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations": "20", + "warmup": "5" + }, + "metrics": [ + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.render() Kotlin string building only", + "key": "pythonproxysource.render() kotlin string building only", + "value": 798.64, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "PythonProxySource.install() render + Python3.exec", + "key": "pythonproxysource.install() render + python3.exec", + "value": 4209.19, + "unit": "us" + }, + { + "source": "Generated proxy install cost", + "section": "", + "label": "...of which Python3.exec", + "key": "of which python3.exec", + "value": 3410.54, + "unit": "us" + } + ], + "origin": "python.multiplatform.ffi.upcall.GeneratedProxyCostTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.overhead.BenchmarkTest" + }, + { + "block": "eval-loop checkpoint cost (freeThreaded=false)", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.ref.EvalCheckpointTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Benchmark Report", + "platform": null, + "conditions": {}, + "metrics": [], + "origin": "python.multiplatform.reflection.UpcallOverheadTest" + }, + { + "block": "Upcall boundary cost", + "platform": "Android 16 (SDK 36, aarch64) / Native", + "conditions": { + "iterations per loop": "10000", + "warmup": "100000" + }, + "metrics": [ + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "empty Python loop", + "key": "empty python loop", + "value": 17.22, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "pure-Python callee, same shape", + "key": "pure-python callee, same shape", + "value": 48.86, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall to Kotlin", + "key": "upcall to kotlin", + "value": 3348.75, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of loop", + "key": "upcall net of loop", + "value": 3331.52, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Python-driven (timed inside Python)", + "label": "upcall net of a Python call", + "key": "upcall net of a python call", + "value": 3299.88, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each)", + "key": "py_incref + py_decref (2 downcalls, a gil scope each)", + "value": 2058.84, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "PyObject_CallObject on a Python def (downcall, same shape)", + "key": "pyobject_callobject on a python def (downcall, same shape)", + "value": 2205.57, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "UpcallTrampoline.invoke from Kotlin (no shim, no boundary)", + "key": "upcalltrampoline.invoke from kotlin (no shim, no boundary)", + "value": 3957.01, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...the same, with the GIL already held by the caller", + "key": "the same, with the gil already held by the caller", + "value": 2790.19, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Comparison basis, same run (timed inside Kotlin)", + "label": "...Python3.withPython { } with nothing in it, for scale", + "key": "python3.withpython { } with nothing in it, for scale", + "value": 998.73, + "unit": "ns" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / downcall of the same shape", + "key": "upcall / downcall of the same shape", + "value": 1.51, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, caller holding nothing", + "key": "upcall / trampoline alone, caller holding nothing", + "value": 0.84, + "unit": "x" + }, + { + "source": "Upcall boundary cost", + "section": "Ratios", + "label": "upcall / trampoline alone, GIL held (what the shim adds)", + "key": "upcall / trampoline alone, gil held (what the shim adds)", + "value": 1.2, + "unit": "x" + } + ], + "origin": "python.native.ffi.UpcallBoundaryCostTest" + } + ], + "status": "ok" + } + ], + "loadAtEnd": [ + 2.47, + 3.28, + 5.19 + ] +} diff --git a/benchmarks/results/cut-hostless.tsv b/benchmarks/results/cut-hostless.tsv new file mode 100644 index 000000000..62f23913d --- /dev/null +++ b/benchmarks/results/cut-hostless.tsv @@ -0,0 +1,826 @@ +target run status origin source section label value unit iterations platform commit contaminated +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 197.55 ns 10000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 126.99 ns 100000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 58.11 ns 100000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 112.59 ns 1000000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 187.13 ns 10000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 80.95 ns 10000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 263.88 ns 10000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 266.06 ns 10000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 2152.41 ns 10000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 5345.68 ns 10000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 2.27 ns 1000000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 2.28 ns 1000000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 2.27 ns 1000000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 112.07 ns 100000 958c0082b294 false +desktop 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 201.47 ns 100000 958c0082b294 false +desktop 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 9.29 ns 1000000 958c0082b294 false +desktop 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 32.95 ns 500000 958c0082b294 false +desktop 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 9.96 ns 1000000 958c0082b294 false +desktop 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 12.89 ns 1000000 958c0082b294 false +desktop 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 14.33 ns 1000000 958c0082b294 false +desktop 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 6.52 ns 1000000 958c0082b294 false +desktop 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 8.91 ns 1000000 958c0082b294 false +desktop 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 2.95 ns 1000000 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 20.26 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 24.51 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 31.02 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 496.79 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 508.24 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 825.38 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 991.92 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 554.49 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 579.14 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 542.09 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 551.29 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 565.98 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 604.35 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 493.54 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 494.28 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 522.68 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 541.04 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 527.6 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 542.92 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 4.24 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 10.75 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 6.5 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 229.48 us MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 1111.52 us MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 882.03 us MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 622.75 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 67.0 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 633.54 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.01 x MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 10.79 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 8.67 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 25.99 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 576.45 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 567.77 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 550.45 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 250.48 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 261.73 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 170.44 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 89.09 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 51.78 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 2.2 x 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 3.38 x 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 6.46 x 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 70.8 ns 1000000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 57.03 ns 1000000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 66.2 ns 1000000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 1562.09 ns 1000000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 3490.55 ns 10000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 1007.93 ns 10000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 32067.15 ns 10000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 4312.22 ns 10000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 919985.55 ns 10000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 109747.17 ns 10000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 1549.84 ns 100000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 771.76 ns 100000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 1538.18 ns 100000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 1942.96 ns 100000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 3359.97 ns 10000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 140.43 ns 1000000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 235.43 ns 1000000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 299.27 ns 1000000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 68.87 ns 1000000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 179.16 ns 1000000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 15.39 ns 1000000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 114.8 ns 1000000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 636.01 ns 500000 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 28.26 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 33.2 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 44.91 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 1837.13 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 1861.62 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 3632.54 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 3952.11 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 2926.4 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 2895.57 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 2511.52 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 2486.67 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 2878.2 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 2974.64 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 1886.37 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 2258.17 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 1953.49 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 2322.3 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 1964.06 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 2311.6 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 4.94 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 16.65 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 11.7 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 3071.79 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 84.72 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 3222.24 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.04 x iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 150.45 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 670.27 us iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 3592.26 us iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 2921.99 us iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 9.92 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 38.32 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 2312.97 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 2303.05 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 2274.65 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 1543.92 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 1655.4 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 2843.16 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 2020.43 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 723.85 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 1.39 x 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 0.81 x 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.14 x 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 14.98 ns 1000000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 14.92 ns 1000000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 6.75 ns 1000000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 48.68 ns 1000000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 102.79 ns 10000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 57.0 ns 10000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 759.26 ns 10000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 438.88 ns 10000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 30692.36 ns 10000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 11486.64 ns 10000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 59.17 ns 100000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 27.08 ns 100000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 44.91 ns 100000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 266.86 ns 100000 958c0082b294 false +wasmJs 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 193.1 ns 10000 958c0082b294 false +wasmJs 1 ok python.multiplatform.ref.WasmFinalizationTest Benchmark.printReport cleaner: registerCleaner + close (the whole hook) 80.28 ns 50000 958c0082b294 false +wasmJs 1 ok python.multiplatform.ref.WasmFinalizationTest Benchmark.printReport cleaner: toJsReference() alone (the externref crossing) 32.7 ns 50000 958c0082b294 false +wasmJs 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 36.62 ns 1000000 958c0082b294 false +wasmJs 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 42.96 ns 1000000 958c0082b294 false +wasmJs 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 51.37 ns 1000000 958c0082b294 false +wasmJs 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 8.98 ns 1000000 958c0082b294 false +wasmJs 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 24.06 ns 1000000 958c0082b294 false +wasmJs 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 4.63 ns 1000000 958c0082b294 false +wasmJs 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 8.16 ns 1000000 958c0082b294 false +wasmJs 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 60.23 ns 500000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: malloc+encodeToByteArray+copy+free (old) 80.44 ns 200000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: malloc+free only 14.15 ns 200000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: encodeToByteArray only 52.39 ns 200000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: Wasm.scratchUtf8 29.48 ns 200000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: Wasm.internedUtf8 (hit) 21.61 ns 200000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 54-char name: Wasm.scratchUtf8 152.15 ns 200000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 54-char name: Wasm.internedUtf8 (hit) 25.88 ns 200000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr: C string allocated per call (old) 259.2 ns 50000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr: interned C string (new) 200.06 ns 50000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr via actual: C string per call (old) 239.95 ns 50000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr via actual: interned (new) 205.26 ns 50000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr: scratch C string 204.75 ns 50000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 4000 B: malloc+encode+copy+free (old) 14866.91 ns 20000 958c0082b294 false +wasmJs 1 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 4000 B: Wasm.scratchUtf8 9830.11 ns 20000 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 37.26 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 42.94 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 57.15 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 298.31 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 312.46 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 691.22 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 1078.36 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 401.04 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 434.34 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 387.06 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 407.76 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 455.47 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 543.09 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 315.21 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 344.52 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 409.84 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 571.64 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 447.21 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 462.02 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 5.68 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 19.89 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 14.2 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 238.24 us Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 2219.05 us Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 1980.8 us Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 15.34 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 49.22 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 310.72 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 295.38 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 261.5 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 46.98 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 100.95 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 164.88 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 129.83 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 25.15 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 3.07 x 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 1.88 x 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 2.39 x 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 70.72 ns 1000000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 84.13 ns 1000000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 95.58 ns 1000000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 2221.67 ns 1000000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 4706.72 ns 10000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 1306.8 ns 10000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 40737.47 ns 10000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 5004.19 ns 10000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 1171074.26 ns 10000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 122217.03 ns 10000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 2286.43 ns 100000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 1209.32 ns 100000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 2166.75 ns 100000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 2703.14 ns 100000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 4649.53 ns 10000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 235.79 ns 1000000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 310.15 ns 1000000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 440.54 ns 1000000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 76.93 ns 1000000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 251.28 ns 1000000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 15.15 ns 1000000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 155.69 ns 1000000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 849.25 ns 500000 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 39.6 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 42.6 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 56.3 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 2873.96 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 2851.41 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 5580.55 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 6058.18 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 4364.48 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 4350.47 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 3756.44 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 3784.96 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 4206.93 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 4362.9 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 2743.95 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 3260.99 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 2823.78 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 3418.61 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 2854.26 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 3412.09 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 2.99 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 16.69 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 13.7 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 4313.36 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 111.14 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 4562.32 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.05 x Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 248.96 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 892.54 us Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 4262.77 us Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 3370.23 us Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 17.31 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 58.45 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 3443.23 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 3425.91 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 3384.78 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 2167.44 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 2365.61 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 4069.4 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 2857.59 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 1051.08 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 1.45 x 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 0.84 x 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 1 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.2 x 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 195.15 ns 10000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 127.77 ns 100000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 59.05 ns 100000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 111.95 ns 1000000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 191.0 ns 10000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 80.29 ns 10000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 261.19 ns 10000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 238.2 ns 10000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 2082.4 ns 10000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 5450.99 ns 10000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 2.31 ns 1000000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 2.32 ns 1000000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 2.35 ns 1000000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 111.24 ns 100000 958c0082b294 false +desktop 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 199.9 ns 100000 958c0082b294 false +desktop 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 8.09 ns 1000000 958c0082b294 false +desktop 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 31.26 ns 500000 958c0082b294 false +desktop 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 9.63 ns 1000000 958c0082b294 false +desktop 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 15.97 ns 1000000 958c0082b294 false +desktop 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 14.64 ns 1000000 958c0082b294 false +desktop 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 6.64 ns 1000000 958c0082b294 false +desktop 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 8.64 ns 1000000 958c0082b294 false +desktop 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 2.26 ns 1000000 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 20.5 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 24.73 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 31.55 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 481.82 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 493.55 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 828.36 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 1004.19 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 542.77 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 565.18 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 524.04 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 537.45 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 546.79 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 591.13 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 469.81 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 491.57 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 511.58 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 550.56 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 506.29 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 551.82 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 4.22 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 11.04 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 6.82 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 266.41 us MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 1136.97 us MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 870.56 us MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 593.34 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 68.91 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 627.86 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.05 x MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 34.52 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 8.67 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 26.09 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 542.84 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 534.16 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 516.75 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 257.99 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 265.81 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 171.62 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 97.95 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 52.32 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 2.04 x 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 3.16 x 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 5.54 x 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 50.58 ns 1000000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 56.84 ns 1000000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 65.1 ns 1000000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 1525.59 ns 1000000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 3338.51 ns 10000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 978.32 ns 10000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 30954.59 ns 10000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 4215.35 ns 10000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 889250.68 ns 10000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 107813.46 ns 10000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 1528.38 ns 100000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 766.84 ns 100000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 1534.58 ns 100000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 1927.48 ns 100000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 3362.83 ns 10000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 140.68 ns 1000000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 236.55 ns 1000000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 298.49 ns 1000000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 67.25 ns 1000000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 173.09 ns 1000000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 15.02 ns 1000000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 111.53 ns 1000000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 618.43 ns 500000 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 28.19 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 32.32 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 43.77 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 1816.24 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 1822.69 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 3544.44 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 3839.31 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 2858.65 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 2877.34 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 2466.5 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 2484.14 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 2809.5 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 2897.04 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 1830.55 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 2178.61 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 1887.52 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 2257.21 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 1887.72 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 2261.64 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 4.12 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 15.58 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 11.45 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 3026.97 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 84.76 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 3187.84 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.05 x iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 160.87 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 626.41 us iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 3293.42 us iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 2667.01 us iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 9.84 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 36.8 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 2259.28 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 2249.43 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 2222.48 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 1488.64 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 1585.46 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 2797.86 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 1948.58 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 701.18 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 1.42 x 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 0.8 x 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.15 x 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 14.42 ns 1000000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 14.45 ns 1000000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 6.35 ns 1000000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 47.5 ns 1000000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 101.55 ns 10000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 58.53 ns 10000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 753.78 ns 10000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 433.79 ns 10000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 30780.56 ns 10000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 11369.47 ns 10000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 57.5 ns 100000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 26.96 ns 100000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 44.48 ns 100000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 278.76 ns 100000 958c0082b294 false +wasmJs 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 187.7 ns 10000 958c0082b294 false +wasmJs 2 ok python.multiplatform.ref.WasmFinalizationTest Benchmark.printReport cleaner: registerCleaner + close (the whole hook) 90.21 ns 50000 958c0082b294 false +wasmJs 2 ok python.multiplatform.ref.WasmFinalizationTest Benchmark.printReport cleaner: toJsReference() alone (the externref crossing) 35.38 ns 50000 958c0082b294 false +wasmJs 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 35.7 ns 1000000 958c0082b294 false +wasmJs 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 41.63 ns 1000000 958c0082b294 false +wasmJs 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 49.09 ns 1000000 958c0082b294 false +wasmJs 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 8.66 ns 1000000 958c0082b294 false +wasmJs 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 23.16 ns 1000000 958c0082b294 false +wasmJs 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 4.57 ns 1000000 958c0082b294 false +wasmJs 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 7.73 ns 1000000 958c0082b294 false +wasmJs 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 57.7 ns 500000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: malloc+encodeToByteArray+copy+free (old) 77.0 ns 200000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: malloc+free only 13.41 ns 200000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: encodeToByteArray only 50.1 ns 200000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: Wasm.scratchUtf8 28.44 ns 200000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: Wasm.internedUtf8 (hit) 20.71 ns 200000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 54-char name: Wasm.scratchUtf8 145.85 ns 200000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 54-char name: Wasm.internedUtf8 (hit) 24.96 ns 200000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr: C string allocated per call (old) 241.35 ns 50000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr: interned C string (new) 186.85 ns 50000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr via actual: C string per call (old) 224.79 ns 50000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr via actual: interned (new) 191.19 ns 50000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr: scratch C string 193.11 ns 50000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 4000 B: malloc+encode+copy+free (old) 14310.89 ns 20000 958c0082b294 false +wasmJs 2 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 4000 B: Wasm.scratchUtf8 9425.63 ns 20000 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 38.46 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 42.73 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 56.78 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 296.88 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 313.83 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 695.6 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 1082.23 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 396.93 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 425.69 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 359.48 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 387.0 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 427.75 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 511.96 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 295.21 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 322.85 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 377.32 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 434.32 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 393.69 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 427.58 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 4.26 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 18.32 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 14.05 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 189.51 us Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 2040.22 us Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 1850.71 us Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 14.74 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 48.43 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 303.3 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 288.56 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 254.87 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 45.23 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 96.53 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 151.05 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 123.15 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 25.0 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 3.14 x 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 2.0 x 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 2.46 x 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 69.11 ns 1000000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 83.98 ns 1000000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 95.75 ns 1000000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 2080.61 ns 1000000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 4419.55 ns 10000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 1226.12 ns 10000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 40420.42 ns 10000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 4947.61 ns 10000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 1182340.92 ns 10000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 122345.69 ns 10000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 2118.88 ns 100000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 1044.72 ns 100000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 2068.65 ns 100000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 2636.53 ns 100000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 4414.72 ns 10000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 236.18 ns 1000000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 304.13 ns 1000000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 431.79 ns 1000000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 77.8 ns 1000000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 240.92 ns 1000000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 15.49 ns 1000000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 157.11 ns 1000000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 842.59 ns 500000 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 37.62 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 40.46 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 53.57 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 2666.4 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 2676.43 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 5142.89 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 5534.72 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 4266.05 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 4280.05 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 3659.93 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 3690.89 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 4122.98 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 4254.71 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 2679.07 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 3221.32 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 2752.9 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 3310.59 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 2755.05 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 3310.72 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 2.83 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 15.94 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 13.11 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 4274.61 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 107.09 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 4470.25 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.04 x Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 195.64 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 793.45 us Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 4192.05 us Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 3398.59 us Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 17.23 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 48.85 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 3382.66 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 3365.42 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 3333.8 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 2062.48 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 2194.26 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 4059.27 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 2839.36 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 1012.97 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 1.54 x 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 0.83 x 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 2 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.19 x 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 190.21 ns 10000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 124.86 ns 100000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 59.11 ns 100000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 109.81 ns 1000000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 185.13 ns 10000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 115.54 ns 10000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 252.13 ns 10000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 273.97 ns 10000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 1867.14 ns 10000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 5308.67 ns 10000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 2.27 ns 1000000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 2.31 ns 1000000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 2.3 ns 1000000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 109.62 ns 100000 958c0082b294 false +desktop 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 199.74 ns 100000 958c0082b294 false +desktop 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 8.06 ns 1000000 958c0082b294 false +desktop 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 29.53 ns 500000 958c0082b294 false +desktop 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 9.84 ns 1000000 958c0082b294 false +desktop 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 12.56 ns 1000000 958c0082b294 false +desktop 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 14.1 ns 1000000 958c0082b294 false +desktop 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 6.44 ns 1000000 958c0082b294 false +desktop 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 8.67 ns 1000000 958c0082b294 false +desktop 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 2.34 ns 1000000 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 19.96 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 24.42 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 31.05 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 488.0 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 503.57 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 803.32 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 996.61 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 562.14 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 569.73 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 531.94 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 553.9 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 545.35 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 589.75 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 491.01 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 508.13 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 510.33 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 530.66 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 511.76 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 543.59 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 4.46 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 11.09 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 6.62 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 218.77 us MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 1081.05 us MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 862.27 us MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 602.4 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 66.59 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 628.67 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.04 x MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 26.26 ns MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 8.68 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 25.77 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 546.67 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 537.99 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 520.9 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 251.02 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 260.27 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 247.42 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 85.52 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 52.6 ns 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 2.1 x 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 2.2 x 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +desktop 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 6.39 x 10000 MacOS 26.5.1 (aarch64) / JVM 21.0.12 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 50.6 ns 1000000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 57.14 ns 1000000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 65.13 ns 1000000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 1541.66 ns 1000000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 3382.62 ns 10000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 976.86 ns 10000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 31135.54 ns 10000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 4219.35 ns 10000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 893572.02 ns 10000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 107941.9 ns 10000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 1532.99 ns 100000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 763.39 ns 100000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 1516.04 ns 100000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 1954.6 ns 100000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 3367.64 ns 10000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 140.5 ns 1000000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 235.86 ns 1000000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 298.53 ns 1000000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 67.48 ns 1000000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 173.52 ns 1000000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 15.02 ns 1000000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 112.24 ns 1000000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 617.84 ns 500000 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 28.19 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 32.35 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 43.89 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 1806.58 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 1825.87 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 3543.14 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 3846.61 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 2842.71 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 2856.91 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 2472.97 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 2495.96 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 2821.65 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 2895.42 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 1824.02 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 2170.03 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 1878.47 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 2252.48 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 1898.82 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 2254.04 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 4.15 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 15.69 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 11.54 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 3009.88 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 86.92 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 3177.15 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.05 x iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 167.27 ns iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 638.23 us iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 3272.17 us iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 2633.94 us iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 10.17 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 37.72 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 2253.2 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 2243.03 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 2215.47 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 1496.68 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 1599.47 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 2794.42 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 1949.75 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 707.08 ns 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 1.4 x 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 0.8 x 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +iosSimulatorArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.15 x 10000 iOS 26.2 (SDK 260200, arm64) / Native 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 14.38 ns 1000000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 14.46 ns 1000000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 6.34 ns 1000000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 45.81 ns 1000000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 103.22 ns 10000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 57.01 ns 10000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 758.31 ns 10000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 435.2 ns 10000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 30560.16 ns 10000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 11283.89 ns 10000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 57.65 ns 100000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 27.02 ns 100000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 44.24 ns 100000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 259.71 ns 100000 958c0082b294 false +wasmJs 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 191.08 ns 10000 958c0082b294 false +wasmJs 3 ok python.multiplatform.ref.WasmFinalizationTest Benchmark.printReport cleaner: registerCleaner + close (the whole hook) 77.7 ns 50000 958c0082b294 false +wasmJs 3 ok python.multiplatform.ref.WasmFinalizationTest Benchmark.printReport cleaner: toJsReference() alone (the externref crossing) 33.57 ns 50000 958c0082b294 false +wasmJs 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 34.78 ns 1000000 958c0082b294 false +wasmJs 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 44.03 ns 1000000 958c0082b294 false +wasmJs 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 49.23 ns 1000000 958c0082b294 false +wasmJs 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 8.73 ns 1000000 958c0082b294 false +wasmJs 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 23.07 ns 1000000 958c0082b294 false +wasmJs 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 4.45 ns 1000000 958c0082b294 false +wasmJs 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 7.78 ns 1000000 958c0082b294 false +wasmJs 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 57.94 ns 500000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: malloc+encodeToByteArray+copy+free (old) 76.95 ns 200000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: malloc+free only 13.5 ns 200000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: encodeToByteArray only 50.42 ns 200000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: Wasm.scratchUtf8 28.48 ns 200000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal: Wasm.internedUtf8 (hit) 21.71 ns 200000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 54-char name: Wasm.scratchUtf8 145.61 ns 200000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 54-char name: Wasm.internedUtf8 (hit) 24.73 ns 200000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr: C string allocated per call (old) 242.4 ns 50000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr: interned C string (new) 187.64 ns 50000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr via actual: C string per call (old) 222.2 ns 50000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr via actual: interned (new) 193.03 ns 50000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport getattr: scratch C string 192.62 ns 50000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 4000 B: malloc+encode+copy+free (old) 14285.62 ns 20000 958c0082b294 false +wasmJs 3 ok python.native.ffi.WasmMarshallingOverheadTest Benchmark.printReport marshal 4000 B: Wasm.scratchUtf8 9420.6 ns 20000 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 37.47 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 41.74 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 57.06 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 296.17 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 314.19 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 693.56 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 1079.33 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 403.25 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 430.66 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 361.2 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 383.43 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 429.79 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 514.4 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 294.77 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 324.03 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 378.71 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 436.1 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 393.86 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 431.77 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 4.27 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 19.59 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 15.32 ns Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 190.6 us Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 2044.08 us Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 1853.48 us Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 14.74 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 47.89 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 302.41 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 287.67 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 254.51 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 45.63 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 97.72 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 149.5 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 124.67 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 24.66 ns 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 3.09 x 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 2.02 x 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +wasmJs 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 2.42 x 10000 Web emscripten (wasm32) / Wasm Kotlin/Wasm 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toRawValue() 68.96 ns 1000000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport NativePointer.toAddressValue() 83.42 ns 1000000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport AddressValue.toNativePointer() 95.53 ns 1000000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Py_IncRef/Py_DecRef 2101.64 ns 1000000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8 chars) 4437.56 ns 10000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8 chars) 1243.76 ns 10000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (256 chars) 40303.97 ns 10000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (256 chars) 4931.79 ns 10000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_FromString (8192 chars) 1169590.51 ns 10000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyUnicode_AsUTF8 (8192 chars) 123729.16 ns 10000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_FromLongLong 2137.91 ns 100000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyLong_AsLongLong 1066.52 ns 100000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport Manual IncRef/DecRef 2063.26 ns 100000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject wrapper creation 2626.97 ns 100000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.overhead.BenchmarkTest Benchmark.printReport PyObject_GetAttrString 4486.95 ns 10000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.resolve(name) [200 entries] 242.4 ns 1000000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invoke(handle, args) 303.76 ns 1000000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport UpcallTable.invokeByName(name, args) 430.23 ns 1000000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport arrayOf(1L) allocation + boxing 77.71 ns 1000000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport invoke with a pre-built args array 242.24 ns 1000000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport direct Kotlin call (floor) 15.26 ns 1000000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.resolve(handle) 157.77 ns 1000000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.reflection.UpcallOverheadTest Benchmark.printReport HandleTable.register + release 849.92 ns 500000 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) empty Python function (the harness floor) 37.63 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.x pure Python, no boundary 40.5 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) plain.p pure Python @property, no boundary 53.47 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 2655.5 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.ping() module function proxy 2681.62 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_release(_pm_invoke(h, (10,))) raw boundary + release 5125.55 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter(10) constructor proxy 5577.53 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 5)) raw boundary 4192.54 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.increment(5) instance method proxy 4228.37 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self,)) raw boundary 3659.1 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.value property read (descriptor) 3670.61 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (self, 'x')) raw boundary 4078.47 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) c.label = 'x' property write (descriptor) 4171.21 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, ()) raw boundary, arity 0 again 2701.15 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) _pm_invoke(h, (12,)) raw boundary 3221.11 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created static read (metaclass) 2757.78 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) Counter.created = 12 static write (metaclass) 3331.42 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally top-level read (module type) 2778.39 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Raw figures (timed inside Python, one shared loop) demo.calc.tally = 9 top-level write (module type) 3316.83 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) plain attribute read 2.86 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) @property read 15.83 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost Pure-Python floors, net of the harness (no boundary anywhere in these) the descriptor itself costs 12.97 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path _pm_invoke(h, (21,)) inside the coroutine, no await 4216.17 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await a pure-Python coroutine that never suspends 104.95 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await demo.calc.doubleNow(21) generated async def, fast path 4428.08 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path await proxy / raw boundary in the same loop 1.05 x Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy cost, await fast path what the async def wrapper adds 211.9 ns Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.render() Kotlin string building only 798.64 us Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost PythonProxySource.install() render + Python3.exec 4209.19 us Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.multiplatform.ffi.upcall.GeneratedProxyCostTest Generated proxy install cost ...of which Python3.exec 3410.54 us Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) empty Python loop 17.22 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) pure-Python callee, same shape 48.86 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall to Kotlin 3348.75 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of loop 3331.52 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Python-driven (timed inside Python) upcall net of a Python call 3299.88 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) Py_IncRef + Py_DecRef (2 downcalls, a GIL scope each) 2058.84 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) PyObject_CallObject on a Python def (downcall, same shape) 2205.57 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) UpcallTrampoline.invoke from Kotlin (no shim, no boundary) 3957.01 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...the same, with the GIL already held by the caller 2790.19 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Comparison basis, same run (timed inside Kotlin) ...Python3.withPython { } with nothing in it, for scale 998.73 ns 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / downcall of the same shape 1.51 x 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, caller holding nothing 0.84 x 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false +androidNativeArm64 3 ok python.native.ffi.UpcallBoundaryCostTest Upcall boundary cost Ratios upcall / trampoline alone, GIL held (what the shim adds) 1.2 x 10000 Android 16 (SDK 36, aarch64) / Native 958c0082b294 false diff --git a/benchmarks/rn-benchmark/.gitignore b/benchmarks/rn-benchmark/.gitignore new file mode 100644 index 000000000..1a4b3328a --- /dev/null +++ b/benchmarks/rn-benchmark/.gitignore @@ -0,0 +1,21 @@ +# Only the benchmark's source and its recorded results are tracked. Everything below is either +# installed (node_modules, Pods, gems), built (DerivedData, build/, .cxx), or a run log. +node_modules/ +**/Pods/ +DerivedData/ +build/ +build-check/ +.gradle/ +.cxx/ +.kotlin/ +.caches/ +vendor/bundle/ +xcuserdata/ +.xcode.env.local +local.properties +*.log +*.apk +*.aab +*.ipa +*.jsbundle +*.o diff --git a/benchmarks/rn-benchmark/COMPARISON.md b/benchmarks/rn-benchmark/COMPARISON.md new file mode 100644 index 000000000..b3a8d446f --- /dev/null +++ b/benchmarks/rn-benchmark/COMPARISON.md @@ -0,0 +1,908 @@ +# React Native's boundary against PythonMultiplatform's, measured the same way + +Reference project: `/Volumes/macMini/thisisthepy/PythonMultiplatform`, whose figures below are +quoted from `docs/design/upcall.md`, `docs/downcall-design.md` and `ROADMAP.md` at the commit +**`73c40da4`** (`develop`, 2026-08-14). **Nothing in the reference project was modified or re-run**; +its numbers are read, not reproduced. + +> The previous revision of this document quoted `bd0029d1`. Upstream moved, and the move mattered: +> §4a's whole device column had been re-measured at a larger warmup, and one ART row changed by 3x. +> **A quoted table goes stale silently.** `run-bench.sh` now stamps the reference commit into every +> results file so a divergence is visible at the next run rather than at the next reading. + +**One fourth source, added for the iOS comparison and cited separately everywhere it appears:** +`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 +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 +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 / 인용 불가): +2026-08-18/19에 두 회차의 단독 실행(`/tmp/rn-android-quiet.log`: WARMUP=100000, load 3.10; `/tmp/rn-android-w400k.log`: WARMUP=400000, load 1.5)을 진행했으나, 두 회차 모두 boundary sweep에서 동일하게 `NEVER SETTLED` 판정을 받았습니다: +`boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup <N> is not justified by this run.` +`NEVER SETTLED within +/-5% over 40 repetitions. This sweep does not license any warmup count; lengthen it.` +따라서 RN Android 수치는 §7c/§7d에 따라 인용할 수 없습니다(NOT QUOTABLE). + +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 +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. + +--- + +## 1. The correspondence, and where it holds + +The proposed mapping was: + +``` +RN: JS → native (TurboModule/JSI) ≈ ours: Python → Kotlin (upcall) +RN: native → JS ≈ ours: Kotlin → Python (downcall) +``` + +**The first line is right, and for a stronger reason than "both are a script calling its host". +The second line is wrong as stated, and the reason it is wrong is the most useful single fact in +this document.** + +### 1a. JS → native ≈ upcall: yes, and the internal structure matches too + +A synchronous TurboModule call and a Python → Kotlin upcall are the same *kind* of event: a script +engine, executing on its own thread, transfers control into host code, with arguments marshalled at +the seam, and gets a value back before the next bytecode runs. Neither queues, neither serialises, +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 +by reading generated code rather than documentation: + +| | React Native | PythonMultiplatform | +|---|---|---| +| **iOS** | JSI (C++) → Objective-C message send. One crossing. The generated `RNBenchSpec.h` builds `NativeBenchSpecJSI` over `ObjCTurboModule::InitParams` and calls the ObjC method directly. | No runtime boundary at all: a `PyMethodDef` whose `ml_meth` is a `staticCFunction` in the same binary (`docs/design/upcall.md` §7.4). | +| **Android** | JSI (C++) → **JNI** → Kotlin. Two crossings. Confirmed with `nm` on the built `libappmodules.so`: `NativeBenchSpecJSI::NativeBenchSpecJSI(JavaTurboModule::InitParams const&)`. | C entry point in `artMain/cinterop/jni_onload.def` → **JNI** → Kotlin. Two crossings, "one shim per shape, plus a JNI upcall per call". | + +So on both projects Android is structurally the expensive side of the same asymmetry, for the same +reason: a second managed runtime that has to be entered through JNI. + +### 1b. native → JS ≉ downcall: the direction is not symmetric in React Native + +The reference project's downcall is a plain synchronous C call: Kotlin calls +`PyObject_CallObject` and has the result before the next statement. React Native has **no +app-level synchronous equivalent in that direction.** Reading the generated bindings: + +- A method that **returns a value** is generated with + `@ReactMethod(isBlockingSynchronousMethod = true)` and runs on the JS thread. +- A method returning **`void`** is `VoidKind`, and `JavaTurboModule` / `RCTTurboModule` dispatch it + onto the module's method queue. It is *not* synchronous. +- A **`Callback`** parameter is invoked through the `RuntimeScheduler`, may be invoked only once, + and lands on the JS thread on a later tick. + +That is not a limitation of this benchmark; it is the architecture. So the native → JS rows here +are labelled **round trips**, are kept in a separate block, and are never subtracted from anything. +Comparing them against the reference project's downcall would be comparing a crossing against a +crossing-plus-scheduler-plus-microtask-drain. + +**The honest mapping is therefore:** + +| direction | RN | ours | comparable? | +|---|---|---|---| +| 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 +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 + +The direction mapping above is not enough to fill a table with. A row is only comparable to a +specific *other row*, measured by a specific test, and three of the rows here have no counterpart at +all. Getting this wrong is the failure mode that matters most: two tables side by side look like a +comparison whether or not the cells correspond. + +Reference-side sources: `UpcallBoundaryCostTest` (`commonTest`, quoted through +`docs/design/upcall.md` §"One upcall, across all five platforms") and `overhead/BenchmarkTest` +(quoted through `docs/downcall-design.md`). + +| 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 | +| `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" | +| 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 | + +#### The ratio that actually transfers, and it is not the one either table leads with + +The reference project's headline ratio is **upcall / downcall of the same shape**. This project's is +**`addInts` / a JS call of the same shape**. Those are different quantities, and reading one against +the other would be the exact mistake this section exists to prevent: React Native has no synchronous +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 +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 +rows, not this ratio): + +| reference platform | upcall | pure-Python callee, same shape | **upcall / callee** | +|---|---|---|---| +| desktop (JVM 21.0.12) | 510–560 ns | 24–27 ns | **19–23x** | +| Android ART `pmp_api36` | 944–1063 ns | 49.7–50.4 ns | **19–21x** | +| 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 +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. + +**The RN side is now measured on iOS: 82.25 – 83.72x** (`addInts` 1101.28–1119.40 ns ÷ a Hermes call +of the same shape, 13.37–13.39 ns, both from `results/ios-20260814-200903.txt`). `appRun.js` prints +it directly as `addInts / a JS call of the same shape`. The Android cell is still 미측정. + +So on the one platform where both sides exist, the ratio says RN's boundary is the dearer of the two +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 +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 +> 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. + +### 1d. And the thing neither table shows: the two projects are not doing the same job + +React Native embeds a JS engine to run **the application's own code**. PythonMultiplatform embeds +CPython to run **another language's ecosystem** inside a Kotlin app. That difference shows up in +the numbers as soon as you look at what is on each side of the boundary: + +- RN's script side is Hermes, an engine built for fast startup and small size, with **no JIT**. +- Our script side is CPython, an engine with a specialising interpreter, a GIL, and reference + counting that the boundary code has to participate in on every call. + +So "which is faster" is close to meaningless. **"What creates the cost" is the comparison that +transfers**, and section 6 is that. + +--- + +## 2. Method + +Identical on both sides, because that is the point. `RNBench/bench/harness.js` is a port of the +reference project's `Benchmark.kt`; see [`README.md`](README.md) for the list of properties it +preserves (same-shape warmup, a warmup count taken from a measured convergence point, min–max with +no outlier dropped, every baseline in the same process, no wall-clock assertion). + +Reference-side figures below are min–max over five runs of the whole suite, which is that project's +stated convention. + +--- + +## 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 +CPython-side. + +**The same sweep was run here on Hermes and on V8 from the same source file.** Both series are 40 +repetitions of the timed loop with no warmup before the first, followed by the same sweep again +after warmup, exactly as the reference project did. + +| | first repetition | second half, min–max | first / second-half min | +|---|---|---|---| +| **Hermes 0.12.0** (host binary, `Date.now`, n=8 192 000/rep) | 32.59 ns | **32.59 – 32.71 ns** | **1.00x** | +| Node 24.19.0 / V8 13.6 (host, `performance.now`, n=4 000/rep) | 18.08 ns | 9.79 – 29.88 ns | 1.85x | + +The full series (`results/host-20260814-125318.txt`): + +``` +Hermes, cold: 32.59 32.59 32.59 32.71 32.59 32.59 32.59 32.59 + 32.71 32.59 32.71 32.59 32.59 32.59 32.59 32.59 … 40 readings, all 32.59–32.71 +Hermes, warm: 32.71 32.71 32.47 32.71 32.59 32.59 32.59 32.59 … all 32.47–32.96 + +V8, cold: 18.08 14.93 2342.30 28.74 20.68 20.64 20.69 20.63 + 20.59 20.65 20.66 20.67 20.63 21.30 20.68 20.58 + 20.71 23.54 9.99 9.84 9.82 11.69 10.46 9.97 … a step down at repetition ~19 +``` + +**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 — + 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.** +2. **V8 does, and it does so unstably.** The same sweep on V8 steps down by roughly half around + repetition 19 (≈80 000 calls, consistent with the reference project's ~70 000 convergence + 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 + (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.** +4. **The JS-side floor in RN is high.** Hermes charges 32.84 ns for a two-argument function call + where V8 charges 3.87–10.68 ns. This matters more than it looks: a boundary that costs 200 ns is + a 6x tax on a Hermes call and a 19–52x tax on a V8 call, so *the same boundary looks cheaper in + an RN app than it would in a browser*, purely because the language floor it sits on is higher. + +**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. + +### 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, +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: + +``` +JS (Hermes, no JIT) --JSI--> C++ --JNI--> Kotlin, running on ART + \____________________/ \__________/ + host-sweep.js sees this and NOT this +``` + +The callee of every boundary row is `BenchModule.kt` executing on ART. **ART is precisely the +runtime whose tier-up the reference project measured, and it produced the largest warmup requirement +in its entire table** (`docs/design/upcall.md` §7.2): + +| reference target | first repetition ÷ plateau | flat from | margin at a 100 000 warmup | +|---|---|---|---| +| iOS simulator | 1.01x | ~10 000 | 10x | +| 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** | + +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 +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". + +**Two things follow, and both were wrong before this pass:** + +1. **This document previously claimed `run-bench.sh android` prints the same sweep. It did not.** + `appRun.js` never called `H.sweep`; the sweep existed only in `host-sweep.js`, which has no + native module and no ART and therefore cannot ask this question at all. It now runs on device, + **cold, before anything else**, in two versions: the JS-only row (prices Hermes) and + `addInts` through the TurboModule (prices Hermes + JSI + JNI + ART). Same 40 repetitions × + 10 000 calls as the reference project's, so the two series can be read against each other. +2. **The run now states its own verdict.** Every report ends with a `warmup verdict` block that + 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 + 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:** + +| | first repetition ÷ plateau | flat from | margin at a 100 000 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 +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 +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 +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. + +One thing the iOS run *does* say about the warmup, in the other direction: the **Hermes** JS-only +sweep did not settle within a 1.5x margin on either iOS run (1.11x and 1.43x, at loads of 3.8 and +2.4). Hermes has no JIT, so this is not tier-up, and the series does not have the shape of one: + +``` +14.12 14.13 14.25 14.13 14.10 14.07 14.15 14.10 [15.37] 14.07 14.08 14.08 ... 13.57 13.58 ... 14.07 14.10 + ^ 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 +**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 +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 +checkable rather than taken on trust. + +The practical consequence: **a quiet machine matters more for the JS baseline than for the boundary +row on iOS**, which is the opposite of what §3a predicts for Android. + +--- + +## 4. The boundary tables + +### 4a. Ours, quoted (not re-run) + +`UpcallBoundaryCostTest`, `docs/design/upcall.md`. **Re-quoted 2026-08-14 at `73c40da4`.** The +previous version of this table was taken at `bd0029d1` and is superseded: it carried four rows as +*italic* 3 000-warmup readings and two ART rows with three columns reading *not recorded*. Upstream +has since re-measured every device row at the 100 000 warmup and filled all five columns from one +test. **Nothing below is italic any more, and no row is a 3 000-warmup reading.** + +| Platform | upcall | downcall, same shape | trampoline alone (GIL held) | upcall / downcall | +|---|---|---|---|---| +| **desktop** (JVM 21.0.12, macOS arm64) | 510–560 ns | 132–144 ns | 76–91 ns ‡ | 3.71–4.14x | +| **wasmJs** (Node) | 290–304 ns ‡ | 95–109 ns | 122–129 ns | 2.67–3.21x | +| **iOS simulator** (iOS 26.2, arm64) | 2266–2301 ns | 1599–1610 ns | 1962–1990 ns | 1.41–1.43x | +| **androidNative** (`pmp_api36`, arm64) | 3228–3275 ns | 2155–2229 ns | 2699–2760 ns | 1.45–1.51x | +| **androidNative** (`pmp_api26`, arm64) | 3234–3310 ns | 2470–2554 ns | 2731–3228 ns ※ | 1.28–1.32x | +| **Android ART** (`pmp_api36`, arm64) | 944–1063 ns | 692–818 ns | 793–907 ns | 1.20–1.45x | +| **Android ART** (`pmp_api26`, arm64) | 1146–1233 ns | 1185–1293 ns | 1012–1087 ns | 0.91–1.04x | + +**The ART rows moved most, and that is the point of §3a.** ART `pmp_api36`'s upcall went +2301–3086 → **944–1063 ns** on the warmup change alone; the three Kotlin/Native targets barely moved. +The rows that fell are exactly the rows with a JIT under them. **Android ART is the row this +project's Android column is compared against**, and it is the one that was most wrong when it was +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 +the quotable one there. + +Per-crossing FFI cost, once the desktop path was fixed (`docs/downcall-design.md`): + +| | `PyList_Size` on `sys.path` | +|---|---| +| Desktop (Panama, `invokeExact`) | **2.65 ns** | +| Android API 36 emulator (`@FastNative`) | **4.28 ns** | +| Android API 36 hardware (`@FastNative`) | 7.24 ns | + +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 +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 +> **empty loop**, not the callee. The distinction is not cosmetic: the callee is the denominator of +> the only ratio that transfers between the two projects (§1c), and using 8.5 instead of 25 would +> have inflated the reference project's side of that ratio by about 3x. + +### 4b. The comparison itself, on iOS: absolute nanoseconds, side by side + +**This is the representative table.** Both columns are one script language calling a host function +that adds two integers, on the same simulator runtime, on the same machine, on the same day. The +pairing is `addInts` ↔ **upcall**, per §1a; it is not `addInts` ↔ downcall, and §1b is why the +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 | +| **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 | +| host | iPhone 17 Pro simulator, iOS 26.2, arm64 | `iosSimulatorArm64`, iOS 26.2 (SDK 260200), arm64 | +| source | `results/ios-20260814-200903.txt`, load 3.79 | `docs/cost-table.md` @ `958c0082`, load [1.97, 2.29, 5.92] | + +**On iOS, one Python → Kotlin upcall costs about twice one React Native TurboModule call: +2.01 – 2.10x**, computed from the min/max ends of the two rows above. + +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 +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% +of RN's entire `addInts` call**, spent by us before a single argument is converted. That, not the +crossing, is where the 2x lives. + +**The normalised ratio points the other way, and that is not a contradiction.** Ours is 58.8 – 62.9 +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 +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. + +### 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. +The Android column is marked 미측정 (인용 불가): 2026-08-18/19에 수행한 두 단독 실행(`/tmp/rn-android-quiet.log`, WARMUP=100000, load 3.10 / `/tmp/rn-android-w400k.log`, WARMUP=400000, load 1.5) 모두에서 boundary sweep이 수렴하지 않고 `NEVER SETTLED` 판정을 받아, §7c에 의해 전 행이 인용 불가(NOT QUOTABLE)로 처리되었습니다. + +| row | Android (Hermes, arm64) | iOS (Hermes, arm64) | +|---|---|---| +| **JS-only floor, measured in the same run** | | | +| 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 | +| `stringLength(128 chars)` | 미측정 | 1783.75 – 1844.78 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 | +| **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 | +| bare `await Promise.resolve(1)` floor | 미측정 | 805.33 – 1351.58 ns | +| **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** | +| **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) † | +| `sumArray(1000)` / `sumArray(8)` (per-element slope) | 미측정 | 31.90x | +| `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 +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 +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. + 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 + 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 + 805 – 1352 ns, and the rest is the `RuntimeScheduler` hop. §1b is why these are reported + separately and must not be subtracted from anything. +- **`noop()` is not a crossing** and is in the table as the control that proves it: at 1421.74 ns it + is *dearer* than `addInts`, because a `void` TurboModule method is dispatched asynchronously. + +The rows are chosen so that the two-length pairs give a slope and an intercept rather than one +blended number: `sumArray` at 8 and 1000 separates the per-call boundary from the per-element +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): + +| row | Hermes 0.12.0 | Node 24.19.0 / V8 13.6 | +|---|---|---| +| empty JS loop | 13.67 – 13.85 ns | 10.15 – 16.24 ns | +| JS call, same shape as `addInts` | 32.84 – 32.84 ns | 3.87 – 10.68 ns | +| JS call returning a string argument | 30.03 – 30.15 ns | 3.91 – 4.44 ns | +| JS sum of an 8-element array | 162.11 – 162.60 ns | 6.72 – 6.78 ns | +| JS sum of a 1000-element array | 14312.50 – 14374.99 ns | 611.66 – 667.60 ns | +| JS sum of a 3-key object | 39.79 – 39.92 ns | 4.33 – 4.49 ns | +| 128-char string built per call | 66.41 – 66.65 ns | 13.29 – 13.45 ns | + +Read the two columns as *engines*, not as platforms: neither is the number an RN app would print, +and the Hermes column is a four-year-old build. The ratio between them is the more durable part, +and it is **not uniform, which is itself the finding**: on min-to-min, + +| | Hermes / V8 | +|---|---| +| empty loop (no call, no allocation) | **1.35x** | +| function call | 8.5x | +| 3-key object read | 9.2x | +| 128-char string built per call | 5.0x | +| 8-element array sum | **24.1x** | +| 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 +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 +correspondingly small. + +--- + +## 5. Size and startup + +### 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 +together because both answer "what does embedding this runtime cost a shipped Android app", but the +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. | +| **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 | + +What is inside the RN release APK (raw entry bytes, arm64-v8a): + +| | raw | +|---|---| +| `libreactnative.so` | 6.90 MB | +| `libhermesvm.so` | 2.47 MB | +| `libc++_shared.so` | 1.29 MB | +| `libjsi.so` | 0.41 MB | +| other `.so` (Fresco image codecs, fbjni, …) | 0.94 MB | +| dex (React Native's Java/Kotlin, AndroidX, this app) | 11.08 MB | +| `assets/index.android.bundle` (Hermes bytecode) | **1.00 MB** | +| res + other | 0.76 MB | + +And what is inside ours, per `ROADMAP.md`'s breakdown of the pre-trim AAR (compressed, per ABI): +`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 +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. + +### 5b. Startup + +| | 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 | 미측정 | + +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 +straight out of the APK's `assets/`, so there is nothing to stage into `filesDir` and no +first-launch/later-launch asymmetry to measure. If that holds, it is the single largest +non-boundary difference between the two designs, and it is a difference in *architecture* rather +than in optimisation: our stdlib is thousands of separate files that CPython must `stat` and open +by path, and RN's is one bytecode blob. + +--- + +## 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. + +**1. The number of managed-runtime boundaries per call is the same story on both sides, and +Android loses it twice.** Both projects go JSI/C → JNI → managed on Android and JSI/C → native +directly on iOS. In the reference project this is why Android's ART upcall is "one shim per shape, +plus a JNI upcall per call". In RN it is why `libappmodules.so` contains a `JavaTurboModule`-based +shim on Android and an `ObjCTurboModule`-based one on iOS. Anything that reduces crossings pays off +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 +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 +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 +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 +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 +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 +one.** + +**6. And the JIT that matters on Android is not in the script engine.** Hermes has none, but the +Kotlin callee behind every RN Android boundary row runs on ART, which has one, and which the +reference project measured as needing the largest warmup of any target it tested. §3a. This is the +single correspondence that is easiest to get wrong in the *method* rather than in the table, because +the host-side sweep answers a question that looks like the right one and is not. + +--- + +## 7. What was blocked, what actually blocked it, and what is still blocked + +### 7a. iOS is not blocked. The previous diagnosis in this section was wrong. + +This section used to open "**iOS cannot be built on this machine right now**", on the evidence of +`xcodebuild -showdestinations` reporting zero eligible iOS destinations and an +`iOS 26.5 is not installed` error, and it concluded that a several-GB `xcodebuild -downloadPlatform +iOS` was required. **iOS builds, installs, launches and produces a full report on this machine, and +nothing was downloaded.** `results/ios-20260814-200903.txt` is the run. + +The `-showdestinations` output was real; the inference from it was not. That command exercises +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. + +**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 +`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. + +### 7b. What actually cost two runs: the app was fine and nothing was listening + +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 +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 +silently drops Info and Debug.** React Native's iOS logger emits JS `console.log` at **Info**, under +`[com.facebook.react.log:javascript]`. The predicate was therefore being applied to a stream the +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 +attached at once: + +| `log stream` invocation | lines matching `RNBENCH\|` | +|---|---| +| `--style compact --predicate 'eventMessage CONTAINS "RNBENCH\|"'` | **0** | +| `--style compact --level info --predicate 'eventMessage CONTAINS "RNBENCH\|"'` | **91** | + +A third stream at `--level debug` filtered on the process rather than the tag saw 1165 lines, which +is what confirms the app was talking the whole time. The 91-line capture contained the complete +report and the `END` marker. + +**The output path was not changed.** `console.log` → unified log → `simctl spawn ... log stream` is +kept, because the app writing to a file and `simctl get_app_container` reading it back would have +diverged the two platforms' capture paths for a problem that was one missing flag on one of them. +Adding `--level info` costs nothing, keeps both platforms on "the app logs, the harness listens", +and leaves the Android path untouched. + +### 7c. Why an empty capture was reported as success, and what now prevents it + +The harness had no idea whether it had captured anything. It wrote the conditions, appended whatever +the log stream produced, and exited on the log stream's status. **A file that says nothing is worse +than a missing file: it looks like a measurement until someone reads it**, and it was misread twice. + +`verify_report` now runs on both platforms and both `cmd_ios` and `cmd_android` return its status: + +| 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 | +| 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 +empty files above return non-zero, a file with rows and `END` returns 0, and the same file with +`END` stripped returns non-zero. + +The last row of that table is the one that fired on the runs reported here, and it is the reason the +iOS column in §4c carries a † rather than being quoted clean. **A complete-looking table with a +warning eleven lines above it is the same failure as an empty file**, and it was previously exiting +0 as well. + +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. + +### 7d. Still blocked + +**Android: 미측정 (인용 불가 판정).** 2026-08-18/19에 두 회차의 단독 실행 로그(`/tmp/rn-android-quiet.log`, `/tmp/rn-android-w400k.log`)를 검증한 결과, RN Android 측정이 **인용 불가(NOT QUOTABLE)**로 확정되었습니다. + +- **실행 조건 및 판정 원문**: + - `/tmp/rn-android-quiet.log` (WARMUP=100000, `load average: 3.10 3.45 3.74 (before the run)`): + `boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup 100000 is not justified by this run.` + `NEVER SETTLED within +/-5% over 40 repetitions. This sweep does not license any warmup count; lengthen it.` + - `/tmp/rn-android-w400k.log` (WARMUP=400000, `load average: 2.97 1.85 1.63 (before the run)`): + `boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup 400000 is not justified by this run.` + `NEVER SETTLED within +/-5% over 40 repetitions. This sweep does not license any warmup count; lengthen it.` + + - `results/android-20260819-095448.txt` (WARMUP=100000, `load average: 3.52 2.31 2.48 (before the run)`, + `RN_BENCH_REQUIRE_QUIET=1`, 다른 에이전트 없이 단독 실행): + `boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup 100000 is not justified by this run.` + 세 번째 회차이자, 기계에 다른 작업이 하나도 없음을 확인하고 돌린 첫 회차입니다. 결과는 같습니다. + +- **배제된 가설 (Ruled Out)**: + 1. **시스템 부하 문제 아님**: 호스트 load average가 3.10, 1.5, 3.52(기준치 4.0 미만)인 조용한 상태에서 + 세 회차 모두 동일하게 `NEVER SETTLED` 판정이 남. 세 번째 회차는 `RN_BENCH_REQUIRE_QUIET=1` 로 + 걸어 기계가 조용하지 않으면 아예 거부하도록 한 상태에서 통과한 것이므로, 부하는 이 판정의 + 원인이 아닙니다. + 2. **워밍업 부족 문제 아님**: WARMUP을 100,000회에서 400,000회(4배)로 올려도 boundary sweep이 수렴하지 않고 동일하게 `NEVER SETTLED` 판정이 남. + +- **다만 워밍업은 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 수치는 무효화하지 않습니다 — 그쪽은 기준선에 의존하지 않습니다). + `appRun.js` 의 `WARMUP` 은 "sweep 이 달리 말하지 않는 한 그대로 둔다"는 자기 규칙을 달고 있었고, + sweep 이 달리 말했으므로 **400,000 으로 올렸습니다.** 400,000 은 00:50 회차가 이미 그 sweep 에 + 충분한 여유를 준다고 보인 값입니다. boundary 쪽은 이것으로 고쳐지지 않습니다. + +- **네 번째 회차 (2026-08-23, WARMUP=400,000, load 2.71, 단독 실행) — 미수렴은 수렴 문제가 아니었습니다.** + 이번에는 부하 가드를 고친 뒤(아래 참조) 실제로 조용한 상태에서 돌렸고, 판정은 여전히 양쪽 다 + `NEVER SETTLED` 였습니다. **그런데 sweep 의 원자료를 보면 수렴하지 않은 곡선이 아니라 + 이미 평탄한 바닥에 스파이크가 얹힌 계열입니다:** + + | | 바닥(min) | 중앙값 | 최대 | 바닥 ±5% 안에 든 회차 | 바닥의 2배를 넘는 회차 | + |---|---|---|---|---|---| + | JS-only (Hermes) | 10.47 ns | 10.86 | **523.04** | 22/40 | 18/40 | + | 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회차부터 평탄한 것이 정상**이고, 실제로 평탄합니다 — + `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" 을 출력하고도 + 그대로 측정을 진행**했습니다. 8/20 회차(load 4.45)가 그렇게 기록됐습니다. + 2. 부하 판정이 `build_android` **뒤에** 있어서, 스크립트 자신의 Gradle 빌드가 만든 부하를 판정하고 + 있었습니다. load 1.79 에서 시작한 실행이 4.45 를 측정해 스스로를 인용 불가로 표시했습니다. + 이제 `require_quiet_before_work` 가 **아무것도 빌드하기 전에** 판정하고 실제로 종료합니다 + (임계값을 0.01 로 낮춰 거부가 정말로 빌드·실행 없이 멈추는 것을 확인했습니다). + +- **남은 가설 (Remaining Hypotheses - 미확정)**: + 1. **에뮬레이터 스케줄링 지터**: 하이퍼바이저/OS 스케줄링 불확실성으로 인해 중간중간 튀는 래그(예: w400k sweep 35회차 851.88 ns, 37회차 1625.86 ns) 발생. + 2. **수렴 기준(40회 연속 ±5%)의 에뮬레이터 환경 부적합성**: 에뮬레이터 환경 특성상 40회 연속 ±5% 이내 평탄(flat) 상태 유지 기준이 지나치게 엄격함. + +- **향후 필요한 조치 (Next Steps)**: + 0. **WARMUP=400,000 으로 재측정**: 위의 JS 기준선 결함이 고쳐졌는지 확인. boundary 판정은 그대로일 + 것으로 예상되며, 그 예상이 맞는지가 곧 위 "워밍업 부족 아님" 결론의 네 번째 확인이 됩니다. + **단독 실행이어야 합니다.** + 1. **실기기(Physical Device) 측정**: 에뮬레이터 가상화 레이어/하이퍼바이저 지터를 제거한 실기기 환경에서 측정. + 2. **수렴 기준(Settling Criteria) 재검토**: 에뮬레이터 전용 수렴 마진(예: ±10% 또는 아웃라이어 정화 기준) 적용 검토. + +**The Hermes JS-only baseline did not settle** on either iOS run, at loads of 2.4–5.8. See the † +note in §4c: this indicts the ratios, not the boundary figures, and the fix is a quieter machine +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 + 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 + ~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 + hypothesis to test is stated in 5b. **Partly automated already**: the Android path now runs + `am start -W` on a freshly installed APK and records it, so the cold-launch half arrives with + every run. The relaunch half still needs a second launch. +4. A **pure C++ TurboModule** using `jsi::Function::call` on the JS thread, to get the one + synchronous native → JS number that would make the second row of the section-1 table + comparable after all. This is the only measurement in this document that is missing for a + design reason rather than a scheduling one. + +--- + +## 9. Producing the numbers: one command, and the conditions it records + +```bash +./run-bench.sh host # no device, always safe +./run-bench.sh android # build, install, am start -W, capture; needs ONE free emulator +./run-bench.sh ios # build, install, launch, capture; needs ONE booted simulator +./run-bench.sh all # all three; a platform that cannot run says so and the next still runs +``` + +Every invocation writes `results/<platform>-<stamp>.txt` **opening with the conditions**, because a +per-call figure without them is not a measurement: + +``` +=== conditions === +platform / timestamp / host (kernel, cores, RAM) +load average (before, and again after the run) +react-native version; engine (Hermes version, reported by the app about itself) +harness digest -- sha256 over bench/*.js, the Kotlin and ObjC modules, the spec +reference repo -- path @ commit, flagged if its working tree is dirty +counts -- warmup 100000, N 10000, 5 reps, sweep 40 x 10000 +device -- serial, model, API level, ABI, build id (android only) +``` + +Three of those exist because of specific failures: the **load average** because this workspace lost +a bisect to a commit that read 672 and 1076 ns under load; the **reference commit** because §4a's +quote went stale without anyone noticing; the **harness digest** because `rn-benchmark` is not a git +repository and there is otherwise nothing to tell two runs of "the benchmark" apart. + +**Behaviour that is deliberate:** + +- **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 + 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. +- **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 + `INCOMPLETE` if the report did not finish rather than leaving a truncated file looking whole. +- **A run that captured nothing fails.** Every results file ends with a `capture check` block giving + the row count, whether `END` arrived and the run's own warmup verdict, and any of those going + wrong is a non-zero exit rather than a quiet 0. §7c is the table of states and why each exists. +- **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. + +**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: + +| step | estimate | basis | +|---|---|---| +| `host` | 2–4 min | Hermes calibrates the JS rows to millions of iterations on a 1 ms `Date.now` clock, then runs 2 x 40 sweep repetitions and 7 rows x 5 reps; the previous host run's output is the only evidence and it is not timestamped per section | +| `android` build (release, all ABIs) | 3–6 min warm, 15+ min cold | `android-build.log` from the last full build | +| `android` install + `am start -W` | < 1 min | | +| `android` benchmark itself | **5–15 min** | 5.5 M boundary calls plus 3.9 M JS-row calls at 100 000 warmup x 5 reps x 18 rows, dominated by the two 1000-element rows; the host Hermes 1000-element JS row alone is 14.3 µs/call, and 550 000 of those is ~8 min if the device matches the host binary | +| `ios` build (Release, simulator, incremental) | 1–3 min | measured today; a cold build with `pod install` already done is longer | +| `ios` benchmark itself | **~75 s** | measured today: launch to `END` in the A/B capture, all 21 rows including both 1000-element rows and the two cold 40 x 10 000 sweeps | + +**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 +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 **미측정**. + +**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 +there is no room in. diff --git a/benchmarks/rn-benchmark/README.md b/benchmarks/rn-benchmark/README.md new file mode 100644 index 000000000..748898527 --- /dev/null +++ b/benchmarks/rn-benchmark/README.md @@ -0,0 +1,174 @@ +# rn-benchmark + +A React Native app whose only purpose is to price the **JS ↔ native boundary** with the same +measurement method the [PythonMultiplatform](../..) repository uses to +price its **Python ↔ Kotlin** boundary, so that the two can be read next to each other. + +The comparison itself, including what corresponds to what and what is still unmeasured, is in +[`COMPARISON.md`](COMPARISON.md). This file is how to run it. + +--- + +## One command + +```bash +./run-bench.sh host # JS-only rows + the warmup convergence sweep. No device. Always safe. +./run-bench.sh build # Build Android and iOS. Touches no device. +./run-bench.sh android # Build, install, launch, capture the report from logcat. +./run-bench.sh ios # Build, install, launch, capture from the simulator log. +./run-bench.sh all # host, then android, then ios. +``` + +Every run writes to `results/<platform>-<timestamp>.txt` as well as to the terminal, **opening with +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 +simulators on this machine are shared. + +A platform that cannot run **writes a results file saying why** and returns non-zero; `all` carries +on to the next platform and prints a summary. Nothing is skipped silently. Above a 1-minute load +average of 4.0 every run prints a warning into its own output that its figures are not quotable; +`RN_BENCH_REQUIRE_QUIET=1` makes that a refusal instead. + +| variable | default | | +|---|---|---| +| `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 | + +Supporting scripts, usable on their own: + +| script | what it does | needs a device? | +|---|---|---| +| `./run-host-sweep.sh [hermes\|node\|both]` | JS-only rows and the 40-repetition convergence sweep | no | +| `./build-ios.sh [Debug\|Release]` | `xcodebuild` for the simulator SDK | no | +| `./check-ios-module.sh` | compiles `Bench.mm` against the generated TurboModule protocol | no | + +--- + +## What is measured + +Nine synchronous JS → native rows, one per shape, each with a JS-only row of the same shape beside +it in the same run: + +| row | what it charges | +|---|---| +| `zeroArgs()` | the boundary and nothing else | +| `addInts(3, 4)` | two numbers across, one back | +| `stringLength(8 / 128 chars)` | one string conversion | +| `echoString(8 / 128 chars)` | two string conversions | +| `sumArray(8 / 1000 elements)` | array conversion; run at two lengths so the per-element slope separates from the intercept | +| `sumObject(3 keys)` | object/map conversion | +| `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`. + +### The method, and why it is copied rather than invented + +`bench/harness.js` is a port of the reference project's +`python-multiplatform/src/commonTest/kotlin/python/multiplatform/overhead/Benchmark.kt`. Two +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. +- **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 — + 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 + 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 + why this is the sweep that matters. +- **min–max over repetitions, no outlier dropped.** A single reading is not a measurement, and a + dropped outlier hides the case where the host's spread is larger than the effect. +- **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 + boundary is free". + +--- + +## Layout + +``` +rn-benchmark/ + env.sh node, npm cache, Android SDK, Ruby/CocoaPods. Source before anything. + run-bench.sh the one command + run-host-sweep.sh Hermes + Node, JS-only rows and the convergence sweep + build-ios.sh xcodebuild for the simulator SDK + check-ios-module.sh clang compile-check of the iOS TurboModule against its generated protocol + COMPARISON.md the comparison with PythonMultiplatform + tools/ standalone Hermes VM (hermes-engine-cli) + RNBench/ the app (React Native 0.87.0, New Architecture, Hermes) + bench/harness.js the port of Benchmark.kt + bench/jsRows.js JS-only rows: the language floor + bench/host-sweep.js the host runner (Hermes and Node) + bench/appRun.js the on-device runner + App.tsx runs on mount, prints every line separately for logcat + modules/bench/ the TurboModule, as an autolinked local library + src/NativeBench.ts the codegen spec + android/…/BenchModule.kt + ios/Bench.mm +``` + +`modules/bench` is a **local library**, not app-level code, so autolinking wires it on both +platforms: CocoaPods picks up `react-native-bench.podspec`, Gradle picks up `android/`, and codegen +runs from the library's own `codegenConfig`. That avoids editing `project.pbxproj` by hand, which +is the usual reason an app-level native module is painful to reproduce. + +--- + +## This machine + +`env.sh` encodes the environment without naming any path outside this repository. Machine-specific +toolchains come in through variables; caches it creates go under `.caches/` here (git-ignored): + +- **Node.** RN 0.87 wants `^22.13 || ^24.3 || >=26`, so the Gradle-cached `node-v22.0.0` does + **not** qualify. Set `RN_BENCH_NODE_HOME` to a qualifying install (the original runs used emsdk's + bundled 24.19.0) when the `node` on PATH is not one. The npm cache defaults to `.caches/npm`. +- **Ruby.** On the machine of the original runs the rbenv Ruby 3.2.2 was broken: it had been + installed under a previous account name, so every Mach-O in it and every shebang pointed at the + old home directory and `pod` died with `bad interpreter`. The workaround was a relocated **copy** + whose install names, shebangs and OpenSSL references were repaired, with `RUBYLIB` set because the + interpreter's compiled-in load path still pointed at the old prefix. Point `RN_BENCH_RUBY_HOME` at + such a copy if you need one. `GEM_HOME` defaults to `.caches/gems`; CocoaPods 1.15.2 comes from the + project's `Gemfile`. +- **Android NDK.** AGP auto-installs NDK 27.1.12297006 (2.4 GB) into the SDK. On a machine whose + internal disk is short, move it elsewhere and leave a symlink so the SDK still finds it. + +The Android SDK needed `platforms;android-37.0` (RN 0.87 compiles against SDK 37). The bundled +`cmdline-tools` was too old to know that package; `cmdline-tools/latest-2` was installed alongside +it and used for that one command. + +--- + +## Status + +| | 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 | +| 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 + `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 + `capture check` block. A run that produced no rows can no longer look like a measurement. diff --git a/benchmarks/rn-benchmark/RNBench/.bundle/config b/benchmarks/rn-benchmark/RNBench/.bundle/config new file mode 100644 index 000000000..848943bb5 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/.bundle/config @@ -0,0 +1,2 @@ +BUNDLE_PATH: "vendor/bundle" +BUNDLE_FORCE_RUBY_PLATFORM: 1 diff --git a/benchmarks/rn-benchmark/RNBench/.eslintrc.js b/benchmarks/rn-benchmark/RNBench/.eslintrc.js new file mode 100644 index 000000000..187894b6a --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: '@react-native', +}; diff --git a/benchmarks/rn-benchmark/RNBench/.gitignore b/benchmarks/rn-benchmark/RNBench/.gitignore new file mode 100644 index 000000000..de9995595 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/.gitignore @@ -0,0 +1,75 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +**/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ +*.keystore +!debug.keystore +.kotlin/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Bundle artifact +*.jsbundle + +# Ruby / CocoaPods +**/Pods/ +/vendor/bundle/ + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* + +# testing +/coverage + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/benchmarks/rn-benchmark/RNBench/.prettierrc.js b/benchmarks/rn-benchmark/RNBench/.prettierrc.js new file mode 100644 index 000000000..06860c8d1 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + arrowParens: 'avoid', + singleQuote: true, + trailingComma: 'all', +}; diff --git a/benchmarks/rn-benchmark/RNBench/.watchmanconfig b/benchmarks/rn-benchmark/RNBench/.watchmanconfig new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/.watchmanconfig @@ -0,0 +1 @@ +{} diff --git a/benchmarks/rn-benchmark/RNBench/App.tsx b/benchmarks/rn-benchmark/RNBench/App.tsx new file mode 100644 index 000000000..e4efa82ca --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/App.tsx @@ -0,0 +1,106 @@ +/** + * The whole app is the benchmark. It runs once on mount, prints the report with `console.log` so + * `adb logcat` / `xcrun simctl spawn ... log stream` can capture it without a debugger attached, + * and shows the same text on screen. + * + * Every line of the report is printed separately: a single long write is truncated by logcat, and + * the reference project hit exactly that when its report had to survive into JUnit XML. + * + * On iOS these arrive in the unified log at **Info** level, under the subsystem/category + * `com.facebook.react.log:javascript`. `log stream` defaults to `--level default`, which drops + * Info, so a reader without `--level info` sees a perfectly empty capture of a perfectly healthy + * run. That cost two runs; see the note on the capture in run-bench.sh before changing how this + * function emits anything. + */ +import React, {useCallback, useEffect, useState} from 'react'; +import { + ActivityIndicator, + Button, + Platform, + SafeAreaView, + ScrollView, + StyleSheet, + Text, +} from 'react-native'; + +const appRun = require('./bench/appRun.js'); + +const TAG = 'RNBENCH'; + +function loadBench(): {module: any | null; error: string | null} { + try { + // Required lazily: if codegen or autolinking did not produce the module, the app must still + // start and say so, rather than dying at import time with a stack nobody can read on a device. + return {module: require('react-native-bench').Bench, error: null}; + } catch (e) { + return {module: null, error: String(e)}; + } +} + +function App(): React.JSX.Element { + const [report, setReport] = useState<string>(''); + const [running, setRunning] = useState<boolean>(false); + + const go = useCallback(async () => { + setRunning(true); + setReport(''); + const {module, error} = loadBench(); + let text: string; + try { + // Progress lines go out immediately and are prefixed `#`, so a run that stalls shows which + // row it stalled on. A benchmark that only prints at the end is indistinguishable from a + // hung one, and the 1000-element rows take tens of seconds each. + const result = await appRun.run(module, { + log: (l: string) => console.log(TAG + '| # ' + l), + // So the report can name its own callee. appRun.js must not import from 'react-native' + // itself -- it is concatenated into a plain script for the standalone Hermes run. + platform: Platform.OS, + }); + text = result.text; + } catch (e) { + text = + 'BENCHMARK THREW: ' + + String(e) + + (e instanceof Error ? '\n' + e.stack : ''); + } + if (error) { + text = 'react-native-bench did not load: ' + error + '\n' + text; + } + const outLines = text.split('\n'); + for (let i = 0; i < outLines.length; i++) { + console.log(TAG + '| ' + outLines[i]); + } + console.log(TAG + '| END'); + setReport(text); + setRunning(false); + }, []); + + useEffect(() => { + // Auto-run so a CI-style runner needs no interaction. + go(); + }, [go]); + + return ( + <SafeAreaView style={styles.root}> + <Button + title={running ? 'running...' : 'run again'} + onPress={go} + disabled={running} + /> + {running ? <ActivityIndicator style={styles.spinner} /> : null} + <ScrollView horizontal> + <ScrollView> + <Text style={styles.mono}>{report}</Text> + </ScrollView> + </ScrollView> + </SafeAreaView> + ); +} + +const styles = StyleSheet.create({ + root: {flex: 1, padding: 8}, + spinner: {margin: 8}, + mono: {fontFamily: 'Courier', fontSize: 10}, +}); + +export default App; diff --git a/benchmarks/rn-benchmark/RNBench/Gemfile b/benchmarks/rn-benchmark/RNBench/Gemfile new file mode 100644 index 000000000..51515233e --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/Gemfile @@ -0,0 +1,17 @@ +source 'https://rubygems.org' + +# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version +ruby ">= 2.6.10" + +# Exclude problematic versions of cocoapods and activesupport that causes build failures. +gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1' +gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0' +gem 'xcodeproj', '< 1.26.0' +gem 'concurrent-ruby', '< 1.3.4' + +# Ruby 3.4.0 has removed some libraries from the standard library. +gem 'bigdecimal' +gem 'logger' +gem 'benchmark' +gem 'mutex_m' +gem 'nkf' diff --git a/benchmarks/rn-benchmark/RNBench/Gemfile.lock b/benchmarks/rn-benchmark/RNBench/Gemfile.lock new file mode 100644 index 000000000..a0e4383bc --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/Gemfile.lock @@ -0,0 +1,171 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + activesupport (7.2.3.2) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + atomos (0.1.3) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + claide (1.1.0) + cocoapods (1.15.2) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.15.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.23.0, < 2.0) + cocoapods-core (1.15.2) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored2 (3.1.2) + concurrent-ruby (1.3.3) + connection_pool (3.0.2) + drb (2.2.3) + escape (0.0.4) + ethon (0.18.0) + ffi (>= 1.15.0) + logger + ffi (1.17.4) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + httpclient (2.9.0) + mutex_m + i18n (1.15.2) + concurrent-ruby (~> 1.0) + json (2.21.2) + logger (1.7.0) + minitest (5.27.0) + molinillo (0.8.0) + mutex_m (0.3.0) + nanaimo (0.3.0) + nap (1.1.0) + netrc (0.11.0) + nkf (0.3.0) + public_suffix (4.0.7) + rexml (3.4.4) + ruby-macho (2.5.1) + securerandom (0.4.1) + typhoeus (1.6.0) + ethon (>= 0.18.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + xcodeproj (1.25.1) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + rexml (>= 3.3.6, < 4.0) + +PLATFORMS + ruby + +DEPENDENCIES + activesupport (>= 6.1.7.5, != 7.1.0) + benchmark + bigdecimal + cocoapods (>= 1.13, != 1.15.1, != 1.15.0) + concurrent-ruby (< 1.3.4) + logger + mutex_m + nkf + xcodeproj (< 1.26.0) + +CHECKSUMS + CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 + activesupport (7.2.3.2) sha256=ddc90d11dd88086d78ace03407fe3c2a3f5c8853c3c3046313db5ed823ef3312 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + algoliasearch (1.27.5) sha256=26c1cddf3c2ec4bd60c148389e42702c98fdac862881dc6b07a4c0b89ffec853 + atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bundler (4.0.18) sha256=02d9a17429de1847b4e0c9f27a9ee4b20c0a74c0a641b4e77195d6019e3618ac + claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e + cocoapods (1.15.2) sha256=f0f5153de8d028d133b96f423e04f37fb97a1da0d11dda581a9f46c0cba4090a + cocoapods-core (1.15.2) sha256=322650d97fe1ad4c0831a09669764b888bd91c6d79d0f6bb07281a17667a2136 + cocoapods-deintegrate (1.0.5) sha256=517c2a448ef563afe99b6e7668704c27f5de9e02715a88ee9de6974dc1b3f6a2 + cocoapods-downloader (2.1) sha256=bb6ebe1b3966dc4055de54f7a28b773485ac724fdf575d9bee2212d235e7b6d1 + cocoapods-plugins (1.0.0) sha256=725d17ce90b52f862e73476623fd91441b4430b742d8a071000831efb440ca9a + cocoapods-search (1.0.1) sha256=1b133b0e6719ed439bd840e84a1828cca46425ab73a11eff5e096c3b2df05589 + cocoapods-trunk (1.6.0) sha256=5f5bda8c172afead48fa2d43a718cf534b1313c367ba1194cebdeb9bfee9ed31 + cocoapods-try (1.2.0) sha256=145b946c6e7747ed0301d975165157951153d27469e6b2763c83e25c84b9defe + colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a + concurrent-ruby (1.3.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + escape (0.0.4) sha256=e49f44ae2b4f47c6a3abd544ae77fe4157802794e32f19b8e773cbc4dcec4169 + ethon (0.18.0) sha256=b598afc9f30448cb068b850714b7d6948e941476095d04f90a4ac65b8d6efcb2 + ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf + fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9 + fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5 + gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 + httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 + molinillo (0.8.0) sha256=efbff2716324e2a30bccd3eba1ff3a735f4d5d53ffddbc6a2f32c0ca9433045d + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + nanaimo (0.3.0) sha256=aaaedc60497070b864a7e220f7c4b4cad3a0daddda2c30055ba8dae306342376 + nap (1.1.0) sha256=949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576 + netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f + nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 + public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + ruby-macho (2.5.1) sha256=9075e52e0f9270b552a90b24fcc6219ad149b0d15eae1bc364ecd0ac8984f5c9 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + typhoeus (1.6.0) sha256=bacc41c23e379547e29801dc235cd1699b70b955a1ba3d32b2b877aa844c331d + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + xcodeproj (1.25.1) sha256=9a2310dccf6d717076e86f602b17c640046b6f1dfe64480044596f6f2f13dc84 + +RUBY VERSION + ruby 3.2.2 + +BUNDLED WITH + 4.0.18 diff --git a/benchmarks/rn-benchmark/RNBench/README.md b/benchmarks/rn-benchmark/RNBench/README.md new file mode 100644 index 000000000..3e2c3f850 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/README.md @@ -0,0 +1,97 @@ +This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). + +# Getting Started + +> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. + +## Step 1: Start Metro + +First, you will need to run **Metro**, the JavaScript build tool for React Native. + +To start the Metro dev server, run the following command from the root of your React Native project: + +```sh +# Using npm +npm start + +# OR using Yarn +yarn start +``` + +## Step 2: Build and run your app + +With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: + +### Android + +```sh +# Using npm +npm run android + +# OR using Yarn +yarn android +``` + +### iOS + +For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). + +The first time you create a new project, run the Ruby bundler to install CocoaPods itself: + +```sh +bundle install +``` + +Then, and every time you update your native dependencies, run: + +```sh +bundle exec pod install +``` + +For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). + +```sh +# Using npm +npm run ios + +# OR using Yarn +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. + +## 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). + +When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: + +- **Android**: Press the <kbd>R</kbd> key twice or select **"Reload"** from the **Dev Menu**, accessed via <kbd>Ctrl</kbd> + <kbd>M</kbd> (Windows/Linux) or <kbd>Cmd ⌘</kbd> + <kbd>M</kbd> (macOS). +- **iOS**: Press <kbd>R</kbd> in iOS Simulator. + +## Congratulations! :tada: + +You've successfully run and modified your React Native App. :partying_face: + +### Now what? + +- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). +- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). + +# Troubleshooting + +If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. + +# Learn More + +To learn more about React Native, take a look at the following resources: + +- [React Native Website](https://reactnative.dev) - learn more about React Native. +- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment. +- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. +- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. +- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. diff --git a/benchmarks/rn-benchmark/RNBench/__tests__/App.test.tsx b/benchmarks/rn-benchmark/RNBench/__tests__/App.test.tsx new file mode 100644 index 000000000..e532f701e --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/__tests__/App.test.tsx @@ -0,0 +1,13 @@ +/** + * @format + */ + +import React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; +import App from '../App'; + +test('renders correctly', async () => { + await ReactTestRenderer.act(() => { + ReactTestRenderer.create(<App />); + }); +}); diff --git a/benchmarks/rn-benchmark/RNBench/android/app/build.gradle b/benchmarks/rn-benchmark/RNBench/android/app/build.gradle new file mode 100644 index 000000000..3bbe568c7 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/app/build.gradle @@ -0,0 +1,119 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js + // cliFile = file("../../node_modules/react-native/cli.js") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized". + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + // + // The command to run when bundling. By default is 'bundle' + // bundleCommand = "ram-bundle" + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + */ +def enableProguardInReleaseBuilds = false + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace "com.rnbench" + defaultConfig { + applicationId "com.rnbench" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/benchmarks/rn-benchmark/RNBench/android/app/debug.keystore b/benchmarks/rn-benchmark/RNBench/android/app/debug.keystore new file mode 100644 index 000000000..364e105ed Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/app/debug.keystore differ diff --git a/benchmarks/rn-benchmark/RNBench/android/app/proguard-rules.pro b/benchmarks/rn-benchmark/RNBench/android/app/proguard-rules.pro new file mode 100644 index 000000000..11b025724 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/app/proguard-rules.pro @@ -0,0 +1,10 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/AndroidManifest.xml b/benchmarks/rn-benchmark/RNBench/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..fb78f3974 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.INTERNET" /> + + <application + android:name=".MainApplication" + android:label="@string/app_name" + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher_round" + android:allowBackup="false" + android:theme="@style/AppTheme" + android:usesCleartextTraffic="${usesCleartextTraffic}" + android:supportsRtl="true"> + <activity + android:name=".MainActivity" + android:label="@string/app_name" + android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" + android:launchMode="singleTask" + android:windowSoftInputMode="adjustResize" + android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> +</manifest> diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/java/com/rnbench/MainActivity.kt b/benchmarks/rn-benchmark/RNBench/android/app/src/main/java/com/rnbench/MainActivity.kt new file mode 100644 index 000000000..17b8ef9a8 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/app/src/main/java/com/rnbench/MainActivity.kt @@ -0,0 +1,22 @@ +package com.rnbench + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +class MainActivity : ReactActivity() { + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "RNBench" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) +} diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/java/com/rnbench/MainApplication.kt b/benchmarks/rn-benchmark/RNBench/android/app/src/main/java/com/rnbench/MainApplication.kt new file mode 100644 index 000000000..d93b8383b --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/app/src/main/java/com/rnbench/MainApplication.kt @@ -0,0 +1,27 @@ +package com.rnbench + +import android.app.Application +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost + +class MainApplication : Application(), ReactApplication { + + override val reactHost: ReactHost by lazy { + getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + }, + ) + } + + override fun onCreate() { + super.onCreate() + loadReactNative(this) + } +} diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/drawable/rn_edit_text_material.xml b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 000000000..5c25e728e --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2014 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<inset xmlns:android="http://schemas.android.com/apk/res/android" + android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material" + android:insetRight="@dimen/abc_edit_text_inset_horizontal_material" + android:insetTop="@dimen/abc_edit_text_inset_top_material" + android:insetBottom="@dimen/abc_edit_text_inset_bottom_material" + > + + <selector> + <!-- + This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I). + The item below with state_pressed="false" and state_focused="false" causes a NullPointerException. + NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)' + + <item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/> + + For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR. + --> + <item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/> + <item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/> + </selector> + +</inset> diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..a2f590828 Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..1b5239980 Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..ff10afd6e Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..115a4c768 Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..dcd3cd808 Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..459ca609d Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..8ca12fe02 Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..8e19b410a Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..b824ebdd4 Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..4c19a13c2 Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/values/strings.xml b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..8e79e3363 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ +<resources> + <string name="app_name">RNBench</string> +</resources> diff --git a/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/values/styles.xml b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..7ba83a2ad --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ +<resources> + + <!-- Base application theme. --> + <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar"> + <!-- Customize your theme here. --> + <item name="android:editTextBackground">@drawable/rn_edit_text_material</item> + </style> + +</resources> diff --git a/benchmarks/rn-benchmark/RNBench/android/build.gradle b/benchmarks/rn-benchmark/RNBench/android/build.gradle new file mode 100644 index 000000000..f0b3a2685 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/build.gradle @@ -0,0 +1,21 @@ +buildscript { + ext { + buildToolsVersion = "37.0.0" + minSdkVersion = 24 + compileSdkVersion = 37 + targetSdkVersion = 36 + ndkVersion = "27.1.12297006" + kotlinVersion = "2.2.0" + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle") + classpath("com.facebook.react:react-native-gradle-plugin") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") + } +} + +apply plugin: "com.facebook.react.rootproject" diff --git a/benchmarks/rn-benchmark/RNBench/android/gradle.properties b/benchmarks/rn-benchmark/RNBench/android/gradle.properties new file mode 100644 index 000000000..50a0ed633 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/gradle.properties @@ -0,0 +1,49 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew <task> -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=true + +# Opt out of built-in kotlin and new DSL behavior that ships with AGP 9. +# Starting from AGP 10.x these opt outs will be removed. +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/benchmarks/rn-benchmark/RNBench/android/gradle/wrapper/gradle-wrapper.jar b/benchmarks/rn-benchmark/RNBench/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..d997cfc60 Binary files /dev/null and b/benchmarks/rn-benchmark/RNBench/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/benchmarks/rn-benchmark/RNBench/android/gradle/wrapper/gradle-wrapper.properties b/benchmarks/rn-benchmark/RNBench/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..c61a118f7 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/benchmarks/rn-benchmark/RNBench/android/gradlew b/benchmarks/rn-benchmark/RNBench/android/gradlew new file mode 100755 index 000000000..739907dfd --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/benchmarks/rn-benchmark/RNBench/android/gradlew.bat b/benchmarks/rn-benchmark/RNBench/android/gradlew.bat new file mode 100644 index 000000000..39baf4d68 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/gradlew.bat @@ -0,0 +1,98 @@ +@REM Copyright (c) Meta Platforms, Inc. and affiliates. +@REM +@REM This source code is licensed under the MIT license found in the +@REM LICENSE file in the root directory of this source tree. + +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/benchmarks/rn-benchmark/RNBench/android/settings.gradle b/benchmarks/rn-benchmark/RNBench/android/settings.gradle new file mode 100644 index 000000000..9ccc98eb8 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/android/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +plugins { id("com.facebook.react.settings") } +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } +rootProject.name = 'RNBench' +include ':app' +includeBuild('../node_modules/@react-native/gradle-plugin') diff --git a/benchmarks/rn-benchmark/RNBench/app.json b/benchmarks/rn-benchmark/RNBench/app.json new file mode 100644 index 000000000..2ab951fb1 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/app.json @@ -0,0 +1,4 @@ +{ + "name": "RNBench", + "displayName": "RNBench" +} diff --git a/benchmarks/rn-benchmark/RNBench/babel.config.js b/benchmarks/rn-benchmark/RNBench/babel.config.js new file mode 100644 index 000000000..f7b3da3b3 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +}; diff --git a/benchmarks/rn-benchmark/RNBench/bench/appRun.js b/benchmarks/rn-benchmark/RNBench/bench/appRun.js new file mode 100644 index 000000000..74529578b --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/bench/appRun.js @@ -0,0 +1,418 @@ +/** + * The on-device run: the JS -> native boundary, priced against baselines measured in the same run. + * + * The shape of this file is taken from the reference project's `UpcallBoundaryCostTest`, and the + * things it copies are the things that make the two comparable: + * + * - **Every baseline is measured here, in this session, on this device.** Not read from a table, + * not carried over from the host sweep. A per-call figure on its own says nothing across + * devices; what travels is a ratio against something measured beside it. + * - **The baselines bracket the boundary from both sides.** The JS-only rows say what the language + * charges for the loop and the call before anything crosses; `nativeLoopNs` says what the callee + * costs with the crossing taken out. The boundary is what is left. + * - **min-max over repetitions, nothing dropped.** + * - **Nothing here asserts a duration.** A wall-clock threshold on a device is a flake generator. + * What is checked is that every row came back as a usable positive number rather than a zero + * from a clock with no resolution -- the failure mode that would otherwise be reported as "the + * boundary is free". + * + * ### The direction that is not symmetric, and is labelled everywhere it appears + * + * `pingCallback` and `pingPromise` are native -> JS, but they are **not** the mirror image of the + * rows above them. The New Architecture gives app code no synchronous native -> JS entry point: + * a `Callback` is invoked through the `RuntimeScheduler`, so those two rows contain a scheduler + * hop and a microtask drain on top of the crossing. They are reported as round trips, in a + * separate block, and must not be subtracted from anything. + */ +const H = require('./harness.js'); +const R = require('./jsRows.js'); + +const REPS = 5; + +/** + * Matches the reference project's warmup, and the reason is **not** the one the host sweep gives. + * + * `host-sweep.js` establishes that Hermes has no JIT and therefore no tier-up, and it would be easy + * to conclude from that alone that a large warmup buys nothing here. That conclusion is wrong on + * Android, and the reason is the half of the boundary the host sweep cannot reach: + * + * JS (Hermes, no JIT) --JSI--> C++ --JNI--> **Kotlin, running on ART** + * + * The callee of every boundary row is `BenchModule.kt` executing on ART, and ART is precisely the + * runtime whose tier-up the reference project measured: its `docs/design/upcall.md` sweep found ART + * on `pmp_api36` still 9% above its plateau at 70 000 calls and only arriving at ~90 000-100 000 -- + * the largest warmup requirement of any configuration it measured, and the one that set its + * constant. So the RN Android boundary rows sit on the same curve, for the same reason, on the same + * runtime. `host-sweep.js` cannot see this because it has no native module and no ART. + * + * That is why `SWEEP` below runs on device against **the boundary row**, not only the JS row: it is + * the only place this question can actually be asked. The count stays where it is unless that sweep + * says otherwise, and the sweep is printed in every report so it can say so. + * + * **It said otherwise, so it moved.** At 100 000 the 2026-08-19T09:55 run + * (`results/android-20260819-095448.txt`, load 3.52) reported the JS-only sweep settling at + * 150 000 -- "warmup 100000 has only 0.67x margin -- TOO THIN" -- which indicts the JS baseline and + * therefore every ratio printed against it. 400 000 is the value the 2026-08-19T00:50 run had + * already shown gives that sweep an adequate margin. It does *not* fix the boundary sweep, which + * has never settled at either count (COMPARISON.md §7d rules out both load and warmup for that + * one); raising it here buys the JS half only, and the boundary half needs a physical device or a + * revised settling criterion. + */ +const WARMUP = 400000; + +/** Calls per timed loop. Same as the reference project's `N`. */ +const N = 10000; + +/** + * The convergence sweep, on device. 40 repetitions of `N`, matching the reference project's sweep + * exactly -- same repetition count, same calls per repetition -- so the two series can be read + * against each other rather than merely resembling one another. + */ +const SWEEP_REPS = 40; + +/** Async rows are three orders of magnitude slower, so they get their own, much smaller counts. */ +const ASYNC_WARMUP = 200; +const ASYNC_N = 1000; + +async function measureAsync(warmupIterations, iterations, block) { + for (let w = 0; w < warmupIterations; w++) { + await block(w); + } + const t0 = H.clock.nowNs(); + for (let i = 0; i < iterations; i++) { + await block(i); + } + const t1 = H.clock.nowNs(); + return (t1 - t0) / iterations; +} + +async function repeatAsync(reps, warmupIterations, iterations, block) { + const readings = []; + for (let r = 0; r < reps; r++) { + readings.push(await measureAsync(warmupIterations, iterations, block)); + } + return readings; +} + +/** + * The engine, named by itself rather than by the package.json it was built from. + * + * A report whose conditions say "React Native 0.87" does not say which Hermes that shipped, and the + * host sweep's Hermes is a four-year-old standalone build that is emphatically not this one. This + * asks the runtime. + */ +function engineDescription() { + if (typeof HermesInternal !== 'undefined' && HermesInternal) { + let props = null; + try { + props = HermesInternal.getRuntimeProperties ? HermesInternal.getRuntimeProperties() : null; + } catch (e) { + props = null; + } + if (!props) { + return 'Hermes (getRuntimeProperties unavailable)'; + } + const parts = []; + for (const key of ['OSS Release Version', 'Version', 'Build', 'Bytecode Version']) { + if (props[key] !== undefined) { + parts.push(key + '=' + props[key]); + } + } + return 'Hermes ' + (parts.length ? parts.join(', ') : JSON.stringify(props)); + } + if (typeof process !== 'undefined' && process.versions && process.versions.v8) { + return 'JSC/V8-family: Node ' + process.versions.node + ' / V8 ' + process.versions.v8; + } + // JSC in an RN app reports neither; say so rather than guessing. + return 'not Hermes (no HermesInternal) -- probably JSC. hermesEnabled is a build-time choice.'; +} + +/** + * @param {object} Bench the TurboModule, or null to run the JS-only rows alone + * @param {object} [opts] `{log, platform}`. `log` is called with each progress line as it happens, + * so a run that stalls shows where; the report itself is returned, not logged, so progress + * cannot contaminate it. `platform` is `Platform.OS`, and is passed in rather than imported + * so this file stays free of a `react-native` dependency -- it is concatenated into a plain + * script for the standalone Hermes run. + * @returns {Promise<{text: string, rows: Array}>} + */ +async function run(Bench, opts) { + const P = R.payloads; + const lines = []; + const say = l => lines.push(l); + const progress = (opts && opts.log) || (() => {}); + + // What is on the far side of the boundary, named truthfully per platform. This was hardcoded to + // the Android path, so the iOS report described its own calls as going "JNI -> ART" and its + // warmup verdict was labelled "Hermes+JSI+JNI+ART". Neither is true on iOS, and the difference is + // not cosmetic: the entire justification for a 100 000-call warmup is ART's tier-up, which does + // not exist on the iOS side of this benchmark. A reader comparing the two columns has to be able + // to see that the warmup argument applies to one of them and not the other. + const ios = (opts && opts.platform) === 'ios'; + const CALLEE = ios ? 'Objective-C++, AOT' : 'Kotlin on ART'; + const PATH = ios ? 'Hermes -> JSI -> Objective-C++' : 'Hermes -> JSI -> JNI -> ART'; + const SWEEP_LABEL = ios ? 'boundary (Hermes+JSI+ObjC++)' : 'boundary (Hermes+JSI+JNI+ART)'; + + say(''); + say('=== RN boundary benchmark: on-device ==='); + say('engine: ' + engineDescription()); + say('clock: ' + H.clock.name); + say('warmup: ' + WARMUP + ' calls per row, iterations per timed loop: ' + N); + say('reps: ' + REPS + ' per row, min-max reported, nothing discarded'); + say('module: ' + (Bench ? 'present' : 'ABSENT -- JS-only rows only')); + say(''); + + const rows = []; + + // ---- 0. The convergence sweeps, COLD, before anything else has run -------------------------- + // + // These must come first and in this order, and the ordering is the measurement: a sweep is only + // a cold sweep if nothing has driven the path before it. The JS sweep touches only `jsAdd`, so + // the boundary is still cold when the second sweep starts. + // + // The boundary sweep is the one that matters. See `WARMUP` above: Hermes has no JIT, but the + // callee behind every boundary row is Kotlin on ART, which does, and which the reference project + // measured needing ~90 000-100 000 calls on `pmp_api36`. This is the only place that can be + // checked for React Native. + progress('sweep: JS-only row, cold'); + const jsSweepRow = R.rows[1]; // "JS call, same shape as addInts" + const jsSweep = H.formatSweep( + '"' + jsSweepRow.name + '" (no boundary: Hermes only)', + H.sweep(SWEEP_REPS, N, jsSweepRow.block), + N, + WARMUP, + ); + say(jsSweep.text); + say(''); + + let boundarySweep = null; + if (Bench) { + progress('sweep: boundary row addInts, cold'); + boundarySweep = H.formatSweep( + 'addInts(3, 4) through the TurboModule (' + PATH + ')', + H.sweep(SWEEP_REPS, N, () => Bench.addInts(3, 4)), + N, + WARMUP, + ); + say(boundarySweep.text); + say(''); + say(' Read the two sweeps together. The JS one prices Hermes, which has no JIT and is expected'); + say(' to be flat from the first repetition. The boundary one prices Hermes plus a callee'); + say(' running as ' + CALLEE + '.'); + if (ios) { + say(' On iOS that callee is AOT-compiled and does NOT tier up, so neither side of this'); + say(' boundary has a warm-up curve to climb: a sweep that fails to settle here is evidence'); + say(' of a busy machine, not of a warmup that is too short. Check the load average.'); + } else { + say(' ART does tier up -- so if either sweep justifies the ' + WARMUP + ' warmup, it is this'); + say(' one, and if this one shows a thin margin the warmup must be raised.'); + } + say(''); + } + + // ---- 1. The JS-side floor, measured here rather than borrowed ------------------------------- + for (const row of R.rows) { + progress('js row: ' + row.name); + rows.push({ + group: 'js', + name: row.name, + readings: H.repeat(REPS, WARMUP, N, row.block), + }); + } + + if (Bench) { + // ---- 2. The boundary, one row per shape --------------------------------------------------- + const nativeRows = [ + ['zeroArgs() -- boundary, no arguments', () => Bench.zeroArgs()], + ['addInts(3, 4) -- two numbers', () => Bench.addInts(3, 4)], + ['stringLength(8 chars)', () => Bench.stringLength(P.SHORT_STRING)], + ['stringLength(128 chars)', () => Bench.stringLength(P.LONG_STRING)], + ['echoString(8 chars) -- 2 conversions', () => Bench.echoString(P.SHORT_STRING)], + ['echoString(128 chars) -- 2 conversions', () => Bench.echoString(P.LONG_STRING)], + ['sumArray(8 elements)', () => Bench.sumArray(P.ARRAY_8)], + ['sumArray(1000 elements)', () => Bench.sumArray(P.ARRAY_1000)], + ['sumObject(3 keys)', () => Bench.sumObject(P.OBJECT_3)], + // Last, and apart: `void` is dispatched asynchronously, so this row is a queue push and is + // not comparable with the eight above it. See NativeBench.ts on `zeroArgs` vs `noop`. + // The `return 1` is not decoration: every other block returns a value into the harness' sink, + // and a block returning `undefined` sets that sink to `undefined`, which makes the + // "sink (proves no row was eliminated)" line print `undefined` and prove nothing. + ['noop() -- void, ASYNC DISPATCH, not a crossing', () => { Bench.noop(); return 1; }], + ]; + for (const [name, block] of nativeRows) { + progress('boundary row: ' + name); + rows.push({group: 'boundary', name, readings: H.repeat(REPS, WARMUP, N, block)}); + } + + // ---- 3. The callee with the crossing removed ---------------------------------------------- + // Timed by the *native* clock inside a native loop. The gap between this and `addInts` above + // is what one crossing adds, with the callee held constant. + const nativeOnly = []; + for (let r = 0; r < REPS; r++) { + nativeOnly.push(Bench.nativeLoopNs(1000000)); + } + rows.push({group: 'control', name: "addInts' body, native loop, no crossing", readings: nativeOnly}); + } + + say(H.formatRows(rows)); + say(''); + say('sink (proves no row was eliminated): ' + H.readSink()); + + // ---- 4. The asymmetric direction, kept apart ------------------------------------------------- + if (Bench) { + progress('async round-trip rows'); + const asyncRows = []; + asyncRows.push({ + name: 'native -> JS callback round trip', + readings: await repeatAsync(REPS, ASYNC_WARMUP, ASYNC_N, () => + new Promise(resolve => Bench.pingCallback(1, resolve)), + ), + }); + asyncRows.push({ + name: 'native -> JS promise round trip', + readings: await repeatAsync(REPS, ASYNC_WARMUP, ASYNC_N, () => Bench.pingPromise(1)), + }); + asyncRows.push({ + name: 'bare JS Promise.resolve await (floor)', + readings: await repeatAsync(REPS, ASYNC_WARMUP, ASYNC_N, () => Promise.resolve(1)), + }); + say(''); + say('--- native -> JS, ROUND TRIPS (scheduler hop included; not comparable with the rows above) ---'); + say(H.formatRows(asyncRows)); + for (const r of asyncRows) { + rows.push({group: 'roundtrip', name: r.name, readings: r.readings}); + } + } + + // ---- 5. Ratios, all from this run ------------------------------------------------------------ + const byName = {}; + for (const r of rows) { + byName[r.name] = H.min(r.readings); + } + /** + * A ratio, or a named complaint. + * + * Looking a row up by a string that no row has produces `undefined / undefined` = `NaN`, and + * `NaN` printed in a ratio column reads as a quirk of the host rather than as a bug in this + * file. It was one: these lookups used `'echoString(128 chars)'` while the row is named + * `'echoString(128 chars) -- 2 conversions'`, so that ratio printed `NaNx` in every report. + */ + const ratio = (label, numeratorName, denominatorName) => { + const a = byName[numeratorName]; + const b = byName[denominatorName]; + if (a === undefined || b === undefined) { + const missing = [ + a === undefined ? numeratorName : null, + b === undefined ? denominatorName : null, + ].filter(Boolean); + say(' ' + label + ' UNAVAILABLE -- no row named: ' + missing.join(' | ')); + return; + } + say(' ' + label + ' ' + H.fmt(a / b) + 'x'); + }; + + const jsCall = byName['JS call, same shape as addInts']; + const addInts = byName['addInts(3, 4) -- two numbers']; + if (Bench && jsCall && addInts) { + say(''); + say('--- ratios, every term from this run ---'); + ratio( + 'addInts / a JS call of the same shape: ', + 'addInts(3, 4) -- two numbers', + 'JS call, same shape as addInts', + ); + ratio( + 'addInts / zeroArgs (what two numbers cost):', + 'addInts(3, 4) -- two numbers', + 'zeroArgs() -- boundary, no arguments', + ); + ratio( + 'sumArray(1000) / sumArray(8): ', + 'sumArray(1000 elements)', + 'sumArray(8 elements)', + ); + ratio( + 'echoString(128) / echoString(8): ', + 'echoString(128 chars) -- 2 conversions', + 'echoString(8 chars) -- 2 conversions', + ); + } + + // ---- 6. The run's verdict on its own warmup --------------------------------------------------- + // + // Stated by the run rather than left to the reader, because the failure this guards against is + // precisely a reader quoting a table whose warmup was too small. The reference project published + // two boundary costs that way and neither reproduced. + say(''); + say('--- warmup verdict, from this run\'s own sweeps ---'); + const verdicts = [ + ['js', 'JS-only (Hermes)', jsSweep && jsSweep.analysis], + ['boundary', SWEEP_LABEL, boundarySweep && boundarySweep.analysis], + ]; + let inadequate = false; + // Which one, not merely whether. The two sweeps indict different rows, and saying only that "at + // least one" was thin sent a reader to the wrong half: a run on this machine had the boundary + // sweep flat from repetition 1 (margin infinite -- there is no warm-up curve when the callee is + // AOT) while the JS-only baseline drifted 14.15 -> 13.57 ns under CPU frequency scaling and came + // out at 1.43x, and the summary below still told them the *boundary* rows were unsettled. They + // were not; the baseline the ratios divide by was. + const badSweeps = []; + for (const [key, label, a] of verdicts) { + if (!a) { + say(' ' + label + ': not swept (no native module)'); + continue; + } + if (!a.settled) { + inadequate = true; + badSweeps.push(key); + say(' ' + label + ': NEVER SETTLED. Warmup ' + WARMUP + ' is not justified by this run.'); + } else if (a.marginAtWarmup < 1.5) { + inadequate = true; + badSweeps.push(key); + say( + ' ' + label + ': settles at ' + a.flatFromCalls + ' calls, so warmup ' + WARMUP + + ' has only ' + H.fmt(a.marginAtWarmup) + 'x margin -- TOO THIN. Raise it.', + ); + } else { + say( + ' ' + label + ': settles at ' + a.flatFromCalls + ' calls; warmup ' + WARMUP + ' has ' + + H.fmt(a.marginAtWarmup) + 'x margin. Adequate.', + ); + } + } + if (inadequate) { + const badBoundary = badSweeps.indexOf('boundary') !== -1; + const badJs = badSweeps.indexOf('js') !== -1; + say(''); + say(' ==> AT LEAST ONE SWEEP SAYS THIS WARMUP IS INSUFFICIENT.'); + if (badBoundary) { + say(' THE BOUNDARY ROWS ARE NOT FIGURES. They were read before the boundary settled;'); + say(' that is every row that crosses into native, which is the point of this benchmark.'); + } + if (badJs) { + say(' THE JS-ONLY BASELINE IS NOT SETTLED. That indicts the JS rows and every ratio'); + say(' printed above, since those divide by one -- but NOT the absolute boundary'); + say(' figures, which do not depend on it.'); + } + if (badJs && !badBoundary) { + say(' Note what this verdict does NOT say: the boundary sweep above settled. Read its'); + say(' "flat from" line before discarding the boundary rows -- an unsettled Hermes'); + say(' baseline on a machine with frequency scaling is not a statement about them.'); + } + say(' Raise WARMUP in bench/appRun.js and re-run, or re-run on a quieter machine.'); + } + + // ---- 7. The only assertion: a number came back, not a zero ----------------------------------- + const zeros = rows.filter(r => H.min(r.readings) <= 0).map(r => r.name); + say(''); + if (zeros.length) { + say('UNUSABLE ROWS (measured <= 0, the clock has no resolution here): ' + zeros.join(', ')); + } else { + say('all ' + rows.length + ' rows returned a positive duration'); + } + say(''); + + return {text: lines.join('\n'), rows, warmupAdequate: !inadequate}; +} + +module.exports = {run, REPS, WARMUP, N, SWEEP_REPS}; diff --git a/benchmarks/rn-benchmark/RNBench/bench/harness.js b/benchmarks/rn-benchmark/RNBench/bench/harness.js new file mode 100644 index 000000000..7b903f115 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/bench/harness.js @@ -0,0 +1,316 @@ +/** + * The measurement harness. Deliberately a copy of the reference project's + * `python-multiplatform/src/commonTest/kotlin/python/multiplatform/overhead/Benchmark.kt`, because + * two boundary costs measured by two different loop shapes are not comparable and the whole point + * of this repository is that they should be. + * + * What is copied, and why each part is not optional: + * + * - **Warmup of the same shape and the same block as the timed loop.** Not a fixed sleep, not a + * different block. In the reference project an unwarmed first row once made a subset of the work + * look cheaper than the whole of it. + * - **A warmup count large enough to reach the plateau, established by sweep rather than picked.** + * The reference project settled on 100 000 after a 40-repetition sweep showed the JVM and V8 + * converging on a steady value from both sides. `sweep()` below is that experiment, so the same + * question can be asked of Hermes instead of assumed. + * - **min-max over repetitions with no outlier dropped.** A single reading is not a measurement, + * and a dropped outlier hides the case where the host's spread is larger than the effect. + * - **Baselines measured in the same run.** A ratio against a number from another process, another + * build or another machine is not a ratio. Every row a comparison is made against is produced by + * the same `measure()` in the same session. + * + * Written as UMD-ish plain JS on purpose: the same file is loaded by Metro (CommonJS) inside the + * app and by the standalone Hermes and Node binaries (no module system) for the host-side sweeps. + */ +(function (root, factory) { + var api = factory(); + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } else { + root.BenchHarness = api; + } +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + /** + * The clock, and its name, because the name changes what a figure is worth. + * + * `performance.now()` is sub-millisecond and is what the app has. Standalone Hermes has no + * `performance` at all, so the host sweeps fall back to `Date.now()` at 1 ms -- which is why + * `suggestIterations` below asks for far more iterations there. A row timed on a 1 ms clock over + * 10 000 iterations of a 5 ns operation would read 0, and "the boundary is free" is exactly the + * failure this guards against. + */ + function makeClock() { + if (typeof performance !== 'undefined' && performance && typeof performance.now === 'function') { + return { + name: 'performance.now', + resolutionNs: 1e3, // conservative; RN reports at least microsecond granularity + nowNs: function () { + return performance.now() * 1e6; + }, + }; + } + return { + name: 'Date.now', + resolutionNs: 1e6, + nowNs: function () { + return Date.now() * 1e6; + }, + }; + } + + var clock = makeClock(); + + /** + * A sink the loops write into, read back by `readSink()`. + * + * Without it an empty-bodied row is dead code, and a host that eliminates it reports the boundary + * as free. Kept on the module object rather than in a local so nothing can prove it unused. + */ + var sink = 0; + + function readSink() { + return sink; + } + + /** + * The reference project's `Benchmark.measure`, one for one: warm with the same block, then time + * the same block, then divide. Returns ns per operation. + */ + function measure(warmupIterations, iterations, block) { + for (var w = 0; w < warmupIterations; w++) { + sink = block(w); + } + + var t0 = clock.nowNs(); + for (var i = 0; i < iterations; i++) { + sink = block(i); + } + var t1 = clock.nowNs(); + + return (t1 - t0) / iterations; + } + + /** + * `measure` run `reps` times over, returning every reading. + * + * The caller reports min-max over these. Nothing here averages and nothing here discards: the + * spread is part of the result, and on an emulator it can be larger than what is being measured. + */ + function repeat(reps, warmupIterations, iterations, block) { + var readings = []; + for (var r = 0; r < reps; r++) { + readings.push(measure(warmupIterations, iterations, block)); + } + return readings; + } + + /** + * The convergence experiment, not a benchmark. + * + * Runs the timed loop `reps` times back to back **with no warmup at all before the first**, and + * returns every reading in order. If the host tiers up -- JIT compilation, inline caches, shape + * feedback -- the early readings are high and the series settles; the index at which it settles + * is the warmup count the real rows need. If the host does not tier up, the series is flat from + * the first reading, and a large warmup buys nothing. + * + * The reference project ran exactly this, 40 repetitions, and read the JVM converging from 1307 + * to ~535 ns and V8 similarly. That is where its 100 000 came from. Hermes is a different kind of + * engine and the answer should not be carried over from those two without asking. + */ + function sweep(reps, iterations, block) { + var readings = []; + for (var r = 0; r < reps; r++) { + var t0 = clock.nowNs(); + for (var i = 0; i < iterations; i++) { + sink = block(i); + } + var t1 = clock.nowNs(); + readings.push((t1 - t0) / iterations); + } + return readings; + } + + /** + * Where a sweep settles, in the same terms the reference project's device sweep table uses + * (`docs/design/upcall.md` §7.2): first repetition over the plateau, + * the call count from which the series stays flat, and the margin a given warmup has over it. + * + * The plateau is the mean of the second half, so it cannot be dragged by the early readings the + * analysis is trying to identify. "Flat from" is the **last** repetition that is outside the band, + * not the first one inside it: a series that dips into the band and back out has not settled, and + * scanning forwards would call it settled at the dip. The reference project's ART `pmp_api36` + * sweep is exactly that shape -- 1218, 1173, 1129, **1532**, 1057 -- so this is not hypothetical. + * + * `band` is printed with the result rather than hidden, because the answer depends on it: at 5% + * that ART series settles at ~90 000 calls and at 10% it settles at ~50 000, and quoting either + * without the threshold is quoting a number nobody can reproduce. + */ + function analyzeSweep(readings, iterationsPerRep, warmupUnderTest, band) { + band = band || 0.05; + var half = Math.floor(readings.length / 2); + var sum = 0; + for (var i = half; i < readings.length; i++) { + sum += readings[i]; + } + var plateau = sum / (readings.length - half); + + var lastOutside = -1; + for (var j = 0; j < readings.length; j++) { + if (plateau > 0 && Math.abs(readings[j] - plateau) / plateau > band) { + lastOutside = j; + } + } + // Repetition `lastOutside` is the final bad one, so the series is flat from the one after it, + // by which point `(lastOutside + 1) * iterationsPerRep` calls have been made. + var flatFromCalls = (lastOutside + 1) * iterationsPerRep; + + return { + band: band, + plateau: plateau, + first: readings[0], + firstOverPlateau: plateau > 0 ? readings[0] / plateau : 0, + flatFromCalls: flatFromCalls, + settled: lastOutside < readings.length - 1, + marginAtWarmup: flatFromCalls > 0 ? warmupUnderTest / flatFromCalls : Infinity, + warmupUnderTest: warmupUnderTest, + }; + } + + /** `analyzeSweep`'s result plus the series itself, formatted eight readings to a line. */ + function formatSweep(title, readings, iterationsPerRep, warmupUnderTest, band) { + var a = analyzeSweep(readings, iterationsPerRep, warmupUnderTest, band); + var out = []; + out.push('--- convergence sweep: ' + title + ' ---'); + out.push( + ' ' + readings.length + ' repetitions of ' + iterationsPerRep + + ' calls, no warmup before the first', + ); + var line = []; + for (var i = 0; i < readings.length; i++) { + line.push(padLeft(fmt(readings[i]), 9)); + if (line.length === 8) { + out.push(' ' + line.join(' ')); + line = []; + } + } + if (line.length) { + out.push(' ' + line.join(' ')); + } + out.push(''); + out.push(' plateau (mean of 2nd half): ' + fmt(a.plateau) + ' ns/op'); + out.push(' first repetition: ' + fmt(a.first) + ' ns/op (' + fmt(a.firstOverPlateau) + 'x the plateau)'); + if (a.settled) { + out.push( + ' flat (within +/-' + (a.band * 100).toFixed(0) + '%) from: ' + a.flatFromCalls + ' calls', + ); + out.push( + ' margin at a warmup of ' + a.warmupUnderTest + ': ' + fmt(a.marginAtWarmup) + 'x' + + (a.marginAtWarmup < 1.5 ? ' <-- THIN. A slower or busier host would read this row unsettled.' : ''), + ); + } else { + out.push( + ' NEVER SETTLED within +/-' + (a.band * 100).toFixed(0) + '% over ' + readings.length + + ' repetitions. This sweep does not license any warmup count; lengthen it.', + ); + } + return {text: out.join('\n'), analysis: a}; + } + + function min(xs) { + var m = xs[0]; + for (var i = 1; i < xs.length; i++) { + if (xs[i] < m) { + m = xs[i]; + } + } + return m; + } + + function max(xs) { + var m = xs[0]; + for (var i = 1; i < xs.length; i++) { + if (xs[i] > m) { + m = xs[i]; + } + } + return m; + } + + function fmt(v) { + if (!isFinite(v)) { + return String(v); + } + return v.toFixed(2); + } + + function pad(s, n) { + s = String(s); + while (s.length < n) { + s += ' '; + } + return s; + } + + function padLeft(s, n) { + s = String(s); + while (s.length < n) { + s = ' ' + s; + } + return s; + } + + /** `name` -> min-max, in the order given. Nothing is sorted; the order is the order run. */ + function formatRows(rows) { + var width = 34; + for (var i = 0; i < rows.length; i++) { + if (rows[i].name.length > width) { + width = rows[i].name.length; + } + } + var out = []; + out.push(pad('row', width) + ' | ' + padLeft('min ns/op', 12) + ' | ' + padLeft('max ns/op', 12) + ' | reps'); + out.push(new Array(width + 36).join('-')); + for (var j = 0; j < rows.length; j++) { + var r = rows[j]; + out.push( + pad(r.name, width) + + ' | ' + + padLeft(fmt(min(r.readings)), 12) + + ' | ' + + padLeft(fmt(max(r.readings)), 12) + + ' | ' + + r.readings.length, + ); + } + return out.join('\n'); + } + + /** + * The iteration count a row needs so that the *total* timed interval is at least 200 clock ticks. + * + * This is the guard against reporting a number the clock cannot represent. With `Date.now()` at + * 1 ms and an operation around 10 ns, it asks for 20 000 000 iterations; with `performance.now()` + * it asks for far fewer. The caller may pass a floor. + */ + function suggestIterations(expectedNsPerOp, floor) { + var needed = Math.ceil((clock.resolutionNs * 200) / Math.max(expectedNsPerOp, 0.5)); + return Math.max(needed, floor || 0); + } + + return { + clock: clock, + measure: measure, + repeat: repeat, + sweep: sweep, + analyzeSweep: analyzeSweep, + formatSweep: formatSweep, + formatRows: formatRows, + suggestIterations: suggestIterations, + readSink: readSink, + min: min, + max: max, + fmt: fmt, + }; +}); diff --git a/benchmarks/rn-benchmark/RNBench/bench/host-sweep.js b/benchmarks/rn-benchmark/RNBench/bench/host-sweep.js new file mode 100644 index 000000000..9b5be8d12 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/bench/host-sweep.js @@ -0,0 +1,187 @@ +/** + * The host-side run: JS-only rows, plus the convergence sweep that decides what warmup the + * boundary rows need. + * + * It runs under the standalone Hermes VM and under Node/V8 **from the same file**, on purpose. The + * reference project's 100 000-call warmup exists because the JVM's C2 and V8's tiering both take + * tens of thousands of calls to reach a plateau, and its docs say so with a 40-repetition sweep as + * evidence. Whether Hermes behaves the same way is not something to assume: Hermes ships without a + * JIT in React Native, so the prediction is that its series is flat from the first repetition. This + * file runs the identical sweep on both engines so the contrast is visible rather than argued. + * + * This measures **no boundary**. It cannot: there is no native module on the host. What it settles + * is the warmup count and the JS-side floor, both of which the on-device run then inherits. + */ +(function (root) { + 'use strict'; + + var isNode = typeof module !== 'undefined' && module.exports; + var H = isNode ? require('./harness.js') : root.BenchHarness; + var R = isNode ? require('./jsRows.js') : root.BenchJsRows; + + function say(line) { + if (typeof print === 'function') { + print(line); + } else { + console.log(line); + } + } + + function engineName() { + if (typeof HermesInternal !== 'undefined' && HermesInternal) { + var props = null; + try { + props = HermesInternal.getRuntimeProperties ? HermesInternal.getRuntimeProperties() : null; + } catch (e) { + props = null; + } + var ver = props && (props['OSS Release Version'] || props.Version); + return 'Hermes' + (ver ? ' ' + ver : ''); + } + if (typeof process !== 'undefined' && process.versions && process.versions.v8) { + return 'Node ' + process.versions.node + ' / V8 ' + process.versions.v8; + } + return 'unknown engine'; + } + + /** + * How many iterations this row needs before the clock can see it. + * + * Doubles until the timed interval is at least 50 clock ticks, then returns that count with a + * 4x margin. The alternative -- a fixed count -- reports 0.00 ns/op for a cheap row on a 1 ms + * clock, and a zero here would be read as "free" rather than as "unmeasured", which is the + * specific mistake the reference project's asserts exist to catch. + */ + function calibrate(block) { + var n = 1000; + for (var attempt = 0; attempt < 40; attempt++) { + var t0 = H.clock.nowNs(); + var s = 0; + for (var i = 0; i < n; i++) { + s = block(i); + } + var elapsed = H.clock.nowNs() - t0; + if (s === undefined && s !== undefined) { + say('unreachable'); + } + if (elapsed >= H.clock.resolutionNs * 50) { + return n * 4; + } + n *= 2; + } + return n; + } + + var REPS = 5; + var SWEEP_REPS = 40; + + /** + * The floor on warmup calls per row, matching the reference project's 100 000. + * + * Calibration sizes a row for the *clock*, and on a microsecond clock that comes out at a few + * thousand iterations -- far below where V8 finishes tiering up, as the sweep above this file's + * output demonstrates. Warming to the calibrated count alone would therefore read V8 several + * tiers early, which is precisely the defect the reference project found in its own table and + * had to re-cut it for. Overridable so the claim can be tested rather than trusted: + * + * RN_BENCH_WARMUP=300000 ./run-host-sweep.sh node + */ + var WARMUP_FLOOR = 100000; + if (typeof process !== 'undefined' && process.env && process.env.RN_BENCH_WARMUP) { + WARMUP_FLOOR = parseInt(process.env.RN_BENCH_WARMUP, 10) || WARMUP_FLOOR; + } + /** Upper bound, so Hermes' multi-million calibrated counts do not turn warmup into the run. */ + var WARMUP_CAP = 1000000; + + function warmupFor(n) { + return Math.max(WARMUP_FLOOR, Math.min(n, WARMUP_CAP)); + } + + say(''); + say('=== RN boundary benchmark: host-side JS rows ==='); + say('engine: ' + engineName()); + say('clock: ' + H.clock.name + ' (assumed resolution ' + H.clock.resolutionNs + ' ns)'); + say('reps: ' + REPS + ' per row, min-max reported, nothing discarded'); + say(''); + say('This run contains no native boundary. It fixes the JS-side floor and the warmup count.'); + say(''); + + // ---- 1. The convergence sweep, cold: no warmup at all before the first repetition ---------- + var sweepRow = R.rows[1]; // "JS call, same shape as addInts" + var sweepN = calibrate(sweepRow.block); + say('--- convergence sweep: "' + sweepRow.name + '" ---'); + say('iterations per repetition: ' + sweepN + ', repetitions: ' + SWEEP_REPS + ', warmup before the first: none'); + var series = H.sweep(SWEEP_REPS, sweepN, sweepRow.block); + var line = []; + for (var i = 0; i < series.length; i++) { + line.push(H.fmt(series[i])); + if (line.length === 8) { + say(' ' + line.join(' ')); + line = []; + } + } + if (line.length) { + say(' ' + line.join(' ')); + } + var first = series[0]; + var tail = series.slice(Math.floor(series.length / 2)); + var tailMin = H.min(tail); + var tailMax = H.max(tail); + say(''); + say(' first repetition: ' + H.fmt(first) + ' ns/op'); + say(' second half, min-max: ' + H.fmt(tailMin) + ' - ' + H.fmt(tailMax) + ' ns/op'); + say(' first / second-half min: ' + H.fmt(first / tailMin) + 'x'); + // The same settle analysis the on-device run prints, so the host answer and the device answer + // are stated in one vocabulary instead of two. The device one is the one that decides the + // warmup -- this engine is not the engine the app ships, and this sweep has no boundary in it. + var coldA = H.analyzeSweep(series, sweepN, WARMUP_FLOOR); + if (coldA.settled) { + say(' flat (within +/-5%) from: ' + coldA.flatFromCalls + ' calls; a warmup of ' + + WARMUP_FLOOR + ' has ' + H.fmt(coldA.marginAtWarmup) + 'x margin'); + } else { + say(' NEVER SETTLED within +/-5% over ' + SWEEP_REPS + ' repetitions on this engine.'); + } + say(''); + say(' A ratio near 1.00x means this engine has no tier-up to warm and a large warmup buys'); + say(' nothing. A ratio well above 1 means it does, and the repetition at which the series'); + say(' settles multiplied by the iteration count is the warmup the boundary rows need.'); + say(''); + + // ---- 2. The same sweep, warm: the reference project's control ------------------------------- + // Its 40-repetition sweep was run cold *and* warm, and the finding was that the two converge on + // the same value from opposite sides. Running only one of them cannot distinguish "the engine + // warmed up" from "the machine was busy at the start". + say('--- the same sweep, but after ' + sweepN * 10 + ' calls of warmup ---'); + H.measure(sweepN * 10, 1, sweepRow.block); + var warmSeries = H.sweep(SWEEP_REPS, sweepN, sweepRow.block); + line = []; + for (var k = 0; k < warmSeries.length; k++) { + line.push(H.fmt(warmSeries[k])); + if (line.length === 8) { + say(' ' + line.join(' ')); + line = []; + } + } + if (line.length) { + say(' ' + line.join(' ')); + } + say(''); + say(' warm first repetition: ' + H.fmt(warmSeries[0]) + ' ns/op'); + say(' warm, min-max overall: ' + H.fmt(H.min(warmSeries)) + ' - ' + H.fmt(H.max(warmSeries)) + ' ns/op'); + say(''); + + // ---- 3. The JS-only rows --------------------------------------------------------------------- + say('--- JS-only rows (no boundary anywhere in them) ---'); + var out = []; + for (var r = 0; r < R.rows.length; r++) { + var row = R.rows[r]; + var n = calibrate(row.block); + var w = warmupFor(n); + var readings = H.repeat(REPS, w, n, row.block); + out.push({name: row.name + ' [n=' + n + ', warmup=' + w + ']', readings: readings}); + } + say(H.formatRows(out)); + say(''); + say('sink (proves no row was eliminated): ' + H.readSink()); + say(''); +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/benchmarks/rn-benchmark/RNBench/bench/jsRows.js b/benchmarks/rn-benchmark/RNBench/bench/jsRows.js new file mode 100644 index 000000000..511f56b93 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/bench/jsRows.js @@ -0,0 +1,128 @@ +/** + * The rows that need no native module: the JS-side floor every boundary figure is read against. + * + * These correspond one for one to the reference project's `_pm_time_loop` (the empty loop) and + * `_pm_py_add` (a callee of the same shape with no boundary in it). Their job is to say what the + * *language* charges for the loop and the call, so that "the crossing costs X" means X above that + * floor rather than X including it. + * + * They also run standalone, under the host Hermes and Node binaries, which is how the warmup + * question gets an answer without a device. + */ +(function (root, factory) { + var harness = + typeof module !== 'undefined' && module.exports ? require('./harness.js') : root.BenchHarness; + var api = factory(harness); + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } else { + root.BenchJsRows = api; + } +})(typeof globalThis !== 'undefined' ? globalThis : this, function (H) { + 'use strict'; + + // Declared at module scope so the shapes stay stable across every call: a callee redefined per + // iteration would be measuring the host's function-allocation path instead of its call path. + function jsAdd(a, b) { + return a + b; + } + + function jsEchoString(s) { + return s; + } + + function jsSumArray(xs) { + var total = 0; + for (var i = 0; i < xs.length; i++) { + total += xs[i]; + } + return total; + } + + function jsSumObject(o) { + return o.a + o.b + o.c; + } + + // The payloads, built once. Building them inside a timed loop would price allocation, which is a + // real cost but a different one, and it is charged separately by the `...built per call` rows. + var SHORT_STRING = 'abcdefgh'; + var LONG_STRING = new Array(129).join('x'); // 128 chars + var ARRAY_8 = [1, 2, 3, 4, 5, 6, 7, 8]; + var ARRAY_1000 = (function () { + var a = []; + for (var i = 0; i < 1000; i++) { + a.push(i); + } + return a; + })(); + var OBJECT_3 = {a: 1, b: 2, c: 3}; + + /** + * `{name, block}` in the order they should be run. + * + * `block` takes the loop index and returns a value; the harness writes that value into a sink it + * can read back, so no row can be eliminated as dead. + */ + var rows = [ + { + name: 'empty JS loop (the loop itself)', + block: function (i) { + return i; + }, + }, + { + name: 'JS call, same shape as addInts', + block: function () { + return jsAdd(3, 4); + }, + }, + { + name: 'JS call returning a string arg', + block: function () { + return jsEchoString(SHORT_STRING); + }, + }, + { + name: 'JS sum of an 8-element array', + block: function () { + return jsSumArray(ARRAY_8); + }, + }, + { + name: 'JS sum of a 1000-element array', + block: function () { + return jsSumArray(ARRAY_1000); + }, + }, + { + name: 'JS sum of a 3-key object', + block: function () { + return jsSumObject(OBJECT_3); + }, + }, + { + name: 'JS string of 128 chars, built per call', + block: function (i) { + return ('' + i + LONG_STRING).length; + }, + }, + ]; + + return { + rows: rows, + payloads: { + SHORT_STRING: SHORT_STRING, + LONG_STRING: LONG_STRING, + ARRAY_8: ARRAY_8, + ARRAY_1000: ARRAY_1000, + OBJECT_3: OBJECT_3, + }, + fns: { + jsAdd: jsAdd, + jsEchoString: jsEchoString, + jsSumArray: jsSumArray, + jsSumObject: jsSumObject, + }, + harness: H, + }; +}); diff --git a/benchmarks/rn-benchmark/RNBench/index.js b/benchmarks/rn-benchmark/RNBench/index.js new file mode 100644 index 000000000..9b7393291 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/index.js @@ -0,0 +1,9 @@ +/** + * @format + */ + +import { AppRegistry } from 'react-native'; +import App from './App'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); diff --git a/benchmarks/rn-benchmark/RNBench/ios/.xcode.env b/benchmarks/rn-benchmark/RNBench/ios/.xcode.env new file mode 100644 index 000000000..3d5782c71 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/.xcode.env @@ -0,0 +1,11 @@ +# This `.xcode.env` file is versioned and is used to source the environment +# used when running script phases inside Xcode. +# To customize your local environment, you can create an `.xcode.env.local` +# file that is not versioned. + +# NODE_BINARY variable contains the PATH to the node executable. +# +# Customize the NODE_BINARY variable here. +# For example, to use nvm with brew, add the following line +# . "$(brew --prefix nvm)/nvm.sh" --no-use +export NODE_BINARY=$(command -v node) diff --git a/benchmarks/rn-benchmark/RNBench/ios/Podfile b/benchmarks/rn-benchmark/RNBench/ios/Podfile new file mode 100644 index 000000000..0607d47ff --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/Podfile @@ -0,0 +1,34 @@ +# Resolve react_native_pods.rb with node to allow for hoisting +require Pod::Executable.execute_command('node', ['-p', + 'require.resolve( + "react-native/scripts/react_native_pods.rb", + {paths: [process.argv[1]]}, + )', __dir__]).strip + +platform :ios, min_ios_version_supported +prepare_react_native_project! + +linkage = ENV['USE_FRAMEWORKS'] +if linkage != nil + Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green + use_frameworks! :linkage => linkage.to_sym +end + +target 'RNBench' do + config = use_native_modules! + + use_react_native!( + :path => config[:reactNativePath], + # An absolute path to your application root. + :app_path => "#{Pod::Config.instance.installation_root}/.." + ) + + post_install do |installer| + react_native_post_install( + installer, + config[:reactNativePath], + :mac_catalyst_enabled => false, + # :ccache_enabled => true + ) + end +end diff --git a/benchmarks/rn-benchmark/RNBench/ios/Podfile.lock b/benchmarks/rn-benchmark/RNBench/ios/Podfile.lock new file mode 100644 index 000000000..c17570935 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/Podfile.lock @@ -0,0 +1,2061 @@ +PODS: + - boost (1.84.0): + - ReactNativeDependencies + - DoubleConversion (1.1.6): + - ReactNativeDependencies + - fast_float (8.0.0): + - ReactNativeDependencies + - FBLazyVector (0.87.0): + - React-Core-prebuilt + - fmt (12.1.0): + - ReactNativeDependencies + - glog (0.3.5): + - ReactNativeDependencies + - hermes-engine (250829098.0.16): + - hermes-engine/Pre-built (= 250829098.0.16) + - hermes-engine/Pre-built (250829098.0.16) + - RCT-Folly (2024.11.18.00): + - RCT-Folly/Default (= 2024.11.18.00) + - ReactNativeDependencies + - RCT-Folly/Default (2024.11.18.00): + - ReactNativeDependencies + - RCTDeprecation (0.87.0): + - React-Core-prebuilt + - RCTRequired (0.87.0): + - React-Core-prebuilt + - RCTSwiftUI (0.87.0) + - RCTSwiftUIWrapper (0.87.0): + - RCTSwiftUI + - RCTTypeSafety (0.87.0): + - FBLazyVector (= 0.87.0) + - RCTRequired (= 0.87.0) + - React-Core (= 0.87.0) + - React (0.87.0): + - React-Core (= 0.87.0) + - React-Core/DevSupport (= 0.87.0) + - React-Core/RCTWebSocket (= 0.87.0) + - React-RCTActionSheet (= 0.87.0) + - React-RCTAnimation (= 0.87.0) + - React-RCTBlob (= 0.87.0) + - React-RCTImage (= 0.87.0) + - React-RCTLinking (= 0.87.0) + - React-RCTNetwork (= 0.87.0) + - React-RCTSettings (= 0.87.0) + - React-RCTText (= 0.87.0) + - React-RCTVibration (= 0.87.0) + - React-bridging (0.87.0): + - React-callinvoker + - React-Core-prebuilt + - React-jsi + - React-timing + - ReactNativeDependencies + - React-callinvoker (0.87.0) + - React-Core (0.87.0): + - React-Core-prebuilt + - React-Core/Default (= 0.87.0) + - React-Core-prebuilt (0.87.0): + - ReactNativeDependencies + - React-Core/CoreModulesHeaders (0.87.0): + - React-Core-prebuilt + - React-Core/Default (0.87.0): + - React-Core-prebuilt + - React-Core/DevSupport (0.87.0): + - React-Core-prebuilt + - React-Core/RCTActionSheetHeaders (0.87.0): + - React-Core-prebuilt + - React-Core/RCTAnimationHeaders (0.87.0): + - React-Core-prebuilt + - React-Core/RCTBlobHeaders (0.87.0): + - React-Core-prebuilt + - React-Core/RCTImageHeaders (0.87.0): + - React-Core-prebuilt + - React-Core/RCTLinkingHeaders (0.87.0): + - React-Core-prebuilt + - React-Core/RCTNetworkHeaders (0.87.0): + - React-Core-prebuilt + - React-Core/RCTSettingsHeaders (0.87.0): + - React-Core-prebuilt + - React-Core/RCTTextHeaders (0.87.0): + - React-Core-prebuilt + - React-Core/RCTVibrationHeaders (0.87.0): + - React-Core-prebuilt + - React-Core/RCTWebSocket (0.87.0): + - React-Core-prebuilt + - React-CoreModules (0.87.0): + - RCTTypeSafety (= 0.87.0) + - React-Core-prebuilt + - React-Core/CoreModulesHeaders (= 0.87.0) + - React-debug + - React-featureflags + - React-jsi (= 0.87.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-NativeModulesApple + - React-RCTBlob + - React-RCTFBReactNativeSpec + - React-RCTImage (= 0.87.0) + - React-runtimeexecutor + - React-utils + - ReactCommon + - ReactNativeDependencies + - React-cxxreact (0.87.0): + - hermes-engine + - React-callinvoker (= 0.87.0) + - React-Core-prebuilt + - React-debug (= 0.87.0) + - React-jserrorhandler (= 0.87.0) + - React-jsi (= 0.87.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-logger (= 0.87.0) + - React-perflogger (= 0.87.0) + - React-runtimeexecutor + - React-timing (= 0.87.0) + - React-utils + - ReactNativeDependencies + - React-cxxstableapi (0.87.0) + - React-debug (0.87.0): + - React-debug/redbox (= 0.87.0) + - React-debug/redbox (0.87.0) + - React-defaultsnativemodule (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-domnativemodule + - React-Fabric/animated + - React-featureflags + - React-featureflagsnativemodule + - React-idlecallbacksnativemodule + - React-intersectionobservernativemodule + - React-jsi + - React-jsiexecutor + - React-microtasksnativemodule + - React-mutationobservernativemodule + - React-RCTFBReactNativeSpec + - React-viewtransitionnativemodule + - React-webperformancenativemodule + - ReactNativeDependencies + - Yoga + - React-domnativemodule (0.87.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-Fabric + - React-Fabric/bridging + - React-FabricComponents + - React-graphics + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/animated (= 0.87.0) + - React-Fabric/animationbackend (= 0.87.0) + - React-Fabric/animations (= 0.87.0) + - React-Fabric/attributedstring (= 0.87.0) + - React-Fabric/bridging (= 0.87.0) + - React-Fabric/componentregistry (= 0.87.0) + - React-Fabric/componentregistrynative (= 0.87.0) + - React-Fabric/components (= 0.87.0) + - React-Fabric/consistency (= 0.87.0) + - React-Fabric/core (= 0.87.0) + - React-Fabric/dom (= 0.87.0) + - React-Fabric/imagemanager (= 0.87.0) + - React-Fabric/leakchecker (= 0.87.0) + - React-Fabric/mounting (= 0.87.0) + - React-Fabric/observers (= 0.87.0) + - React-Fabric/scheduler (= 0.87.0) + - React-Fabric/telemetry (= 0.87.0) + - React-Fabric/uimanager (= 0.87.0) + - React-Fabric/viewtransition (= 0.87.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animated (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/animationbackend + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animationbackend (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animations (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/attributedstring (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/bridging (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistry (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistrynative (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/components/legacyviewmanagerinterop (= 0.87.0) + - React-Fabric/components/root (= 0.87.0) + - React-Fabric/components/scrollview (= 0.87.0) + - React-Fabric/components/view (= 0.87.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/legacyviewmanagerinterop (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/root (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/scrollview (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/view (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-renderercss + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric/consistency (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/core (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/dom (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/imagemanager (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/leakchecker (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/mounting (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-jsinspectortracing + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/observers/events (= 0.87.0) + - React-Fabric/observers/intersection (= 0.87.0) + - React-Fabric/observers/mutation (= 0.87.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/events (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/intersection (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/mutation (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/scheduler (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/animationbackend + - React-Fabric/observers/events + - React-Fabric/viewtransition + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-performancecdpmetrics + - React-performancetimeline + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/telemetry (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/uimanager/consistency (= 0.87.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager/consistency (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/viewtransition (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-FabricComponents (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components (= 0.87.0) + - React-FabricComponents/textlayoutmanager (= 0.87.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components/inputaccessory (= 0.87.0) + - React-FabricComponents/components/iostextinput (= 0.87.0) + - React-FabricComponents/components/modal (= 0.87.0) + - React-FabricComponents/components/rncore (= 0.87.0) + - React-FabricComponents/components/safeareaview (= 0.87.0) + - React-FabricComponents/components/scrollview (= 0.87.0) + - React-FabricComponents/components/switch (= 0.87.0) + - React-FabricComponents/components/text (= 0.87.0) + - React-FabricComponents/components/textinput (= 0.87.0) + - React-FabricComponents/components/unimplementedview (= 0.87.0) + - React-FabricComponents/components/virtualview (= 0.87.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/inputaccessory (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/iostextinput (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/modal (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/rncore (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/safeareaview (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/scrollview (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/switch (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/text (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/textinput (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/unimplementedview (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/virtualview (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/textlayoutmanager (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricImage (0.87.0): + - hermes-engine + - RCTRequired (= 0.87.0) + - RCTTypeSafety (= 0.87.0) + - React-Core-prebuilt + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsiexecutor (= 0.87.0) + - React-logger + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-featureflags (0.87.0): + - React-Core-prebuilt + - ReactNativeDependencies + - React-featureflagsnativemodule (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-graphics (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-rendererdebug + - React-utils + - ReactNativeDependencies + - React-hermes (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.87.0) + - React-jsi + - React-jsiexecutor (= 0.87.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-oscompat + - React-perflogger (= 0.87.0) + - React-runtimeexecutor + - ReactNativeDependencies + - React-idlecallbacksnativemodule (0.87.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - React-runtimescheduler + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-ImageManager (0.87.0): + - React-Core-prebuilt + - React-Core/Default + - React-debug + - React-Fabric + - React-graphics + - React-rendererdebug + - React-utils + - ReactNativeDependencies + - React-intersectionobservernativemodule (0.87.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-cxxreact + - React-Fabric + - React-Fabric/bridging + - React-graphics + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - React-runtimescheduler + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-jserrorhandler (0.87.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-debug + - React-featureflags + - React-jsi + - ReactNativeDependencies + - React-jsi (0.87.0): + - hermes-engine + - React-Core-prebuilt + - ReactNativeDependencies + - React-jsiexecutor (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-jserrorhandler + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsinspector (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-jsinspectortracing + - React-oscompat + - React-perflogger (= 0.87.0) + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsinspectorcdp (0.87.0): + - React-Core-prebuilt + - ReactNativeDependencies + - React-jsinspectornetwork (0.87.0): + - React-Core-prebuilt + - React-jsinspectorcdp + - ReactNativeDependencies + - React-jsinspectortracing (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsinspectornetwork + - React-oscompat + - React-timing + - React-utils + - ReactNativeDependencies + - React-jsitooling (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.87.0) + - React-debug + - React-jsi (= 0.87.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsitracing (0.87.0): + - React-jsi + - React-logger (0.87.0): + - React-Core-prebuilt + - ReactNativeDependencies + - React-Mapbuffer (0.87.0): + - React-Core-prebuilt + - React-debug + - ReactNativeDependencies + - React-microtasksnativemodule (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-mutationobservernativemodule (0.87.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-cxxreact + - React-Fabric + - React-Fabric/bridging + - React-Fabric/observers/mutation + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-bench (0.1.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context (5.9.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common (= 5.9.0) + - react-native-safe-area-context/fabric (= 5.9.0) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/common (5.9.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/fabric (5.9.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-NativeModulesApple (0.87.0): + - hermes-engine + - React-bridging + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-networking (0.87.0): + - React-Core-prebuilt + - React-jsinspectornetwork + - React-jsinspectortracing + - React-performancetimeline + - React-timing + - ReactNativeDependencies + - React-oscompat (0.87.0) + - React-perflogger (0.87.0): + - React-Core-prebuilt + - ReactNativeDependencies + - React-performancecdpmetrics (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-performancetimeline + - React-runtimeexecutor + - React-timing + - ReactNativeDependencies + - React-performancetimeline (0.87.0): + - React-Core-prebuilt + - React-featureflags + - React-jsinspector + - React-jsinspectortracing + - React-perflogger + - React-timing + - ReactNativeDependencies + - React-RCTActionSheet (0.87.0): + - React-Core/RCTActionSheetHeaders (= 0.87.0) + - React-RCTAnimatedModuleProvider (0.87.0): + - hermes-engine + - React-Core + - React-Core-prebuilt + - React-Fabric/animated + - React-featureflags + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-RCTAnimation (0.87.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTAnimationHeaders + - React-debug + - React-featureflags + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTAppDelegate (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-CoreModules + - React-debug + - React-defaultsnativemodule + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsitooling + - React-NativeModulesApple + - React-RCTAnimatedModuleProvider + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RCTImage + - React-RCTNetwork + - React-RCTRuntime + - React-rendererdebug + - React-RuntimeApple + - React-RuntimeCore + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon + - ReactNativeDependencies + - React-RCTBlob (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-Core/RCTBlobHeaders + - React-Core/RCTWebSocket + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTFabric (0.87.0): + - React-Core-prebuilt + - React-RCTFBReactNativeSpec (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec/components (= 0.87.0) + - ReactCommon + - ReactNativeDependencies + - React-RCTFBReactNativeSpec/components (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-NativeModulesApple + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-RCTImage (0.87.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTImageHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTLinking (0.87.0): + - React-Core/RCTLinkingHeaders (= 0.87.0) + - React-jsi (= 0.87.0) + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactCommon/turbomodule/core (= 0.87.0) + - React-RCTNetwork (0.87.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTNetworkHeaders + - React-debug + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-NativeModulesApple + - React-networking + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTRuntime (0.87.0): + - React-Core-prebuilt + - React-RCTSettings (0.87.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTSettingsHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTText (0.87.0): + - React-Core/RCTTextHeaders (= 0.87.0) + - Yoga + - React-RCTVibration (0.87.0): + - React-Core-prebuilt + - React-Core/RCTVibrationHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-rendererconsistency (0.87.0) + - React-renderercss (0.87.0): + - React-debug + - React-utils + - React-rendererdebug (0.87.0): + - React-Core-prebuilt + - React-debug + - ReactNativeDependencies + - React-RuntimeApple (0.87.0): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-Core/Default + - React-CoreModules + - React-cxxreact + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-Mapbuffer + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RuntimeCore + - React-runtimeexecutor + - React-RuntimeHermes + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-RuntimeCore (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-Fabric + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-performancetimeline + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-runtimeexecutor (0.87.0): + - React-Core-prebuilt + - React-debug + - React-featureflags + - React-jsi (= 0.87.0) + - React-utils + - ReactNativeDependencies + - React-RuntimeHermes (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-hermes + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-jsitracing + - React-RuntimeCore + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-runtimescheduler (0.87.0): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsinspectortracing + - React-performancetimeline + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-timing + - React-utils + - ReactNativeDependencies + - React-timing (0.87.0): + - React-debug + - React-utils (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-debug + - React-jsi (= 0.87.0) + - ReactNativeDependencies + - React-viewtransitionnativemodule (0.87.0): + - hermes-engine + - React-Core-prebuilt + - React-Fabric + - React-Fabric/bridging + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-webperformancenativemodule (0.87.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-cxxreact + - React-jsi + - React-jsiexecutor + - React-performancetimeline + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactAppDependencyProvider (0.87.0): + - ReactCodegen + - ReactCodegen (0.87.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-FabricImage + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-NativeModulesApple + - React-RCTAppDelegate + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactCommon (0.87.0): + - React-Core-prebuilt + - ReactCommon/turbomodule (= 0.87.0) + - ReactNativeDependencies + - ReactCommon/turbomodule (0.87.0): + - hermes-engine + - React-callinvoker (= 0.87.0) + - React-Core-prebuilt + - React-jsi (= 0.87.0) + - React-logger (= 0.87.0) + - React-perflogger (= 0.87.0) + - ReactCommon/turbomodule/core (= 0.87.0) + - ReactNativeDependencies + - ReactCommon/turbomodule/core (0.87.0): + - hermes-engine + - React-bridging + - React-callinvoker (= 0.87.0) + - React-Core-prebuilt + - React-cxxreact (= 0.87.0) + - React-debug (= 0.87.0) + - React-featureflags (= 0.87.0) + - React-jsi (= 0.87.0) + - React-logger (= 0.87.0) + - React-perflogger (= 0.87.0) + - React-utils (= 0.87.0) + - ReactNativeDependencies + - ReactNativeDependencies (0.87.0) + - SocketRocket (0.7.1): + - ReactNativeDependencies + - Yoga (0.0.0): + - React-Core-prebuilt + +DEPENDENCIES: + - boost (from `build/rndeps-facades/boost`) + - DoubleConversion (from `build/rndeps-facades/DoubleConversion`) + - fast_float (from `build/rndeps-facades/fast_float`) + - FBLazyVector (from `build/rncore-facades/FBLazyVector`) + - fmt (from `build/rndeps-facades/fmt`) + - glog (from `build/rndeps-facades/glog`) + - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - RCT-Folly (from `build/rndeps-facades/RCT-Folly`) + - RCTDeprecation (from `build/rncore-facades/RCTDeprecation`) + - RCTRequired (from `build/rncore-facades/RCTRequired`) + - RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`) + - RCTSwiftUIWrapper (from `../node_modules/react-native/ReactApple/RCTSwiftUIWrapper`) + - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../node_modules/react-native/`) + - React-bridging (from `../node_modules/react-native/ReactCommon/react/bridging`) + - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `build/rncore-facades/React-Core`) + - React-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`) + - React-Core/RCTWebSocket (from `build/rncore-facades/React-Core`) + - React-CoreModules (from `../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`) + - React-cxxstableapi (from `../node_modules/react-native/ReactCommon/react/cxxstableapi`) + - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-intersectionobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver`) + - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) + - React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`) + - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) + - React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`) + - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - React-mutationobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`) + - react-native-bench (from `../node_modules/react-native-bench`) + - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-networking (from `../node_modules/react-native/ReactCommon/react/networking`) + - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) + - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancecdpmetrics (from `../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`) + - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimatedModuleProvider (from `../node_modules/react-native/ReactApple/RCTAnimatedModuleProvider`) + - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `build/rncore-facades/React-RCTFabric`) + - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`) + - React-RCTImage (from `../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`) + - React-RCTRuntime (from `build/rncore-facades/React-RCTRuntime`) + - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`) + - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) + - React-viewtransitionnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/viewtransition`) + - React-webperformancenativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`) + - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`) + - ReactCodegen (from `build/generated/ios/ReactCodegen`) + - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) + - SocketRocket (from `build/rndeps-facades/SocketRocket`) + - Yoga (from `build/rncore-facades/Yoga`) + +EXTERNAL SOURCES: + boost: + :path: build/rndeps-facades/boost + DoubleConversion: + :path: build/rndeps-facades/DoubleConversion + fast_float: + :path: build/rndeps-facades/fast_float + FBLazyVector: + :path: build/rncore-facades/FBLazyVector + fmt: + :path: build/rndeps-facades/fmt + glog: + :path: build/rndeps-facades/glog + hermes-engine: + :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :tag: hermes-v250829098.0.16 + RCT-Folly: + :path: build/rndeps-facades/RCT-Folly + RCTDeprecation: + :path: build/rncore-facades/RCTDeprecation + RCTRequired: + :path: build/rncore-facades/RCTRequired + RCTSwiftUI: + :path: "../node_modules/react-native/ReactApple/RCTSwiftUI" + RCTSwiftUIWrapper: + :path: "../node_modules/react-native/ReactApple/RCTSwiftUIWrapper" + RCTTypeSafety: + :path: "../node_modules/react-native/Libraries/TypeSafety" + React: + :path: "../node_modules/react-native/" + React-bridging: + :path: "../node_modules/react-native/ReactCommon/react/bridging" + React-callinvoker: + :path: "../node_modules/react-native/ReactCommon/callinvoker" + React-Core: + :path: build/rncore-facades/React-Core + React-Core-prebuilt: + :podspec: "../node_modules/react-native/React-Core-prebuilt.podspec" + React-CoreModules: + :path: "../node_modules/react-native/React/CoreModules" + React-cxxreact: + :path: "../node_modules/react-native/ReactCommon/cxxreact" + React-cxxstableapi: + :path: "../node_modules/react-native/ReactCommon/react/cxxstableapi" + React-debug: + :path: "../node_modules/react-native/ReactCommon/react/debug" + React-defaultsnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults" + React-domnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom" + React-Fabric: + :path: "../node_modules/react-native/ReactCommon" + React-FabricComponents: + :path: "../node_modules/react-native/ReactCommon" + React-FabricImage: + :path: "../node_modules/react-native/ReactCommon" + React-featureflags: + :path: "../node_modules/react-native/ReactCommon/react/featureflags" + React-featureflagsnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + React-graphics: + :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics" + React-hermes: + :path: "../node_modules/react-native/ReactCommon/hermes" + React-idlecallbacksnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + React-ImageManager: + :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + React-intersectionobservernativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver" + React-jserrorhandler: + :path: "../node_modules/react-native/ReactCommon/jserrorhandler" + React-jsi: + :path: "../node_modules/react-native/ReactCommon/jsi" + React-jsiexecutor: + :path: "../node_modules/react-native/ReactCommon/jsiexecutor" + React-jsinspector: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern" + React-jsinspectorcdp: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" + React-jsinspectornetwork: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network" + React-jsinspectortracing: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + React-jsitooling: + :path: "../node_modules/react-native/ReactCommon/jsitooling" + React-jsitracing: + :path: "../node_modules/react-native/ReactCommon/hermes/executor/" + React-logger: + :path: "../node_modules/react-native/ReactCommon/logger" + React-Mapbuffer: + :path: "../node_modules/react-native/ReactCommon" + React-microtasksnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + React-mutationobservernativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver" + react-native-bench: + :path: "../node_modules/react-native-bench" + react-native-safe-area-context: + :path: "../node_modules/react-native-safe-area-context" + React-NativeModulesApple: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + React-networking: + :path: "../node_modules/react-native/ReactCommon/react/networking" + React-oscompat: + :path: "../node_modules/react-native/ReactCommon/oscompat" + React-perflogger: + :path: "../node_modules/react-native/ReactCommon/reactperflogger" + React-performancecdpmetrics: + :path: "../node_modules/react-native/ReactCommon/react/performance/cdpmetrics" + React-performancetimeline: + :path: "../node_modules/react-native/ReactCommon/react/performance/timeline" + React-RCTActionSheet: + :path: "../node_modules/react-native/Libraries/ActionSheetIOS" + React-RCTAnimatedModuleProvider: + :path: "../node_modules/react-native/ReactApple/RCTAnimatedModuleProvider" + React-RCTAnimation: + :path: "../node_modules/react-native/Libraries/NativeAnimation" + React-RCTAppDelegate: + :path: "../node_modules/react-native/Libraries/AppDelegate" + React-RCTBlob: + :path: "../node_modules/react-native/Libraries/Blob" + React-RCTFabric: + :path: build/rncore-facades/React-RCTFabric + React-RCTFBReactNativeSpec: + :path: "../node_modules/react-native/React" + React-RCTImage: + :path: "../node_modules/react-native/Libraries/Image" + React-RCTLinking: + :path: "../node_modules/react-native/Libraries/LinkingIOS" + React-RCTNetwork: + :path: "../node_modules/react-native/Libraries/Network" + React-RCTRuntime: + :path: build/rncore-facades/React-RCTRuntime + React-RCTSettings: + :path: "../node_modules/react-native/Libraries/Settings" + React-RCTText: + :path: "../node_modules/react-native/Libraries/Text" + React-RCTVibration: + :path: "../node_modules/react-native/Libraries/Vibration" + React-rendererconsistency: + :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency" + React-renderercss: + :path: "../node_modules/react-native/ReactCommon/react/renderer/css" + React-rendererdebug: + :path: "../node_modules/react-native/ReactCommon/react/renderer/debug" + React-RuntimeApple: + :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios" + React-RuntimeCore: + :path: "../node_modules/react-native/ReactCommon/react/runtime" + React-runtimeexecutor: + :path: "../node_modules/react-native/ReactCommon/runtimeexecutor" + React-RuntimeHermes: + :path: "../node_modules/react-native/ReactCommon/react/runtime" + React-runtimescheduler: + :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + React-timing: + :path: "../node_modules/react-native/ReactCommon/react/timing" + React-utils: + :path: "../node_modules/react-native/ReactCommon/react/utils" + React-viewtransitionnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/viewtransition" + React-webperformancenativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/webperformance" + ReactAppDependencyProvider: + :path: build/generated/ios/ReactAppDependencyProvider + ReactCodegen: + :path: build/generated/ios/ReactCodegen + ReactCommon: + :path: "../node_modules/react-native/ReactCommon" + ReactNativeDependencies: + :podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" + SocketRocket: + :path: build/rndeps-facades/SocketRocket + Yoga: + :path: build/rncore-facades/Yoga + +SPEC CHECKSUMS: + boost: 2a9d6e1d9873337543f08d9dbc007b288f4e113b + DoubleConversion: e85e4faac763226bfd1cc4c6a98d8355149b27a5 + fast_float: 908e5ee35999f3d91af4b962ac951acac591866c + FBLazyVector: ef7f5a8a8d3c7b3fb0b7c6f2bb810938c5fda9ea + fmt: c7776a2a4b1d7ce88b809a1e4d1f7add0f2369df + glog: b4427c3119645da795ff09985ba9da909c940c96 + hermes-engine: 6a26e94b4a974cbadc751eed9caa12624c38f226 + RCT-Folly: d173e541eeb78cad5c81011c1bbf62d5b86b9778 + RCTDeprecation: e12a993f8b27b25e9ed3617181ca87c20e6ab7ac + RCTRequired: 3ba259676adfd0c6371e474db758470fdf97f06d + RCTSwiftUI: ac8537fe21f096259c91bd663655ff52ec864cb0 + RCTSwiftUIWrapper: 0cebbc6ccb093236749715885d7655b761c03375 + RCTTypeSafety: 80eac4783ed394cdffb5981d2c99b73a1bc519ca + React: 41924a5bad9466fe869b4511650b7d6e0cf02c84 + React-bridging: dd8fc4c80bdd98b136d910c2be3ba1dffd92c64d + React-callinvoker: 2703817f9ad45a19f839421aa77a3504b2a76f0d + React-Core: 7a5e9cd7d14b4149c817b6e522206477b7fa63a2 + React-Core-prebuilt: d7a5fd7d35b1b39a271286ef44a294bc4228093f + React-CoreModules: 846a3cc0bfe0e5c930da571dd7c2cb1cb0033012 + React-cxxreact: ca6ef2aa2d29f0905be8a0109f649c42fd12768a + React-cxxstableapi: 06f491a41595d7e71e9a1783781913e0b2291d57 + React-debug: f156620f41c3947a9a80a0f0cd98321270613be6 + React-defaultsnativemodule: 5598669ad6378f69bd03a6934b670bc1cf6e1a6c + React-domnativemodule: 6b397db980b9b5eff642d595a96a1fc288aece3f + React-Fabric: eeefa4e3a110884f143e806f5f9fc224229f68fe + React-FabricComponents: 577eea7fa4946dd4c601d748fef8f645e2ec3205 + React-FabricImage: 94e38ba0b9bbe2cbcc193e2c9510bdddd077c671 + React-featureflags: 58e83ba96a419d951be0d4b21a6ae68f781878dd + React-featureflagsnativemodule: 747a3d8ab183715d1970d41ce0e0b8e6efde587b + React-graphics: 5aaae731d271336ba0987df85959a6c18e99cf18 + React-hermes: 5e11668d40cd4018b12b9e5fdb70734b397d5d36 + React-idlecallbacksnativemodule: bc04973c5f25c950b3cc6762b852d9aa71db0d7d + React-ImageManager: 0902cf80dec9b0718f132be4282b4e8c54261ea2 + React-intersectionobservernativemodule: 87d6836718008cc488276bc0c451b5704c299bc3 + React-jserrorhandler: 73e465fdd1048d7875bb1aa5ef7c3fe705223a92 + React-jsi: d6357d2b0c432e11d27c56f7c669b1e25ef6679a + React-jsiexecutor: 0c17d498f1bcb9918624bbc6e2a42e69811072ce + React-jsinspector: 1ef75331bf942924d5815048c36c54f61ea806da + React-jsinspectorcdp: 1b6e01e04288f536ee213c51a7a3308bef7a666b + React-jsinspectornetwork: 2939ee17ed68c2f6ba7d7dfcc5a087e31ffe0e59 + React-jsinspectortracing: c0e466edbea32a90cb9cd9d434a4e086f346be11 + React-jsitooling: 6739a8a977ea8c3fecd66f21af12a3a78deaba97 + React-jsitracing: ca7afeb7e4b87996719b404b7caa0ea2e6327109 + React-logger: 5634bd3a58fcebc0e017c50cf276e6a658e173bf + React-Mapbuffer: 4b91498f164010499357104ef12cb3161ddbec9e + React-microtasksnativemodule: e0095be041c79483d1d3fded0c682c31d351730f + React-mutationobservernativemodule: edfe5f0e9548231a767c3a1db6fc52926da0002a + react-native-bench: 282cdcfcfbb9ab7dc4828bc2d5a5f2f207ed12d8 + react-native-safe-area-context: 2dcb1f85f454dcd176abb52bce501d05552bd517 + React-NativeModulesApple: 9f0679a0f98eecd53150ebb6cba645f73a92434a + React-networking: cabb0d227644bddc24f5e7dba0f5c4a8ea9b8723 + React-oscompat: 72573ceb65b6c6c6bc34e51e0df8f44c91081a6d + React-perflogger: aacf75afce65b4ca90210f6dbab01963d1428cbf + React-performancecdpmetrics: 9458fc11e71499082b9f1e8635ae3cd3e8083ed5 + React-performancetimeline: ad513cd5ba092d5539641cf820790a53910627d5 + React-RCTActionSheet: a88d0f7eb5738ec418a2ff1984439a6924662b2c + React-RCTAnimatedModuleProvider: af5d33fd0cbbf1884b9369b62b90659156aa822f + React-RCTAnimation: 7b945912cabc7c53266322de0282e8788cee39b7 + React-RCTAppDelegate: 3567f3c6cf9c44a9fef5f2c61a4d2992cdb7b396 + React-RCTBlob: 74e5441e8b6d55b3c6b1fd891b46502bd0965376 + React-RCTFabric: d33d814d491ae85179fd91166224e6fa4e1d5fdb + React-RCTFBReactNativeSpec: 2b9967057eb81d324deee2b1e1079d195d6ae17a + React-RCTImage: 9f691b97ee53b2391721c6e300c277336296ae52 + React-RCTLinking: 7727049c4cd98ad115bbd7e3731de72bee9b83a0 + React-RCTNetwork: 6e90725d37a017b3337e1f8734f97c02d504deb3 + React-RCTRuntime: 22e387c218ff58ed0a5e4a28959e3b53ff50012d + React-RCTSettings: 373be2bbc026043135004c57aea276e6399d6c20 + React-RCTText: 386a5390130df3970e7de10e54b05002f70ac85a + React-RCTVibration: 97d1083b724e20c48d0ffd8729d18058e5bc5e13 + React-rendererconsistency: 98dbc9df61c67bfca2d95626f11aa9520b930be5 + React-renderercss: 369a9d7d75f77c98cf078baf70018fe49bd70b27 + React-rendererdebug: 8879d9bbd067507cac51849fe2174466e3abad2a + React-RuntimeApple: a76defddd4f00e2dadf353922358101ea0461b85 + React-RuntimeCore: 7fe8468e2b7004f3089cb07340e12f790de90ef1 + React-runtimeexecutor: 151f8320f1193418aa6e80e441fdd74878fe39da + React-RuntimeHermes: 3dd08c248378354b652f007950cc318a4a329775 + React-runtimescheduler: 84e2dd7afa39cf20de9de7abec63629ddfc1828f + React-timing: b4188ec6e1329cd0d9d808a916fcfac069172ea7 + React-utils: 5f70b1679c349e42a7d0b91007351cda2569b353 + React-viewtransitionnativemodule: 216e3d392406e58c3e0ae9f3a9afe72c23720b5f + React-webperformancenativemodule: 4577ed49e8b74da3d5cce379de6cf1c65a67212a + ReactAppDependencyProvider: 564644d20b4598060a0d24a9dfc029b5673b32f3 + ReactCodegen: b54c2a8d91c25df024024b078e9fd3cdf93ce8cf + ReactCommon: 31d05b76329c8b3c9ef37fff8212f830925a2535 + ReactNativeDependencies: ec5530432d8191b38e278117d498aaff05ea9d6f + SocketRocket: d57b1af80c029e12a75cd9b218287c67381e4593 + Yoga: 58b502c52b14ad3c4468805d461fc37d275c2a42 + +PODFILE CHECKSUM: f4223198ab5bcdc35c65068accdd29932a147d05 + +COCOAPODS: 1.15.2 diff --git a/benchmarks/rn-benchmark/RNBench/ios/RNBench.xcodeproj/project.pbxproj b/benchmarks/rn-benchmark/RNBench/ios/RNBench.xcodeproj/project.pbxproj new file mode 100644 index 000000000..234c0720d --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/RNBench.xcodeproj/project.pbxproj @@ -0,0 +1,494 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 049900C1DAAF6F378DE616B2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; + 0C80B921A6F3F58F76C31292 /* libPods-RNBench.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-RNBench.a */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 13B07F961A680F5B00A75B9A /* RNBench.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RNBench.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = RNBench/Images.xcassets; sourceTree = "<group>"; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = RNBench/Info.plist; sourceTree = "<group>"; }; + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = RNBench/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; + 3B4392A12AC88292D35C810B /* Pods-RNBench.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNBench.debug.xcconfig"; path = "Target Support Files/Pods-RNBench/Pods-RNBench.debug.xcconfig"; sourceTree = "<group>"; }; + 5709B34CF0A7D63546082F79 /* Pods-RNBench.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNBench.release.xcconfig"; path = "Target Support Files/Pods-RNBench/Pods-RNBench.release.xcconfig"; sourceTree = "<group>"; }; + 5DCACB8F33CDC322A6C60F78 /* libPods-RNBench.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNBench.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = RNBench/AppDelegate.swift; sourceTree = "<group>"; }; + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = RNBench/LaunchScreen.storyboard; sourceTree = "<group>"; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C80B921A6F3F58F76C31292 /* libPods-RNBench.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* RNBench */ = { + isa = PBXGroup; + children = ( + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 761780EC2CA45674006654EE /* AppDelegate.swift */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, + ); + name = RNBench; + sourceTree = "<group>"; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 5DCACB8F33CDC322A6C60F78 /* libPods-RNBench.a */, + ); + name = Frameworks; + sourceTree = "<group>"; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = "<group>"; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* RNBench */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + BBD78D7AC51CEA395F1C20DB /* Pods */, + ); + indentWidth = 2; + sourceTree = "<group>"; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* RNBench.app */, + ); + name = Products; + sourceTree = "<group>"; + }; + BBD78D7AC51CEA395F1C20DB /* Pods */ = { + isa = PBXGroup; + children = ( + 3B4392A12AC88292D35C810B /* Pods-RNBench.debug.xcconfig */, + 5709B34CF0A7D63546082F79 /* Pods-RNBench.release.xcconfig */, + ); + path = Pods; + sourceTree = "<group>"; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* RNBench */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "RNBench" */; + buildPhases = ( + C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */, + E235C05ADACE081382539298 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RNBench; + productName = RNBench; + productReference = 13B07F961A680F5B00A75B9A /* RNBench.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1210; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1120; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "RNBench" */; + compatibilityVersion = "Xcode 12.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* RNBench */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 049900C1DAAF6F378DE616B2 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/.xcode.env", + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"\\\"$WITH_ENVIRONMENT\\\" \\\"$REACT_NATIVE_XCODE\\\"\"\n"; + }; + 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RNBench/Pods-RNBench-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RNBench/Pods-RNBench-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RNBench/Pods-RNBench-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RNBench-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RNBench/Pods-RNBench-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-RNBench/Pods-RNBench-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RNBench/Pods-RNBench-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-RNBench.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = RNBench/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = RNBench; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-RNBench.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = RNBench/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = RNBench; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + PODFILE_DIR = "$(SRCROOT)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + USE_HERMES = true; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + PODFILE_DIR = "$(SRCROOT)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "RNBench" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "RNBench" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/benchmarks/rn-benchmark/RNBench/ios/RNBench.xcodeproj/xcshareddata/xcschemes/RNBench.xcscheme b/benchmarks/rn-benchmark/RNBench/ios/RNBench.xcodeproj/xcshareddata/xcschemes/RNBench.xcscheme new file mode 100644 index 000000000..5b281acc6 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/RNBench.xcodeproj/xcshareddata/xcschemes/RNBench.xcscheme @@ -0,0 +1,88 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1210" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "13B07F861A680F5B00A75B9A" + BuildableName = "RNBench.app" + BlueprintName = "RNBench" + ReferencedContainer = "container:RNBench.xcodeproj"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES"> + <Testables> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "00E356ED1AD99517003FC87E" + BuildableName = "RNBenchTests.xctest" + BlueprintName = "RNBenchTests" + ReferencedContainer = "container:RNBench.xcodeproj"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + <BuildableProductRunnable + runnableDebuggingMode = "0"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "13B07F861A680F5B00A75B9A" + BuildableName = "RNBench.app" + BlueprintName = "RNBench" + ReferencedContainer = "container:RNBench.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <BuildableProductRunnable + runnableDebuggingMode = "0"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "13B07F861A680F5B00A75B9A" + BuildableName = "RNBench.app" + BlueprintName = "RNBench" + ReferencedContainer = "container:RNBench.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/benchmarks/rn-benchmark/RNBench/ios/RNBench.xcworkspace/contents.xcworkspacedata b/benchmarks/rn-benchmark/RNBench/ios/RNBench.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..f6e27dc5c --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/RNBench.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Workspace + version = "1.0"> + <FileRef + location = "group:RNBench.xcodeproj"> + </FileRef> + <FileRef + location = "group:Pods/Pods.xcodeproj"> + </FileRef> +</Workspace> diff --git a/benchmarks/rn-benchmark/RNBench/ios/RNBench/AppDelegate.swift b/benchmarks/rn-benchmark/RNBench/ios/RNBench/AppDelegate.swift new file mode 100644 index 000000000..20e61ec61 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/RNBench/AppDelegate.swift @@ -0,0 +1,48 @@ +import UIKit +import React +import React_RCTAppDelegate +import ReactAppDependencyProvider + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + var reactNativeDelegate: ReactNativeDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = RCTReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + + window = UIWindow(frame: UIScreen.main.bounds) + + factory.startReactNative( + withModuleName: "RNBench", + in: window, + launchOptions: launchOptions + ) + + return true + } +} + +class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { + override func sourceURL(for bridge: RCTBridge) -> URL? { + self.bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") +#else + Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/benchmarks/rn-benchmark/RNBench/ios/RNBench/Images.xcassets/AppIcon.appiconset/Contents.json b/benchmarks/rn-benchmark/RNBench/ios/RNBench/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..81213230d --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/RNBench/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,53 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/benchmarks/rn-benchmark/RNBench/ios/RNBench/Images.xcassets/Contents.json b/benchmarks/rn-benchmark/RNBench/ios/RNBench/Images.xcassets/Contents.json new file mode 100644 index 000000000..2d92bd53f --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/RNBench/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/benchmarks/rn-benchmark/RNBench/ios/RNBench/Info.plist b/benchmarks/rn-benchmark/RNBench/ios/RNBench/Info.plist new file mode 100644 index 000000000..e5a86dd7f --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/RNBench/Info.plist @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CADisableMinimumFrameDurationOnPhone</key> + <true/> + <key>CFBundleDevelopmentRegion</key> + <string>en</string> + <key>CFBundleDisplayName</key> + <string>RNBench</string> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>$(MARKETING_VERSION)</string> + <key>CFBundleSignature</key> + <string>????</string> + <key>CFBundleVersion</key> + <string>$(CURRENT_PROJECT_VERSION)</string> + <key>LSRequiresIPhoneOS</key> + <true/> + <key>NSAppTransportSecurity</key> + <dict> + <key>NSAllowsArbitraryLoads</key> + <false/> + <key>NSAllowsLocalNetworking</key> + <true/> + </dict> + <key>NSLocationWhenInUseUsageDescription</key> + <string></string> + <key>RCTNewArchEnabled</key> + <true/> + <key>UILaunchStoryboardName</key> + <string>LaunchScreen</string> + <key>UIRequiredDeviceCapabilities</key> + <array> + <string>arm64</string> + </array> + <key>UISupportedInterfaceOrientations</key> + <array> + <string>UIInterfaceOrientationPortrait</string> + </array> + <key>UISupportedInterfaceOrientations~ipad</key> + <array> + <string>UIInterfaceOrientationLandscapeLeft</string> + <string>UIInterfaceOrientationLandscapeRight</string> + <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationPortraitUpsideDown</string> + </array> + <key>UIViewControllerBasedStatusBarAppearance</key> + <false/> +</dict> +</plist> diff --git a/benchmarks/rn-benchmark/RNBench/ios/RNBench/LaunchScreen.storyboard b/benchmarks/rn-benchmark/RNBench/ios/RNBench/LaunchScreen.storyboard new file mode 100644 index 000000000..c47c373aa --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/RNBench/LaunchScreen.storyboard @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM"> + <device id="retina4_7" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <scenes> + <!--View Controller--> + <scene sceneID="EHf-IW-A2E"> + <objects> + <viewController id="01J-lp-oVM" sceneMemberID="viewController"> + <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> + <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="RNBench" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb"> + <rect key="frame" x="0.0" y="202" width="375" height="43"/> + <fontDescription key="fontDescription" type="boldSystem" pointSize="36"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Powered by React Native" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu"> + <rect key="frame" x="0.0" y="626" width="375" height="21"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/> + <constraints> + <constraint firstItem="Bcu-3y-fUS" firstAttribute="bottom" secondItem="MN2-I3-ftu" secondAttribute="bottom" constant="20" id="OZV-Vh-mqD"/> + <constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/> + <constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="akx-eg-2ui"/> + <constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" id="i1E-0Y-4RG"/> + <constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/3" constant="1" id="moa-c2-u7t"/> + <constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/> + </constraints> + <viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/> + </view> + </viewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/> + </objects> + <point key="canvasLocation" x="52.173913043478265" y="375"/> + </scene> + </scenes> +</document> diff --git a/benchmarks/rn-benchmark/RNBench/ios/RNBench/PrivacyInfo.xcprivacy b/benchmarks/rn-benchmark/RNBench/ios/RNBench/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..fc90547a7 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/ios/RNBench/PrivacyInfo.xcprivacy @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>NSPrivacyAccessedAPITypes</key> + <array> + <dict> + <key>NSPrivacyAccessedAPIType</key> + <string>NSPrivacyAccessedAPICategoryFileTimestamp</string> + <key>NSPrivacyAccessedAPITypeReasons</key> + <array> + <string>C617.1</string> + </array> + </dict> + <dict> + <key>NSPrivacyAccessedAPIType</key> + <string>NSPrivacyAccessedAPICategorySystemBootTime</string> + <key>NSPrivacyAccessedAPITypeReasons</key> + <array> + <string>35F9.1</string> + </array> + </dict> + <dict> + <key>NSPrivacyAccessedAPIType</key> + <string>NSPrivacyAccessedAPICategoryUserDefaults</string> + <key>NSPrivacyAccessedAPITypeReasons</key> + <array> + <string>CA92.1</string> + </array> + </dict> + </array> + <key>NSPrivacyCollectedDataTypes</key> + <array/> + <key>NSPrivacyTracking</key> + <false/> +</dict> +</plist> diff --git a/benchmarks/rn-benchmark/RNBench/jest.config.js b/benchmarks/rn-benchmark/RNBench/jest.config.js new file mode 100644 index 000000000..294be30fe --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + preset: '@react-native/jest-preset', +}; diff --git a/benchmarks/rn-benchmark/RNBench/metro.config.js b/benchmarks/rn-benchmark/RNBench/metro.config.js new file mode 100644 index 000000000..2a0a21ce4 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/metro.config.js @@ -0,0 +1,11 @@ +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); + +/** + * Metro configuration + * https://reactnative.dev/docs/metro + * + * @type {import('@react-native/metro-config').MetroConfig} + */ +const config = {}; + +module.exports = mergeConfig(getDefaultConfig(__dirname), config); diff --git a/benchmarks/rn-benchmark/RNBench/modules/bench/android/build.gradle b/benchmarks/rn-benchmark/RNBench/modules/bench/android/build.gradle new file mode 100644 index 000000000..0f929bb6e --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/modules/bench/android/build.gradle @@ -0,0 +1,38 @@ +// No `buildscript` block on purpose. This module is included in the app's build, so AGP, the +// Kotlin plugin and the React Native plugin are already on the root project's classpath with +// versions the app chose. Declaring them again here resolves against an empty repository list and +// fails with "Could not find com.android.tools.build:gradle:" -- no version, because there is none +// to inherit. + +apply plugin: "com.android.library" +apply plugin: "org.jetbrains.kotlin.android" +// Runs codegen for this library from ../package.json's codegenConfig and puts the generated +// NativeBenchSpec on the compile classpath. +apply plugin: "com.facebook.react" + +android { + namespace "com.rnbench.bench" + compileSdk rootProject.ext.has("compileSdkVersion") ? rootProject.ext.compileSdkVersion : 36 + + defaultConfig { + minSdk rootProject.ext.has("minSdkVersion") ? rootProject.ext.minSdkVersion : 24 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlin { + jvmToolchain(17) + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + implementation "com.facebook.react:react-android" +} diff --git a/benchmarks/rn-benchmark/RNBench/modules/bench/android/src/main/AndroidManifest.xml b/benchmarks/rn-benchmark/RNBench/modules/bench/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..94cbbcfc3 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/modules/bench/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +<manifest xmlns:android="http://schemas.android.com/apk/res/android" /> diff --git a/benchmarks/rn-benchmark/RNBench/modules/bench/android/src/main/java/com/rnbench/bench/BenchModule.kt b/benchmarks/rn-benchmark/RNBench/modules/bench/android/src/main/java/com/rnbench/bench/BenchModule.kt new file mode 100644 index 000000000..3290a43e0 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/modules/bench/android/src/main/java/com/rnbench/bench/BenchModule.kt @@ -0,0 +1,80 @@ +package com.rnbench.bench + +import com.facebook.react.bridge.Callback +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.module.annotations.ReactModule + +/** + * The native half of the boundary. **Every method body here is as close to empty as the shape + * allows**, because what is being timed is the crossing, not the callee. + * + * The one exception is [nativeLoopNs], which exists precisely to say how much the callee costs + * when nothing is crossed, so that the crossing can be read net of it. + * + * On the New Architecture this class is reached through JSI: the JS call lands in the generated + * `NativeBenchSpecJSI` C++ shim, which does a JNI call into these methods. That means an Android + * JS -> native call pays **two** boundaries -- JSI/C++ and C++/JNI -- which is the single most + * important structural fact when comparing this with the reference project, where an upcall pays + * one on iOS/androidNative and two on Android/ART for exactly the same reason. + */ +@ReactModule(name = BenchModule.NAME) +class BenchModule(reactContext: ReactApplicationContext) : NativeBenchSpec(reactContext) { + + override fun getName(): String = NAME + + /** The boundary's floor: sync (it returns a value), no arguments, constant result. */ + override fun zeroArgs(): Double = 0.0 + + override fun noop() { + // Deliberately empty -- and deliberately *not* the floor row. Being `void` makes this + // `VoidKind`, which `JavaTurboModule` dispatches onto the native method queue rather than + // running on the JS thread, so timing it from JS times an enqueue. + } + + override fun addInts(a: Double, b: Double): Double = a + b + + override fun echoString(s: String): String = s + + override fun stringLength(s: String): Double = s.length.toDouble() + + override fun sumArray(xs: ReadableArray): Double { + var total = 0.0 + val n = xs.size() + for (i in 0 until n) { + total += xs.getDouble(i) + } + return total + } + + override fun sumObject(o: ReadableMap): Double = + o.getDouble("a") + o.getDouble("b") + o.getDouble("c") + + override fun nativeLoopNs(iterations: Double): Double { + val n = iterations.toLong() + if (n <= 0L) return 0.0 + // `sink` is read back into the return value so the loop cannot be eliminated. + var sink = 0.0 + val t0 = System.nanoTime() + for (i in 0L until n) { + sink += addInts(3.0, 4.0) + } + val elapsed = System.nanoTime() - t0 + // The multiply-by-zero keeps `sink` live without perturbing the result. + return elapsed.toDouble() / n.toDouble() + sink * 0.0 + } + + override fun pingCallback(value: Double, cb: Callback) { + cb.invoke(value) + } + + override fun pingPromise(value: Double, promise: Promise) { + promise.resolve(value) + } + + companion object { + const val NAME = "Bench" + } +} diff --git a/benchmarks/rn-benchmark/RNBench/modules/bench/android/src/main/java/com/rnbench/bench/BenchPackage.kt b/benchmarks/rn-benchmark/RNBench/modules/bench/android/src/main/java/com/rnbench/bench/BenchPackage.kt new file mode 100644 index 000000000..2969e7aad --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/modules/bench/android/src/main/java/com/rnbench/bench/BenchPackage.kt @@ -0,0 +1,30 @@ +package com.rnbench.bench + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +/** + * Autolinked. The RN Gradle plugin finds this through `getPackages()` in the generated + * `PackageList`, which it builds from the CLI's autolinking output. + */ +class BenchPackage : BaseReactPackage() { + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = + if (name == BenchModule.NAME) BenchModule(reactContext) else null + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider { + mapOf( + BenchModule.NAME to ReactModuleInfo( + BenchModule.NAME, + BenchModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true, // isTurboModule + ), + ) + } +} diff --git a/benchmarks/rn-benchmark/RNBench/modules/bench/ios/Bench.h b/benchmarks/rn-benchmark/RNBench/modules/bench/ios/Bench.h new file mode 100644 index 000000000..9adcfc8f5 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/modules/bench/ios/Bench.h @@ -0,0 +1,17 @@ +#import <Foundation/Foundation.h> +#import <RNBenchSpec/RNBenchSpec.h> + +NS_ASSUME_NONNULL_BEGIN + +/** + * The native half of the boundary on iOS. + * + * Structurally this is the cheaper of the two platforms: the generated `NativeBenchSpecJSI` shim + * calls straight into these Objective-C methods, so a JS -> native call crosses **one** boundary + * (JSI/C++ -> ObjC message send) where Android crosses two (JSI/C++ -> JNI -> JVM). The reference + * project has the same asymmetry in the same direction, and for the same reason. + */ +@interface Bench : NSObject <NativeBenchSpec> +@end + +NS_ASSUME_NONNULL_END diff --git a/benchmarks/rn-benchmark/RNBench/modules/bench/ios/Bench.mm b/benchmarks/rn-benchmark/RNBench/modules/bench/ios/Bench.mm new file mode 100644 index 000000000..9a2c039e2 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/modules/bench/ios/Bench.mm @@ -0,0 +1,99 @@ +#import "Bench.h" + +#import <mach/mach_time.h> + +@implementation Bench + +RCT_EXPORT_MODULE(Bench) + +// Every body below is as close to empty as its shape allows: what is timed is the crossing, not +// the callee. `nativeLoopNs` is the one exception and exists to price the callee. + +// The boundary's floor: sync (it returns a value), no arguments, constant result. +- (NSNumber *)zeroArgs +{ + return @(0.0); +} + +// Deliberately *not* the floor row -- being `void` makes this `VoidKind`, which RCTTurboModule +// dispatches onto the module's method queue instead of running it on the JS thread. +- (void)noop +{ +} + +- (NSNumber *)addInts:(double)a b:(double)b +{ + return @(a + b); +} + +- (NSString *)echoString:(NSString *)s +{ + return s; +} + +- (NSNumber *)stringLength:(NSString *)s +{ + return @((double)s.length); +} + +- (NSNumber *)sumArray:(NSArray *)xs +{ + double total = 0.0; + for (NSNumber *n in xs) { + total += n.doubleValue; + } + return @(total); +} + +- (NSNumber *)sumObject:(NSDictionary *)o +{ + double total = 0.0; + total += [(NSNumber *)o[@"a"] doubleValue]; + total += [(NSNumber *)o[@"b"] doubleValue]; + total += [(NSNumber *)o[@"c"] doubleValue]; + return @(total); +} + +- (NSNumber *)nativeLoopNs:(double)iterations +{ + long long n = (long long)iterations; + if (n <= 0) { + return @(0.0); + } + + static mach_timebase_info_data_t timebase; + static dispatch_once_t once; + dispatch_once(&once, ^{ + mach_timebase_info(&timebase); + }); + + // `sink` is folded into the return value so the loop cannot be optimised away. + volatile double sink = 0.0; + uint64_t t0 = mach_absolute_time(); + for (long long i = 0; i < n; i++) { + sink += 3.0 + 4.0; + } + uint64_t elapsed = mach_absolute_time() - t0; + double ns = (double)elapsed * (double)timebase.numer / (double)timebase.denom; + return @(ns / (double)n + sink * 0.0); +} + +- (void)pingCallback:(double)value cb:(RCTResponseSenderBlock)cb +{ + cb(@[ @(value) ]); +} + +- (void)pingPromise:(double)value + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + resolve(@(value)); +} + +- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared<facebook::react::NativeBenchSpecJSI>(params); +} + +@end diff --git a/benchmarks/rn-benchmark/RNBench/modules/bench/package.json b/benchmarks/rn-benchmark/RNBench/modules/bench/package.json new file mode 100644 index 000000000..ae5cac710 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/modules/bench/package.json @@ -0,0 +1,25 @@ +{ + "name": "react-native-bench", + "version": "0.1.0", + "description": "Boundary-cost benchmark TurboModule: the JS <-> native crossing, priced against baselines measured in the same run.", + "main": "src/index.ts", + "private": true, + "files": [ + "src", + "android", + "ios", + "react-native-bench.podspec" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "codegenConfig": { + "name": "RNBenchSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.rnbench.bench" + } + } +} diff --git a/benchmarks/rn-benchmark/RNBench/modules/bench/react-native-bench.podspec b/benchmarks/rn-benchmark/RNBench/modules/bench/react-native-bench.podspec new file mode 100644 index 000000000..e17d95bd8 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/modules/bench/react-native-bench.podspec @@ -0,0 +1,21 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "react-native-bench" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://example.invalid/rn-benchmark" + s.license = "MIT" + s.authors = { "rn-benchmark" => "noreply@example.invalid" } + s.platforms = { :ios => min_ios_version_supported } + s.source = { :path => "." } + + s.source_files = "ios/**/*.{h,m,mm,cpp}" + + # Pulls in React-Core, ReactCommon/turbomodule and the generated spec headers, and sets the + # new-architecture compiler flags. Provided by react_native_pods.rb, which the app's Podfile + # already requires. + install_modules_dependencies(s) +end diff --git a/benchmarks/rn-benchmark/RNBench/modules/bench/src/NativeBench.ts b/benchmarks/rn-benchmark/RNBench/modules/bench/src/NativeBench.ts new file mode 100644 index 000000000..6489a036c --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/modules/bench/src/NativeBench.ts @@ -0,0 +1,102 @@ +import type {TurboModule} from 'react-native'; +import {TurboModuleRegistry} from 'react-native'; + +/** + * The JS -> native boundary, one shape per method, and nothing else in any of them. + * + * Every method here is **synchronous**: in the New Architecture a TurboModule method whose return + * type is not a Promise is invoked on the JS thread through JSI, with no bridge, no serialisation + * and no scheduler hop. That is the property that makes this comparable with the reference + * project's upcall (Python -> Kotlin), which is also a direct call on the calling thread. + * + * The four shapes mirror `UpcallBoundaryCostTest`'s rows: + * + * | here | reference project | + * |-----------------|--------------------------------------------| + * | `noop` | the boundary with no arguments at all | + * | `addInts` | `trampoline.add` -- two ints in, one out | + * | `echoString` | UTF-8 marshalling across the boundary | + * | `sumArray` | collection conversion (JSI Array -> native) | + * | `sumObject` | collection conversion (JSI Object -> native)| + * + * `nativeLoopNs` and `pingCallback` are controls, not shapes -- see their comments. + */ +export interface Spec extends TurboModule { + /** + * The boundary and nothing else: no arguments, a constant back. + * + * **This, not `noop`, is the empty-call row.** Codegen marks a method + * `@ReactMethod(isBlockingSynchronousMethod = true)` only when it returns a value; a `void` + * method is `VoidKind` and `JavaTurboModule`/`RCTTurboModule` dispatch those onto the module's + * method queue instead of running them on the JS thread. So `noop()` times an *enqueue*, and + * only a value-returning method times a crossing that goes there and comes back. + * + * That is not a detail to note in passing -- it is the difference between comparing like with + * like and publishing a boundary cost that is really a queue push. The generated + * `NativeBenchSpec.java` was read to establish it rather than inferred from the docs. + */ + zeroArgs(): number; + + /** + * A `void` method, kept **as a contrast, not as the empty-call row**: it is dispatched + * asynchronously (see `zeroArgs`), so what it prices is how cheap fire-and-forget is when the + * caller never waits. Reported in its own line and never subtracted from anything. + */ + noop(): void; + + /** + * Two integers in, one out. The same shape as the reference project's `trampoline.add`, so this + * is the row the cross-project comparison is actually made on. + * + * JS numbers are IEEE doubles; codegen types this as `double` on both platforms, so "integer" + * here means "a number that happens to be integral", exactly as it does in JS. + */ + addInts(a: number, b: number): number; + + /** + * String in, the same string out. Charges the crossing for two conversions: a JSI string to the + * platform's string type on the way in, and back on the way out. The reference project's + * equivalent is `PyUnicode` <-> UTF-8, which its docs price at "hundreds of ns". + */ + echoString(s: string): string; + + /** String in, its length out. One conversion instead of two, so the return leg can be priced. */ + stringLength(s: string): number; + + /** + * An array of numbers in, their sum out. The native side must walk the whole array, which is + * where a per-element conversion cost shows up if there is one. Call it with several lengths and + * fit a line: the intercept is the boundary, the slope is the per-element conversion. + */ + sumArray(xs: Array<number>): number; + + /** An object in, the sum of three known keys out. The map-shaped counterpart of `sumArray`. */ + sumObject(o: Object): number; + + /** + * A loop that runs entirely on the native side, timed by the native side's own clock, and + * returns ns/iteration. + * + * This is the control that says how much of a measured per-call cost is the boundary and how + * much is the work behind it: it performs `addInts`' body `iterations` times without crossing + * anything. The reference project's counterpart is `_pm_time_loop` / the pure-Python callee. + */ + nativeLoopNs(iterations: number): number; + + /** + * The other direction: native -> JS. + * + * **This is not the symmetric counterpart of the methods above and must not be read as one.** + * The New Architecture gives app code no synchronous native -> JS entry point: a `Callback` + * parameter is invoked through the `RuntimeScheduler`, so the figure this produces contains a + * scheduler hop and a queue drain as well as the crossing. It is measured anyway because it is + * what an RN app actually pays in that direction, and it is labelled as a round trip everywhere + * it is reported. + */ + pingCallback(value: number, cb: (result: number) => void): void; + + /** The Promise-returning round trip, for the same reason and with the same caveat. */ + pingPromise(value: number): Promise<number>; +} + +export default TurboModuleRegistry.getEnforcing<Spec>('Bench'); diff --git a/benchmarks/rn-benchmark/RNBench/modules/bench/src/index.ts b/benchmarks/rn-benchmark/RNBench/modules/bench/src/index.ts new file mode 100644 index 000000000..dd544dea0 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/modules/bench/src/index.ts @@ -0,0 +1,2 @@ +export {default as Bench} from './NativeBench'; +export type {Spec as BenchSpec} from './NativeBench'; diff --git a/benchmarks/rn-benchmark/RNBench/package-lock.json b/benchmarks/rn-benchmark/RNBench/package-lock.json new file mode 100644 index 000000000..7e1c21a06 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/package-lock.json @@ -0,0 +1,12254 @@ +{ + "name": "RNBench", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "RNBench", + "version": "0.0.1", + "dependencies": { + "@react-native/new-app-screen": "0.87.0", + "react": "19.2.3", + "react-native": "0.87.0", + "react-native-bench": "file:./modules/bench", + "react-native-safe-area-context": "^5.5.2" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@babel/preset-env": "^7.25.3", + "@babel/runtime": "^7.25.0", + "@react-native-community/cli": "20.2.0", + "@react-native-community/cli-platform-android": "20.2.0", + "@react-native-community/cli-platform-ios": "20.2.0", + "@react-native/babel-preset": "0.87.0", + "@react-native/eslint-config": "0.87.0", + "@react-native/jest-preset": "0.87.0", + "@react-native/metro-config": "0.87.0", + "@react-native/typescript-config": "0.87.0", + "@types/jest": "^29.5.13", + "@types/react": "^19.2.0", + "@types/react-test-renderer": "^19.1.0", + "eslint": "^8.19.0", + "jest": "^29.6.3", + "prettier": "2.8.8", + "react-test-renderer": "19.2.3", + "typescript": "^6.0.3" + }, + "engines": { + "node": ">= 22.11.0" + } + }, + "modules/bench": { + "name": "react-native-bench", + "version": "0.1.0", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/eslint-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.29.7.tgz", + "integrity": "sha512-zxt+UJTOMKvUt3yOg+D58MLuz334pHp93qifMFcjIIO+9hN6t+ufw2gi7vDPMpxvfnHRR+3VVXvIjineCcgyXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0", + "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", + "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", + "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", + "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@jest/reporters/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@react-native-community/cli": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.2.0.tgz", + "integrity": "sha512-5RZKkBeUFC4hhrzySzxWmRJwXBTc2PE3L6QUgvnJk3QDxNYMY7aGuI8Gr9eZRS1DbiY10IPWqpJfqSIplzVG6A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-clean": "20.2.0", + "@react-native-community/cli-config": "20.2.0", + "@react-native-community/cli-doctor": "20.2.0", + "@react-native-community/cli-server-api": "20.2.0", + "@react-native-community/cli-tools": "20.2.0", + "@react-native-community/cli-types": "20.2.0", + "commander": "^9.4.1", + "deepmerge": "^4.3.0", + "execa": "^5.0.0", + "find-up": "^5.0.0", + "fs-extra": "^8.1.0", + "graceful-fs": "^4.1.3", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "semver": "^7.5.2" + }, + "bin": { + "rnc-cli": "build/bin.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native-community/cli-clean": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-20.2.0.tgz", + "integrity": "sha512-krqbhFiwHN8l5wZ2XTcrNq9N+DqDWHxW8RK0bIcrK0O+fMvjYi9e/1Pnx2W0CTotOVcZpHzUbgZ4TQVF231Skw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.2.0", + "execa": "^5.0.0", + "fast-glob": "^3.3.2", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-config": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-20.2.0.tgz", + "integrity": "sha512-GF5FxgDfOSLPi/bSiE/xvOGELwzV2GLQnDED+7ArbPi01W1Vu+hD/m+J+bwX34Pocpo767eXAVxc2h9WFUTUfQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.2.0", + "cosmiconfig": "^9.0.0", + "deepmerge": "^4.3.0", + "fast-glob": "^3.3.2", + "joi": "^17.2.1", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-config-android": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-20.2.0.tgz", + "integrity": "sha512-lASofUNBVK0Pq3VEJ1JrCfAW94Rnk38DikauycRcoHl0hWjHIN3XhsN20dEq+CaIJoq37aCg5NSSSpmkET+ShA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.2.0", + "fast-glob": "^3.3.2", + "fast-xml-parser": "^5.3.6", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-config-apple": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-20.2.0.tgz", + "integrity": "sha512-oohr6BV2riWJ5PYjayi1u52bG3H95MwKPkJCZo9pnOlYTjifZQVkMxZ8VTuk392efh0bijVTYfxH8iJPi97SIQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.2.0", + "execa": "^5.0.0", + "fast-glob": "^3.3.2", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-doctor": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-20.2.0.tgz", + "integrity": "sha512-eZjlwmjPoBXgyD6nV5oDDocL1VH5UW4LxZcMAqyN3rQw5vq3CeJikFpNedKTtSl3P6JizIkInyIHpfrW+9qfJA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-config": "20.2.0", + "@react-native-community/cli-platform-android": "20.2.0", + "@react-native-community/cli-platform-apple": "20.2.0", + "@react-native-community/cli-platform-ios": "20.2.0", + "@react-native-community/cli-tools": "20.2.0", + "command-exists": "^1.2.8", + "deepmerge": "^4.3.0", + "envinfo": "^7.13.0", + "execa": "^5.0.0", + "node-stream-zip": "^1.9.1", + "ora": "^5.4.1", + "picocolors": "^1.1.1", + "semver": "^7.5.2", + "wcwidth": "^1.0.1", + "yaml": "^2.2.1" + } + }, + "node_modules/@react-native-community/cli-doctor/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native-community/cli-platform-android": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-20.2.0.tgz", + "integrity": "sha512-fRglzcf/Yq5lnIkqdA8uf1GvlG18x3Dm48Yvo+l7KiWgh/SVtfVhfvalrtX5rmx+KeJkDA534bGoPvi5+ruWjw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-config-android": "20.2.0", + "@react-native-community/cli-tools": "20.2.0", + "execa": "^5.0.0", + "logkitty": "^0.7.1", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-platform-apple": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.2.0.tgz", + "integrity": "sha512-jkEPLAd8C/ZRu39a3nhxwSXe0iisUiJC808GExnrnTcViLtg3sad2ciQ/HjjvbdsPCS9sT+Jr7xh3MzBsPojsQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-config-apple": "20.2.0", + "@react-native-community/cli-tools": "20.2.0", + "execa": "^5.0.0", + "fast-xml-parser": "^5.3.6", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-platform-ios": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.2.0.tgz", + "integrity": "sha512-bCKlBt2HoD7WTl/HX60+4HFhR1lKY64Y9MPWu7yWQwOCEqYRMi6MkRxLimiONj9M2/lNf0z9BPHDzdDe5HM2ag==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-platform-apple": "20.2.0" + } + }, + "node_modules/@react-native-community/cli-server-api": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-20.2.0.tgz", + "integrity": "sha512-AI1fsl+6LNuOoDcdWsnF3s9ozqminGCUlbFcrgdZIJWyOuFBeQclNydI22VO3vWaO4dHQkfVa3Zo10AncWQvwA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.2.0", + "body-parser": "^2.2.2", + "compression": "^1.7.1", + "connect": "^3.6.5", + "errorhandler": "^1.5.1", + "nocache": "^3.0.1", + "open": "^6.2.0", + "pretty-format": "^29.7.0", + "serve-static": "^1.13.1", + "ws": "^6.2.3" + } + }, + "node_modules/@react-native-community/cli-tools": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-20.2.0.tgz", + "integrity": "sha512-A5W2nqlTidPzGyXzS57jvHsMpQd8iOGSjePj4H318uMbhIdeIHQ5e59fNbdHahS62ISs+2p9s78sVHMbGVa1bg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vscode/sudo-prompt": "^9.0.0", + "appdirsjs": "^1.2.4", + "execa": "^5.0.0", + "find-up": "^5.0.0", + "launch-editor": "^2.9.1", + "mime": "^2.4.1", + "ora": "^5.4.1", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "semver": "^7.5.2" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native-community/cli-types": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-20.2.0.tgz", + "integrity": "sha512-K60zY/ly8G9rGJQzZ+bFRyLEckAUzyF8Fu9a2Kw6JipCrOl7SiVRBVqjvOr/XnCRKuOpH8i9y7RVg07wkSGRBQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "joi": "^17.2.1" + } + }, + "node_modules/@react-native-community/cli/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/asset-utils": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/asset-utils/-/asset-utils-0.87.0.tgz", + "integrity": "sha512-XUSXMnBmQuBMuMWNWaXMGt4tLzb2yraCVO4b7TFCvEqiCg3ByhbQA0QNmPp+cdWgvmPmptCJwOrBWjPxppXgOA==", + "license": "MIT", + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.87.0.tgz", + "integrity": "sha512-q0CCSAYgomb2G1fkmJ5ughwvVLqjHcdJ4w3Ffxw4YsdBenWYZsnMARyr/ghvcvfV33/YsZQhdMZ3XnpG6oth/Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@react-native/codegen": "0.87.0" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.87.0.tgz", + "integrity": "sha512-vthkHlSDWFVCQtQ22JZadM1J7wicn0773sP0C8wMb+2g6+6hn1209A1rMJz0ijpTI+RQ4XyQRmbnyTsf4kuIWg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@react-native/babel-plugin-codegen": "0.87.0", + "babel-plugin-syntax-hermes-parser": "0.36.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.87.0.tgz", + "integrity": "sha512-odcBGR0A9Ee83A/XvzOp9juGRkSQOQcI4ZYs+3H20MySMxvZJ34/2nVHoxdmj0YY0Mn6gF8BjLzYkL0axbHuCA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "hermes-parser": "0.36.1", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.87.0.tgz", + "integrity": "sha512-Voaq6e6aSjLsFmOVofP1RHM0wD0Wo51AlIheIDAzXqgBKwEHEz2na4swNZ6sjWLZH/4Wm2ydy+8A/6RCyfht6w==", + "license": "MIT", + "dependencies": { + "@react-native/asset-utils": "0.87.0", + "@react-native/dev-middleware": "0.87.0", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.87.0", + "semver": "^7.1.3" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "0.87.0" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.87.0.tgz", + "integrity": "sha512-TSUVjiX1cYganE6DcWVblDxs67x96u8/KqwhiHHViR6hGGjSIfIdNQcRLOEdXRpucuHB0awIFepBlw+34nTSUg==", + "license": "BSD-3-Clause", + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.87.0.tgz", + "integrity": "sha512-8Om5Qm8Ln9zGZ93DJj7gZtlfT0y1wAFc/iWr3GFkIXSa6zwmC3DGzm4sm5eBTtdMl27EXmKACIOGsqztPH3Cvg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.87.0.tgz", + "integrity": "sha512-Fy4RMTCcAn402KGgxQZ4CwoURJdVkoPVxUbN7OTf9xy9xCOmBsOQQ+Xs1n6ULkNMUGcL3ptajpb39ual6r5ixg==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.87.0", + "@react-native/debugger-shell": "0.87.0", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.3.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/eslint-config": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/eslint-config/-/eslint-config-0.87.0.tgz", + "integrity": "sha512-bVFvL9GE3zL0+0acjnr1iWdEzp4J9iyeZ4kDmnEG8bc6i9AcbzGzeh57qI1hEf6uEqmKFpe0O0KRDEFvsT6Rjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/eslint-parser": "^7.25.1", + "@react-native/eslint-plugin": "0.87.0", + "@typescript-eslint/eslint-plugin": "^8.59.2", + "@typescript-eslint/parser": "^8.59.2", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-eslint-comments": "^3.2.0", + "eslint-plugin-ft-flow": "^2.0.1", + "eslint-plugin-jest": "^29.0.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-native": "^5.0.0" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0", + "prettier": ">=2" + } + }, + "node_modules/@react-native/eslint-plugin": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/eslint-plugin/-/eslint-plugin-0.87.0.tgz", + "integrity": "sha512-q6kuDQFIF8BwzGe/IGJj8n9c61TxGBE+NVOChCSCC8b49T1nJ8nLmUAn9z29abOQLk+DLjqxSo1LQASgqPtD+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.87.0.tgz", + "integrity": "sha512-CFOhPsxN4yS6vYjShSGKQiJHDPQFKmbiJG17zZfJuHTvJiaWz7JkpznE/P1RRKXdgvCmACrj31vxJiCHLlI7Gg==", + "license": "MIT", + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/@react-native/jest-preset": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/jest-preset/-/jest-preset-0.87.0.tgz", + "integrity": "sha512-aNK/CXEfOIQPIobwywB0SmtsaTANV1ONXZmRx/glwdHeFfudDvoHbYNudKAwH1d/HBzfXZONCJXuc0UtRca60w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@react-native/js-polyfills": "0.87.0", + "babel-jest": "^29.7.0", + "jest-environment-node": "^29.7.0", + "regenerator-runtime": "^0.13.2" + }, + "engines": { + "node": ">= 22.13.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.87.0.tgz", + "integrity": "sha512-iwX3S/6XnR/TkOIk6Cz+FjeToDM/a4M1Cz/o1tfDakULjhKMI/UBtta1DsJgeg5c4WIJjkD4c8XlAJEb8pWOtg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/@react-native/metro-babel-transformer": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.87.0.tgz", + "integrity": "sha512-z57jsBX3q0fh2jTnrXisBo8A5qfkVFrnVJ0HMKRa8+cVsw/bz6MiEkXA/dsBK8eJfjawccxzXhAIzTzwbnxl4Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@react-native/babel-preset": "0.87.0", + "hermes-parser": "0.36.1", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/metro-config": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.87.0.tgz", + "integrity": "sha512-Pg0DmsJ/DbLP8JuxnRSDmrIaQjKhpzd2uYAZRyB2A8fJQzJWpAFLr7TUAXhiGD5025F6+PVPAnJL3HXaeMfZqQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native/js-polyfills": "0.87.0", + "@react-native/metro-babel-transformer": "0.87.0", + "metro-config": "^0.87.0", + "metro-runtime": "^0.87.0" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/@react-native/new-app-screen": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/new-app-screen/-/new-app-screen-0.87.0.tgz", + "integrity": "sha512-+beQmQ6C0DcRl9E6LjWKxIMrUnopVuJMeTYR9hGin2icYbCG9x/DzuqAYHNP2aXLl7AbpY/+tpIdCHbob2QlKw==", + "license": "MIT", + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + }, + "peerDependencies": { + "@types/react": "^19.1.0", + "react": "*", + "react-native": "0.87.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.87.0.tgz", + "integrity": "sha512-7aEGyrrquUqpbp+aEgOCp2xr0cTLYhJFCMfUqI4IoJddfwosra9Y5qtbrJU5Z/vBLndCmSIzABrWnHK0lxpwcA==", + "license": "MIT" + }, + "node_modules/@react-native/typescript-config": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/typescript-config/-/typescript-config-0.87.0.tgz", + "integrity": "sha512-JNpV6NYzQWgMGX4Z9CB1zrKpjXgaq9UOtnRFg1GnSFZcRHiY86RrLm/TGd+zN1QuGAZvGyj7yrZBmFblTMPASA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.87.0.tgz", + "integrity": "sha512-rYQCtZupN7Iczm8fdGg3uKZUXhrxdZPRkg4BZK3RaQJ2HJ7n5tJNR2QPR3vSgpp5CATQggiGCuom1fNEC3T/8g==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "react": "*", + "react-native": "0.87.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vscode/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-fragments": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ansi-fragments/-/ansi-fragments-0.2.1.tgz", + "integrity": "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "colorette": "^1.0.7", + "slice-ansi": "^2.0.0", + "strip-ansi": "^5.0.0" + } + }, + "node_modules/ansi-fragments/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-fragments/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/appdirsjs": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/appdirsjs/-/appdirsjs-1.2.8.tgz", + "integrity": "sha512-8zl1xlxeS4a0/36CT6LOaVioPOL8TeLT1b9OHk0j9xSbzmPBuM7lUgWMSTh6SbuF8fbwjcP1rr30OCLpd1fl+A==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/codingjerk" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/codingjerk" + } + ], + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz", + "integrity": "sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.36.1" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chrome-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", + "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4" + } + }, + "node_modules/chromium-edge-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/command-exists": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "devOptional": true, + "license": "Python-2.0" + }, + "node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.406", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.406.tgz", + "integrity": "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==", + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "devOptional": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/errorhandler": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.2.tgz", + "integrity": "sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "escape-html": "~1.0.3" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz", + "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-eslint-comments": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.2.0.tgz", + "integrity": "sha512-0jkOl0hfojIHHmEHgmNdqv4fmh7300NdpA9FFpF7zaoLvB/QeXOGNLIo86oAveJFrfB1p05kC8hpEMHM8DwWVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5", + "ignore": "^5.0.5" + }, + "engines": { + "node": ">=6.5.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } + }, + "node_modules/eslint-plugin-eslint-comments/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint-plugin-eslint-comments/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint-plugin-ft-flow": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-ft-flow/-/eslint-plugin-ft-flow-2.0.3.tgz", + "integrity": "sha512-Vbsd/b+LYA99jUbsL6viEUWShFaYQt2YQs3QN3f+aeszOhh2sgdcU0mjzDyD4yyBvMc8qy2uwvBBWfMzEX06tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "string-natural-compare": "^3.0.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "@babel/eslint-parser": "^7.12.0", + "eslint": "^8.1.0" + } + }, + "node_modules/eslint-plugin-jest": { + "version": "29.16.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.16.1.tgz", + "integrity": "sha512-tfxOIsjzaBud+f74aLbBMRcnrztt5eCIgnAdeoGdnzMAQ4IdAa/s/p8Ls55mk9MC79N3j2jbv4Qetz6Hclbcfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.0.0" + }, + "engines": { + "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "jest": "*", + "typescript": ">=4.8.4 <8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/eslint-plugin-react-native": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-native/-/eslint-plugin-react-native-5.0.0.tgz", + "integrity": "sha512-VyWlyCC/7FC/aONibOwLkzmyKg4j9oI8fzrk9WYNs4I8/m436JuOTAFwLvEn1CVvc7La4cPfbCyspP4OYpP52Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-plugin-react-native-globals": "^0.1.1" + }, + "peerDependencies": { + "eslint": "^3.17.0 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react-native-globals": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-native-globals/-/eslint-plugin-react-native-globals-0.1.2.tgz", + "integrity": "sha512-9aEPf1JEpiTjcFAmmyw8eiIXmcNZOqaZyHO77wgm0/dWfT/oxC1SrIq8ET38pMxHYrcB6Uew+TzUVsBeczF88g==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-scope/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-compiler": { + "version": "250829098.0.16", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.16.tgz", + "integrity": "sha512-xsgzk+mUyvt9t1nUbF8USBlYxajTUtPJhVZ86q85s/SEoMKCF+52YZcudb0ENSnV3T3lV9mgB3s6R7+pH90zgw==", + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.1.tgz", + "integrity": "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.1.tgz", + "integrity": "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.1" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/joi": { + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "devOptional": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/launch-editor": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.4" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logkitty": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz", + "integrity": "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-fragments": "^0.2.1", + "dayjs": "^1.8.15", + "yargs": "^15.1.0" + }, + "bin": { + "logkitty": "bin/logkitty.js" + } + }, + "node_modules/logkitty/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/logkitty/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logkitty/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/logkitty/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/metro": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.87.0.tgz", + "integrity": "sha512-fRqFhSzQhLNQSCvJFeuRzBRXAOOKXf1O8d2cvmMtG6yFR0jCllQ7vBsXoLP18yuqtf+N1XwWXTPF11eWy9q6dQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.36.1", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.87.0", + "metro-cache": "0.87.0", + "metro-cache-key": "0.87.0", + "metro-config": "0.87.0", + "metro-core": "0.87.0", + "metro-file-map": "0.87.0", + "metro-resolver": "0.87.0", + "metro-runtime": "0.87.0", + "metro-source-map": "0.87.0", + "metro-symbolicate": "0.87.0", + "metro-transform-plugins": "0.87.0", + "metro-transform-worker": "0.87.0", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.87.0.tgz", + "integrity": "sha512-IEn1K1FyY4J1sA5y6zqDjf2OkfmpTEqhZOeP6MJX8HepSW0cuHGw1m8bYOdv2adkG3XUE9dtM0csUs0gP/Xa5w==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.36.1", + "metro-cache-key": "0.87.0", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-cache": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.87.0.tgz", + "integrity": "sha512-146vS1BMSKcp99jddOhFBfHwzUEWN35NrsnSJDF2sQQ0ZT5OsBcOjd574PM233TWEZISRJ5DOK+vokD+1ubx+w==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.87.0" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-cache-key": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.87.0.tgz", + "integrity": "sha512-Q+MPt6jl0zQogr4Q02WaJK6HY+GtE5A0nzj8kIV1Owgrx6OMNvm6scPTr1SM/R4LpCE8EH/Y5qfbXQ84GHTr0Q==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-config": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.87.0.tgz", + "integrity": "sha512-yZ9QAIzWH9MxwrzwRlX/CBGRWOT14l7klSDYg8hdtSdnoUs5A7MQRdHE2KB9iHVzGQW5wgWM5aXJswNeWbSQPA==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.87.0", + "metro-cache": "0.87.0", + "metro-core": "0.87.0", + "metro-runtime": "0.87.0" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-core": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.87.0.tgz", + "integrity": "sha512-yW57+pCOHRC/CJZ99GA2PTd+30dORwDAjUPRCokj91IWW5In9Jwtt2FB5wACrGO8P0GHTyVHdTwDNyZsNndUbA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.87.0" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-file-map": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.87.0.tgz", + "integrity": "sha512-Dc57t8jsINwA90bbVlqaeDlxf1rVGgj5SmOEnOMbaHNUM/HCYYTJxPV8SRdOBwh7qTz/biO9vaQvBTjRBgbbsg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.87.0.tgz", + "integrity": "sha512-tPa0O983PDutFu3LXbArRH5NduogcKrvW6fs9VHhksTKUA1iqDyoD1ZSj/Me52xJ6T9/9pOwIVyj9ulKc/zMkg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-resolver": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.87.0.tgz", + "integrity": "sha512-Xl3M9R3KToaHJvXlI2lSOxtYHitzxite+195DSi00HL9PcS7Xik5+3xlRjfkKb3FA86SZxYPj+WJwlcfgaZoxg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-runtime": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.87.0.tgz", + "integrity": "sha512-XsXZkgEwI0ZMYSBfvOMAbenzwa60XlObXJ27g6/Khgrz9ESbiBbAsd7hR62G2jRBYOhGAzG61Gk22dpRJj/mdw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-source-map": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.87.0.tgz", + "integrity": "sha512-31BrYqu1c2co93rF1LN9Pw+7g+BrfDyxJkNQWrYm+pfA/+eVYVumF7tFMHbXLePLmHfhvSgVvbK6su1Oyiw1ng==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.87.0", + "nullthrows": "^1.1.1", + "ob1": "0.87.0", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-source-map/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.87.0.tgz", + "integrity": "sha512-uOpTxAXu74N+RSujUZ78L6gjI6bDdnz6XuW+AIUNuubZDEQPIpaX0StzIb0GMeQWP6zfHOHwFrWPP5Iquu1GXw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.87.0", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-symbolicate/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.87.0.tgz", + "integrity": "sha512-i8keUe9+BaSwMuQM26DGheElCpTtflAKIrSwJAm8ZsgDb50RAUQus+e6zt2suaJXJ1OxZa7vqgtqoZxdniM6Fw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.87.0.tgz", + "integrity": "sha512-YftLzNJxCTYxEN5k4AzR8KYwiENTEuz30L+4QeoMrtDd+U8mDThZg/ArR3JVRd8LaikwPOjVAS5SP3xPJN0AaA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.87.0", + "metro-babel-transformer": "0.87.0", + "metro-cache": "0.87.0", + "metro-cache-key": "0.87.0", + "metro-minify-terser": "0.87.0", + "metro-source-map": "0.87.0", + "metro-transform-plugins": "0.87.0", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/metro/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/metro/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/metro/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "devOptional": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nocache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/nocache/-/nocache-3.0.4.tgz", + "integrity": "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-stream-zip": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.16.0.tgz", + "integrity": "sha512-ObaRrRoR8T68wF6suxHd7R4XQNamij6ZQHrwG7Dx1D2zeHcDNLsIOBcWrIwtDm7AsCXBguaPHgXhcjxDa2szrg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT" + }, + "node_modules/ob1": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.87.0.tgz", + "integrity": "sha512-8Q8sKCiUwsxgSmjDtVWyRgmxsgeJXXam3oQH6Id8ADfNaJMV6GZKyeAl8+pGVVdgwAZabfk5+aExle7AP/nZiA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz", + "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-native": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.87.0.tgz", + "integrity": "sha512-e7CeZOm1wBksIISI5oLNmH4/GJCqWrfNphF7PuBsLY2qUM6mtzqL23N7iLTW6U7LF45qYi+a4iAp5Y1kSk1vVw==", + "license": "MIT", + "dependencies": { + "@react-native/asset-utils": "0.87.0", + "@react-native/codegen": "0.87.0", + "@react-native/community-cli-plugin": "0.87.0", + "@react-native/gradle-plugin": "0.87.0", + "@react-native/normalize-colors": "0.87.0", + "@react-native/virtualized-lists": "0.87.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-plugin-syntax-hermes-parser": "0.36.1", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "hermes-compiler": "250829098.0.16", + "invariant": "^2.2.4", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.87.0", + "metro-source-map": "^0.87.0", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.27.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "tinyglobby": "^0.2.15", + "whatwg-fetch": "^3.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" + }, + "peerDependencies": { + "@types/react": "^19.1.1", + "react": "^19.2.3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-bench": { + "resolved": "modules/bench", + "link": true + }, + "node_modules/react-native-safe-area-context": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.9.0.tgz", + "integrity": "sha512-jRqIeU7+ezRlSPv1uIeqvjgh7qamxXknqs+vXOgnY2NJqBFpK9u5HfFUWUw7WtBbK8SeoMfVt2fE/KT5c9On0Q==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-test-renderer": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.2.3.tgz", + "integrity": "sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^19.2.3", + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-test-renderer/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "devOptional": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "devOptional": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/slice-ansi/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/slice-ansi/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/stacktrace-parser/node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-natural-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", + "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/terser": { + "version": "5.50.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.50.0.tgz", + "integrity": "sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT" + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.6.tgz", + "integrity": "sha512-XTrf1gv7kXoVf1hbC3PAyAiPgR8Wz1blcrYIjEsUmr08BLksT41R8KbjmS9408C2ERx7v1JDLD/BkpLEttjfKA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/benchmarks/rn-benchmark/RNBench/package.json b/benchmarks/rn-benchmark/RNBench/package.json new file mode 100644 index 000000000..de2cda261 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/package.json @@ -0,0 +1,43 @@ +{ + "name": "RNBench", + "version": "0.0.1", + "private": true, + "scripts": { + "android": "react-native run-android", + "ios": "react-native run-ios", + "lint": "eslint .", + "start": "react-native start", + "test": "jest" + }, + "dependencies": { + "react": "19.2.3", + "react-native": "0.87.0", + "react-native-bench": "file:./modules/bench", + "@react-native/new-app-screen": "0.87.0", + "react-native-safe-area-context": "^5.5.2" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@babel/preset-env": "^7.25.3", + "@babel/runtime": "^7.25.0", + "@react-native-community/cli": "20.2.0", + "@react-native-community/cli-platform-android": "20.2.0", + "@react-native-community/cli-platform-ios": "20.2.0", + "@react-native/babel-preset": "0.87.0", + "@react-native/eslint-config": "0.87.0", + "@react-native/jest-preset": "0.87.0", + "@react-native/metro-config": "0.87.0", + "@react-native/typescript-config": "0.87.0", + "@types/jest": "^29.5.13", + "@types/react": "^19.2.0", + "@types/react-test-renderer": "^19.1.0", + "eslint": "^8.19.0", + "jest": "^29.6.3", + "prettier": "2.8.8", + "react-test-renderer": "19.2.3", + "typescript": "^6.0.3" + }, + "engines": { + "node": ">= 22.11.0" + } +} \ No newline at end of file diff --git a/benchmarks/rn-benchmark/RNBench/tsconfig.json b/benchmarks/rn-benchmark/RNBench/tsconfig.json new file mode 100644 index 000000000..c41b7e201 --- /dev/null +++ b/benchmarks/rn-benchmark/RNBench/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@react-native/typescript-config", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["**/node_modules", "**/Pods"] +} diff --git a/benchmarks/rn-benchmark/build-ios.sh b/benchmarks/rn-benchmark/build-ios.sh new file mode 100755 index 000000000..679f02bd1 --- /dev/null +++ b/benchmarks/rn-benchmark/build-ios.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Builds the iOS app for the simulator. A build never boots or touches a simulator, so this is safe +# to run while something else is using them. +# +# --- why this does not use -workspace/-scheme/-destination --------------------------------------- +# +# On this machine Xcode 26.6 refuses to enumerate ANY iOS destination, simulator ones included: +# +# $ xcodebuild -showdestinations -workspace RNBench.xcworkspace -scheme RNBench +# Ineligible destinations for the "RNBench" scheme: +# { platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, +# name:Any iOS Device, error:iOS 26.5 is not installed. Please download and +# install the platform from Xcode > Settings > Components. } +# +# Not one simulator destination is listed, eligible or ineligible. The same output comes back for +# the Pods project's own targets and for an unrelated project elsewhere on this machine, so it is a +# property of the Xcode install, not of this project. +# +# It is NOT a missing simulator runtime, which is what an earlier pass concluded. Seven iOS runtimes +# are installed and every one reports Ready and isAvailable=true, including iOS 26.2 (23C54) -- the +# runtime the reference project measures its iosSimulatorArm64 column on. `xcrun xcdevice list` +# shows all of their devices with available=true. CoreSimulator is entirely healthy; it is Xcode's +# scheme-destination layer that will not accept an SDK (26.5) with no exactly-matching platform +# component installed. +# +# The destination system is only consulted for -scheme builds. A target build with -sdk resolves +# straight to the SDK, which is present and usable (SDKROOT=iPhoneSimulator26.5.sdk), so that is +# what this uses. Nothing is downloaded and no runtime is needed at build time -- the deployment +# target is 15.1 and every installed runtime clears it. +# +# CocoaPods' Pods-RNBench target is built first because a -project build resolves no cross-project +# dependency for us; both invocations share one SYMROOT so the app finds libPods-RNBench.a. +# +# --- why LaunchScreen.storyboard is excluded ------------------------------------------------------ +# +# One tool still consults the platform check that the SDK path avoids: `ibtool`. With everything +# else compiled and linked, the build ended on +# +# CompileStoryboard .../RNBench/LaunchScreen.storyboard +# /* com.apple.ibtool.errors */ +# .../LaunchScreen.storyboard: error: iOS 26.5 Platform Not Installed. +# +# clang, swiftc, actool and the linker all worked; only ibtool refuses. EXCLUDED_SOURCE_FILE_NAMES +# drops the storyboard from the resource phase, and the app builds and launches without it. What is +# lost is the launch image: Info.plist still names UILaunchStoryboardName=LaunchScreen and the +# resource is absent, so the launch placeholder is blank. That has no effect on the benchmark, which +# times calls after the JS runtime is up, but it does mean this app is not shippable as built. +# +# ./build-ios.sh Release (default -- see run-bench.sh for why Release) +# ./build-ios.sh Debug Debug; needs a Metro server at run time, so not what the bench uses +# +# Derived data goes to the external SSD: an RN build tree is several GB and the internal disk does +# not have it. +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/env.sh" + +CONFIG="${1:-Release}" +SYM="$HERE/DerivedData/Build/Products" +OBJ="$HERE/DerivedData/Build/Intermediates.noindex" + +cd "$HERE/RNBench/ios" || exit 1 + +common=( + -sdk iphonesimulator + -configuration "$CONFIG" + SYMROOT="$SYM" + OBJROOT="$OBJ" + ARCHS=arm64 + ONLY_ACTIVE_ARCH=NO + CODE_SIGNING_ALLOWED=NO + CODE_SIGNING_REQUIRED=NO + CODE_SIGN_IDENTITY= + EXCLUDED_SOURCE_FILE_NAMES=LaunchScreen.storyboard +) + +echo "### building Pods-RNBench ($CONFIG)" +xcodebuild -project Pods/Pods.xcodeproj -target Pods-RNBench "${common[@]}" build || exit $? + +echo "### building RNBench ($CONFIG)" +exec xcodebuild -project RNBench.xcodeproj -target RNBench "${common[@]}" build diff --git a/benchmarks/rn-benchmark/check-ios-module.sh b/benchmarks/rn-benchmark/check-ios-module.sh new file mode 100755 index 000000000..af284f24c --- /dev/null +++ b/benchmarks/rn-benchmark/check-ios-module.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Compiles the benchmark module's Objective-C++ against the CocoaPods header tree and the codegen +# output, with clang directly. **This is not a substitute for building the app** -- it does not +# link, and it does not exercise the app target. What it does establish is the thing the app build +# would otherwise be the only witness to: that `Bench.mm` satisfies the generated +# `NativeBenchSpec` protocol, that the JSI shim it names exists, and that the selectors match. +# +# It exists because on this machine `xcodebuild` cannot build for iOS at all -- Xcode 26.6 reports +# "iOS 26.5 is not installed" and offers zero iOS destinations for *any* project, including the +# Pods project's own targets. That is a missing Xcode platform component, not a project defect, and +# it is not fixable without a multi-gigabyte download onto an internal disk that does not have the +# room. See COMPARISON.md, "What is blocked". +# +# ./check-ios-module.sh -> compiles Bench.mm, prints the object file's size, exit 0 on success +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/env.sh" + +IOS="$HERE/RNBench/ios" +PODS="$IOS/Pods" +SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)" +OUT="$HERE/build-check" +mkdir -p "$OUT" + +# The same search paths CocoaPods generated for this pod target, taken from its xcconfig rather +# than guessed, plus the generated-code directory the app target adds. +INCLUDES=() +INCLUDES+=(-I "$PODS/Headers/Private") +INCLUDES+=(-I "$PODS/Headers/Private/react-native-bench") +INCLUDES+=(-I "$PODS/Headers/Public") +INCLUDES+=(-I "$PODS/React-Core-prebuilt/Headers") +INCLUDES+=(-I "$PODS/ReactNativeDependencies/Headers") +INCLUDES+=(-I "$IOS/build/generated/ios") +while IFS= read -r d; do + INCLUDES+=(-I "$d") +done < <(find "$PODS/Headers/Public" -maxdepth 1 -type d | tail -n +2) + +# `#import <React/RCTBridgeModule.h>` resolves through a framework search path, not a header one: +# React ships as a prebuilt xcframework in RN 0.87, so the simulator slice has to be named. +FRAMEWORKS=() +FRAMEWORKS+=(-F "$PODS/React-Core-prebuilt/React.xcframework/ios-arm64_x86_64-simulator") +for x in "$PODS"/ReactNativeDependencies/framework/packages/react-native/*.xcframework; do + [ -d "$x/ios-arm64_x86_64-simulator" ] && FRAMEWORKS+=(-F "$x/ios-arm64_x86_64-simulator") +done +for x in "$PODS"/hermes-engine/destroot/Library/Frameworks/universal/*.xcframework; do + [ -d "$x/ios-arm64_x86_64-simulator" ] && FRAMEWORKS+=(-F "$x/ios-arm64_x86_64-simulator") +done + +exec xcrun clang \ + -c "$HERE/RNBench/modules/bench/ios/Bench.mm" \ + -o "$OUT/Bench.o" \ + -isysroot "$SDK" \ + "${FRAMEWORKS[@]}" \ + -target arm64-apple-ios15.1-simulator \ + -std=c++20 \ + -fobjc-arc \ + -DRCT_NEW_ARCH_ENABLED=1 \ + -DCOCOAPODS=1 \ + -Wno-nullability-completeness \ + -Werror=protocol \ + -Werror=incomplete-implementation \ + "${INCLUDES[@]}" diff --git a/benchmarks/rn-benchmark/env.sh b/benchmarks/rn-benchmark/env.sh new file mode 100644 index 000000000..d955cbb29 --- /dev/null +++ b/benchmarks/rn-benchmark/env.sh @@ -0,0 +1,34 @@ +# Source this before any node/npm/pod command in this project. +# +# No path outside this repository is hard-coded here. Toolchains that live elsewhere on a given +# machine are named through environment variables; caches this benchmark creates itself go under +# `.caches/` next to this file (git-ignored). +_RN_BENCH_HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + +# Node: set RN_BENCH_NODE_HOME to a Node install (e.g. emsdk's bundled node) when the one on PATH +# is not the one to use. +if [ -n "${RN_BENCH_NODE_HOME:-}" ]; then + export NODE_HOME="$RN_BENCH_NODE_HOME" + export PATH="$NODE_HOME/bin:$PATH" +fi +export npm_config_cache="${npm_config_cache:-$_RN_BENCH_HERE/.caches/npm}" + +export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" +export ANDROID_SDK_ROOT="$ANDROID_HOME" +export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" + +# --- Ruby / CocoaPods ------------------------------------------------------------------------- +# On the machine this was first run on, the system rbenv Ruby 3.2.2 was unusable: it had been +# installed under a previous account name and every Mach-O in it, plus every shebang, pointed at the +# old home directory, so `pod` failed with "bad interpreter". The fix was a relocated copy with its +# install names and shebangs repaired, and RUBYLIB set because the interpreter's compiled-in default +# load path still pointed at the old prefix. Point RN_BENCH_RUBY_HOME at such a copy if you need it. +export GEM_HOME="${GEM_HOME:-$_RN_BENCH_HERE/.caches/gems}" +if [ -n "${RN_BENCH_RUBY_HOME:-}" ]; then + export RUBY_HOME="$RN_BENCH_RUBY_HOME" + export RUBYLIB="$RUBY_HOME/lib/ruby/3.2.0:$RUBY_HOME/lib/ruby/3.2.0/arm64-darwin23" + export GEM_PATH="$GEM_HOME:$RUBY_HOME/lib/ruby/gems/3.2.0" + export PATH="$RUBY_HOME/bin:$PATH" +fi +export PATH="$GEM_HOME/bin:$PATH" +unset _RN_BENCH_HERE diff --git a/benchmarks/rn-benchmark/results/android-20260814-160244.txt b/benchmarks/rn-benchmark/results/android-20260814-160244.txt new file mode 100644 index 000000000..4f830b26d --- /dev/null +++ b/benchmarks/rn-benchmark/results/android-20260814-160244.txt @@ -0,0 +1,21 @@ +=== conditions === +platform: android +timestamp: 2026-08-14T16:02:45+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 15.68 17.80 16.83 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: df3f74f34aff (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ 73c40da4 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold + +=== NOT RUN === +SKIPPED: more than one device is attached and ANDROID_SERIAL is not set. +Attached: emulator-5554 emulator-5556 +The app id (com.rnbench) is the same on every device, so a multi-device run would install over +itself. Re-run with the one you may use, e.g.: + ANDROID_SERIAL=emulator-5554 ./run-bench.sh android +On this machine the emulators are shared, so setting it is your assertion that that one is free. + +No figures were produced. Do not treat any cell this run would have filled as measured. diff --git a/benchmarks/rn-benchmark/results/android-20260814-203238.txt b/benchmarks/rn-benchmark/results/android-20260814-203238.txt new file mode 100644 index 000000000..e12f8f724 --- /dev/null +++ b/benchmarks/rn-benchmark/results/android-20260814-203238.txt @@ -0,0 +1,133 @@ +=== conditions === +platform: android +timestamp: 2026-08-14T20:32:58+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 3.17 4.58 4.36 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 0c397d62e51c (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ 5ff77ff4 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +device: emulator-5554 Android SDK built for arm64 / API 26 / arm64-v8a / build OSR1.180418.028.A3 +apk: /Volumes/macMini/rn-benchmark/RNBench/android/app/build/outputs/apk/release/app-release.apk (52M) + +--- am start -W (cold launch of a just-installed APK) --- + Starting: Intent { cmp=com.rnbench/.MainActivity } + Status: ok + Activity: com.rnbench/.MainActivity + ThisTime: 447 + TotalTime: 447 + WaitTime: 471 + Complete + +# sweep: JS-only row, cold +# sweep: boundary row addInts, cold +# js row: empty JS loop (the loop itself) +# js row: JS call, same shape as addInts +# js row: JS call returning a string arg +# js row: JS sum of an 8-element array +# js row: JS sum of a 1000-element array +# js row: JS sum of a 3-key object +# js row: JS string of 128 chars, built per call +# boundary row: zeroArgs() -- boundary, no arguments +# boundary row: addInts(3, 4) -- two numbers +# boundary row: stringLength(8 chars) +# boundary row: stringLength(128 chars) +# boundary row: echoString(8 chars) -- 2 conversions +# boundary row: echoString(128 chars) -- 2 conversions +# boundary row: sumArray(8 elements) +# boundary row: sumArray(1000 elements) +# boundary row: sumObject(3 keys) +# boundary row: noop() -- void, ASYNC DISPATCH, not a crossing +# async round-trip rows + +=== RN boundary benchmark: on-device === +engine: Hermes OSS Release Version=250829098.0.16, Build=Release, Bytecode Version=98 +clock: performance.now +warmup: 100000 calls per row, iterations per timed loop: 10000 +reps: 5 per row, min-max reported, nothing discarded +module: present + +--- convergence sweep: "JS call, same shape as addInts" (no boundary: Hermes only) --- + 40 repetitions of 10000 calls, no warmup before the first + 11.03 10.65 33.23 22.33 82.24 10.65 34.70 11.44 + 10.79 10.65 33.02 10.70 10.65 10.65 10.64 10.63 + 10.62 10.44 10.69 10.75 10.64 10.63 10.81 10.65 + 10.63 10.66 10.64 10.63 10.64 10.64 10.63 10.64 + 10.64 10.64 10.63 10.64 10.63 10.63 10.63 10.63 + + plateau (mean of 2nd half): 10.65 ns/op + first repetition: 11.03 ns/op (1.04x the plateau) + flat (within +/-5%) from: 110000 calls + margin at a warmup of 100000: 0.91x <-- THIN. A slower or busier host would read this row unsettled. + +--- convergence sweep: addInts(3, 4) through the TurboModule (Hermes -> JSI -> JNI -> ART) --- + 40 repetitions of 10000 calls, no warmup before the first + 806.35 244.90 239.72 251.26 341.37 239.07 240.49 234.65 + 239.44 566.00 236.25 234.72 334.88 315.97 233.91 236.12 + 299.06 234.46 483.80 312.18 234.41 236.30 239.79 243.84 + 489.82 235.20 700.12 234.39 665.17 290.50 235.59 238.60 + 234.35 275.66 261.21 244.52 246.53 235.99 384.13 237.43 + + plateau (mean of 2nd half): 308.18 ns/op + first repetition: 806.35 ns/op (2.62x the plateau) + NEVER SETTLED within +/-5% over 40 repetitions. This sweep does not license any warmup count; lengthen it. + + Read the two sweeps together. The JS one prices Hermes, which has no JIT and is expected + to be flat from the first repetition. The boundary one prices Hermes plus a callee + running as Kotlin on ART. + ART does tier up -- so if either sweep justifies the 100000 warmup, it is this + one, and if this one shows a thin margin the warmup must be raised. + +row | min ns/op | max ns/op | reps +--------------------------------------------------------------------------------- +empty JS loop (the loop itself) | 11.07 | 11.41 | 5 +JS call, same shape as addInts | 10.83 | 11.09 | 5 +JS call returning a string arg | 13.55 | 70.64 | 5 +JS sum of an 8-element array | 72.92 | 74.29 | 5 +JS sum of a 1000-element array | 6518.46 | 6867.72 | 5 +JS sum of a 3-key object | 19.88 | 20.77 | 5 +JS string of 128 chars, built per call | 60.66 | 64.50 | 5 +zeroArgs() -- boundary, no arguments | 138.62 | 144.31 | 5 +addInts(3, 4) -- two numbers | 227.95 | 238.29 | 5 +stringLength(8 chars) | 297.04 | 345.11 | 5 +stringLength(128 chars) | 694.65 | 753.10 | 5 +echoString(8 chars) -- 2 conversions | 370.06 | 398.55 | 5 +echoString(128 chars) -- 2 conversions | 873.84 | 928.01 | 5 +sumArray(8 elements) | 2412.43 | 2438.92 | 5 +sumArray(1000 elements) | 175300.75 | 185630.65 | 5 +sumObject(3 keys) | 2879.44 | 2921.70 | 5 +noop() -- void, ASYNC DISPATCH, not a crossing | 2410.39 | 3625.65 | 5 +addInts' body, native loop, no crossing | 0.97 | 4.34 | 5 + +sink (proves no row was eliminated): 1 + +--- native -> JS, ROUND TRIPS (scheduler hop included; not comparable with the rows above) --- +row | min ns/op | max ns/op | reps +------------------------------------------------------------------------ +native -> JS callback round trip | 13395.92 | 389646.46 | 5 +native -> JS promise round trip | 27520.46 | 304885.25 | 5 +bare JS Promise.resolve await (floor) | 724.17 | 3968.33 | 5 + +--- ratios, every term from this run --- + addInts / a JS call of the same shape: 21.05x + addInts / zeroArgs (what two numbers cost): 1.64x + sumArray(1000) / sumArray(8): 72.67x + echoString(128) / echoString(8): 2.36x + +--- warmup verdict, from this run's own sweeps --- + JS-only (Hermes): settles at 110000 calls, so warmup 100000 has only 0.91x margin -- TOO THIN. Raise it. + boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup 100000 is not justified by this run. + + ==> AT LEAST ONE SWEEP SAYS THIS WARMUP IS INSUFFICIENT. + THE BOUNDARY ROWS ARE NOT FIGURES. They were read before the boundary settled; + that is every row that crosses into native, which is the point of this benchmark. + THE JS-ONLY BASELINE IS NOT SETTLED. That indicts the JS rows and every ratio + printed above, since those divide by one -- but NOT the absolute boundary + figures, which do not depend on it. + Raise WARMUP in bench/appRun.js and re-run, or re-run on a quieter machine. + +all 21 rows returned a positive duration + +END diff --git a/benchmarks/rn-benchmark/results/android-20260818-195228.txt b/benchmarks/rn-benchmark/results/android-20260818-195228.txt new file mode 100644 index 000000000..c47d69f5f --- /dev/null +++ b/benchmarks/rn-benchmark/results/android-20260818-195228.txt @@ -0,0 +1,133 @@ +=== conditions === +platform: android +timestamp: 2026-08-18T19:52:42+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 3.10 3.45 3.74 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 0c397d62e51c (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ cbdf1ce0 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +device: emulator-5554 Android SDK built for arm64 / API 26 / arm64-v8a / build OSR1.180418.028.A3 +apk: /Volumes/macMini/rn-benchmark/RNBench/android/app/build/outputs/apk/release/app-release.apk (52M) + +--- am start -W (cold launch of a just-installed APK) --- + Starting: Intent { cmp=com.rnbench/.MainActivity } + Status: ok + Activity: com.rnbench/.MainActivity + ThisTime: 425 + TotalTime: 425 + WaitTime: 450 + Complete + +# sweep: JS-only row, cold +# sweep: boundary row addInts, cold +# js row: empty JS loop (the loop itself) +# js row: JS call, same shape as addInts +# js row: JS call returning a string arg +# js row: JS sum of an 8-element array +# js row: JS sum of a 1000-element array +# js row: JS sum of a 3-key object +# js row: JS string of 128 chars, built per call +# boundary row: zeroArgs() -- boundary, no arguments +# boundary row: addInts(3, 4) -- two numbers +# boundary row: stringLength(8 chars) +# boundary row: stringLength(128 chars) +# boundary row: echoString(8 chars) -- 2 conversions +# boundary row: echoString(128 chars) -- 2 conversions +# boundary row: sumArray(8 elements) +# boundary row: sumArray(1000 elements) +# boundary row: sumObject(3 keys) +# boundary row: noop() -- void, ASYNC DISPATCH, not a crossing +# async round-trip rows + +=== RN boundary benchmark: on-device === +engine: Hermes OSS Release Version=250829098.0.16, Build=Release, Bytecode Version=98 +clock: performance.now +warmup: 100000 calls per row, iterations per timed loop: 10000 +reps: 5 per row, min-max reported, nothing discarded +module: present + +--- convergence sweep: "JS call, same shape as addInts" (no boundary: Hermes only) --- + 40 repetitions of 10000 calls, no warmup before the first + 10.93 10.65 11.51 10.63 10.68 10.63 10.64 10.99 + 10.63 10.87 11.08 10.63 10.70 10.64 10.64 10.71 + 10.63 11.81 10.79 10.65 10.64 10.64 10.75 10.98 + 10.73 10.77 10.64 10.63 10.66 10.78 10.92 10.82 + 10.75 10.75 11.05 10.75 10.75 10.76 10.75 10.75 + + plateau (mean of 2nd half): 10.76 ns/op + first repetition: 10.93 ns/op (1.02x the plateau) + flat (within +/-5%) from: 180000 calls + margin at a warmup of 100000: 0.56x <-- THIN. A slower or busier host would read this row unsettled. + +--- convergence sweep: addInts(3, 4) through the TurboModule (Hermes -> JSI -> JNI -> ART) --- + 40 repetitions of 10000 calls, no warmup before the first + 306.25 268.75 253.77 258.75 317.79 258.75 351.10 269.10 + 262.68 270.32 261.55 297.71 296.64 258.17 267.65 254.93 + 267.17 380.96 757.23 253.10 261.35 422.73 265.99 250.08 + 257.62 246.67 397.79 252.99 806.08 416.57 255.71 349.16 + 265.53 255.43 387.74 263.53 426.16 342.52 600.82 379.34 + + plateau (mean of 2nd half): 355.19 ns/op + first repetition: 306.25 ns/op (0.86x the plateau) + NEVER SETTLED within +/-5% over 40 repetitions. This sweep does not license any warmup count; lengthen it. + + Read the two sweeps together. The JS one prices Hermes, which has no JIT and is expected + to be flat from the first repetition. The boundary one prices Hermes plus a callee + running as Kotlin on ART. + ART does tier up -- so if either sweep justifies the 100000 warmup, it is this + one, and if this one shows a thin margin the warmup must be raised. + +row | min ns/op | max ns/op | reps +--------------------------------------------------------------------------------- +empty JS loop (the loop itself) | 11.05 | 139.47 | 5 +JS call, same shape as addInts | 10.79 | 10.88 | 5 +JS call returning a string arg | 13.32 | 13.96 | 5 +JS sum of an 8-element array | 73.44 | 103.83 | 5 +JS sum of a 1000-element array | 6478.42 | 6527.94 | 5 +JS sum of a 3-key object | 20.05 | 20.08 | 5 +JS string of 128 chars, built per call | 60.76 | 69.91 | 5 +zeroArgs() -- boundary, no arguments | 155.77 | 160.64 | 5 +addInts(3, 4) -- two numbers | 240.84 | 245.20 | 5 +stringLength(8 chars) | 299.24 | 354.32 | 5 +stringLength(128 chars) | 682.42 | 824.19 | 5 +echoString(8 chars) -- 2 conversions | 381.10 | 441.52 | 5 +echoString(128 chars) -- 2 conversions | 905.85 | 960.66 | 5 +sumArray(8 elements) | 2398.13 | 2640.75 | 5 +sumArray(1000 elements) | 173180.10 | 176542.66 | 5 +sumObject(3 keys) | 2841.04 | 2906.25 | 5 +noop() -- void, ASYNC DISPATCH, not a crossing | 2675.57 | 4544.12 | 5 +addInts' body, native loop, no crossing | 0.95 | 4.56 | 5 + +sink (proves no row was eliminated): 1 + +--- native -> JS, ROUND TRIPS (scheduler hop included; not comparable with the rows above) --- +row | min ns/op | max ns/op | reps +------------------------------------------------------------------------ +native -> JS callback round trip | 16125.79 | 348194.08 | 5 +native -> JS promise round trip | 25880.54 | 311699.17 | 5 +bare JS Promise.resolve await (floor) | 676.33 | 1585.38 | 5 + +--- ratios, every term from this run --- + addInts / a JS call of the same shape: 22.33x + addInts / zeroArgs (what two numbers cost): 1.55x + sumArray(1000) / sumArray(8): 72.21x + echoString(128) / echoString(8): 2.38x + +--- warmup verdict, from this run's own sweeps --- + JS-only (Hermes): settles at 180000 calls, so warmup 100000 has only 0.56x margin -- TOO THIN. Raise it. + boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup 100000 is not justified by this run. + + ==> AT LEAST ONE SWEEP SAYS THIS WARMUP IS INSUFFICIENT. + THE BOUNDARY ROWS ARE NOT FIGURES. They were read before the boundary settled; + that is every row that crosses into native, which is the point of this benchmark. + THE JS-ONLY BASELINE IS NOT SETTLED. That indicts the JS rows and every ratio + printed above, since those divide by one -- but NOT the absolute boundary + figures, which do not depend on it. + Raise WARMUP in bench/appRun.js and re-run, or re-run on a quieter machine. + +all 21 rows returned a positive duration + +END diff --git a/benchmarks/rn-benchmark/results/android-20260819-005022.txt b/benchmarks/rn-benchmark/results/android-20260819-005022.txt new file mode 100644 index 000000000..25b0d0f90 --- /dev/null +++ b/benchmarks/rn-benchmark/results/android-20260819-005022.txt @@ -0,0 +1,130 @@ +=== conditions === +platform: android +timestamp: 2026-08-19T00:50:41+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 2.97 1.85 1.63 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 2e634d2509a8 (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ cbdf1ce0 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +device: emulator-5554 Android SDK built for arm64 / API 26 / arm64-v8a / build OSR1.180418.028.A3 +apk: /Volumes/macMini/rn-benchmark/RNBench/android/app/build/outputs/apk/release/app-release.apk (52M) + +--- am start -W (cold launch of a just-installed APK) --- + Starting: Intent { cmp=com.rnbench/.MainActivity } + Status: ok + Activity: com.rnbench/.MainActivity + ThisTime: 298 + TotalTime: 298 + WaitTime: 306 + Complete + +# sweep: JS-only row, cold +# sweep: boundary row addInts, cold +# js row: empty JS loop (the loop itself) +# js row: JS call, same shape as addInts +# js row: JS call returning a string arg +# js row: JS sum of an 8-element array +# js row: JS sum of a 1000-element array +# js row: JS sum of a 3-key object +# js row: JS string of 128 chars, built per call +# boundary row: zeroArgs() -- boundary, no arguments +# boundary row: addInts(3, 4) -- two numbers +# boundary row: stringLength(8 chars) +# boundary row: stringLength(128 chars) +# boundary row: echoString(8 chars) -- 2 conversions +# boundary row: echoString(128 chars) -- 2 conversions +# boundary row: sumArray(8 elements) +# boundary row: sumArray(1000 elements) +# boundary row: sumObject(3 keys) +# boundary row: noop() -- void, ASYNC DISPATCH, not a crossing +# async round-trip rows + +=== RN boundary benchmark: on-device === +engine: Hermes OSS Release Version=250829098.0.16, Build=Release, Bytecode Version=98 +clock: performance.now +warmup: 400000 calls per row, iterations per timed loop: 10000 +reps: 5 per row, min-max reported, nothing discarded +module: present + +--- convergence sweep: "JS call, same shape as addInts" (no boundary: Hermes only) --- + 40 repetitions of 10000 calls, no warmup before the first + 10.85 10.65 10.77 10.63 10.78 10.63 10.78 10.76 + 10.87 10.64 10.64 10.83 10.63 10.79 10.67 10.68 + 10.70 10.71 10.70 10.70 10.72 10.74 10.67 10.71 + 10.72 10.72 10.71 10.72 10.72 10.67 10.63 10.63 + 10.70 10.71 10.71 10.70 10.72 10.71 10.71 10.71 + + plateau (mean of 2nd half): 10.70 ns/op + first repetition: 10.85 ns/op (1.01x the plateau) + flat (within +/-5%) from: 0 calls + margin at a warmup of 400000: Infinityx + +--- convergence sweep: addInts(3, 4) through the TurboModule (Hermes -> JSI -> JNI -> ART) --- + 40 repetitions of 10000 calls, no warmup before the first + 302.88 237.24 238.69 237.07 243.11 253.94 237.68 237.75 + 238.69 287.99 288.21 241.27 258.90 241.06 301.00 238.93 + 243.51 241.39 243.29 235.21 244.18 238.86 238.49 239.76 + 237.44 251.37 242.53 237.14 238.93 333.31 283.42 477.69 + 237.14 238.75 236.72 239.38 851.88 480.30 1625.86 456.82 + + plateau (mean of 2nd half): 381.50 ns/op + first repetition: 302.88 ns/op (0.79x the plateau) + NEVER SETTLED within +/-5% over 40 repetitions. This sweep does not license any warmup count; lengthen it. + + Read the two sweeps together. The JS one prices Hermes, which has no JIT and is expected + to be flat from the first repetition. The boundary one prices Hermes plus a callee + running as Kotlin on ART. + ART does tier up -- so if either sweep justifies the 400000 warmup, it is this + one, and if this one shows a thin margin the warmup must be raised. + +row | min ns/op | max ns/op | reps +--------------------------------------------------------------------------------- +empty JS loop (the loop itself) | 10.82 | 11.41 | 5 +JS call, same shape as addInts | 10.33 | 46.60 | 5 +JS call returning a string arg | 13.45 | 13.68 | 5 +JS sum of an 8-element array | 69.93 | 73.57 | 5 +JS sum of a 1000-element array | 6399.58 | 6508.38 | 5 +JS sum of a 3-key object | 19.52 | 19.98 | 5 +JS string of 128 chars, built per call | 57.65 | 65.45 | 5 +zeroArgs() -- boundary, no arguments | 139.15 | 144.13 | 5 +addInts(3, 4) -- two numbers | 223.40 | 232.32 | 5 +stringLength(8 chars) | 291.37 | 302.13 | 5 +stringLength(128 chars) | 677.40 | 736.55 | 5 +echoString(8 chars) -- 2 conversions | 366.69 | 424.00 | 5 +echoString(128 chars) -- 2 conversions | 867.99 | 914.91 | 5 +sumArray(8 elements) | 2290.94 | 2432.86 | 5 +sumArray(1000 elements) | 173913.23 | 176487.01 | 5 +sumObject(3 keys) | 2785.25 | 2869.74 | 5 +noop() -- void, ASYNC DISPATCH, not a crossing | 2423.50 | 3479.96 | 5 +addInts' body, native loop, no crossing | 0.95 | 4.32 | 5 + +sink (proves no row was eliminated): 1 + +--- native -> JS, ROUND TRIPS (scheduler hop included; not comparable with the rows above) --- +row | min ns/op | max ns/op | reps +------------------------------------------------------------------------ +native -> JS callback round trip | 328091.08 | 373845.04 | 5 +native -> JS promise round trip | 339718.46 | 424759.04 | 5 +bare JS Promise.resolve await (floor) | 720.25 | 1066.04 | 5 + +--- ratios, every term from this run --- + addInts / a JS call of the same shape: 21.63x + addInts / zeroArgs (what two numbers cost): 1.61x + sumArray(1000) / sumArray(8): 75.91x + echoString(128) / echoString(8): 2.37x + +--- warmup verdict, from this run's own sweeps --- + JS-only (Hermes): settles at 0 calls; warmup 400000 has Infinityx margin. Adequate. + boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup 400000 is not justified by this run. + + ==> AT LEAST ONE SWEEP SAYS THIS WARMUP IS INSUFFICIENT. + THE BOUNDARY ROWS ARE NOT FIGURES. They were read before the boundary settled; + that is every row that crosses into native, which is the point of this benchmark. + Raise WARMUP in bench/appRun.js and re-run, or re-run on a quieter machine. + +all 21 rows returned a positive duration + +END diff --git a/benchmarks/rn-benchmark/results/android-20260819-095448.txt b/benchmarks/rn-benchmark/results/android-20260819-095448.txt new file mode 100644 index 000000000..e574add29 --- /dev/null +++ b/benchmarks/rn-benchmark/results/android-20260819-095448.txt @@ -0,0 +1,154 @@ +=== conditions === +platform: android +timestamp: 2026-08-19T09:55:11+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 3.52 2.31 2.48 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 0c397d62e51c (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ 82e78251 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +device: emulator-5554 Android SDK built for arm64 / API 26 / arm64-v8a / build OSR1.180418.028.A3 +apk: /Volumes/macMini/rn-benchmark/RNBench/android/app/build/outputs/apk/release/app-release.apk (52M) + +--- am start -W (cold launch of a just-installed APK) --- + Starting: Intent { cmp=com.rnbench/.MainActivity } + Status: ok + Activity: com.rnbench/.MainActivity + ThisTime: 557 + TotalTime: 557 + WaitTime: 570 + Complete + +# sweep: JS-only row, cold +# sweep: boundary row addInts, cold +# js row: empty JS loop (the loop itself) +# js row: JS call, same shape as addInts +# js row: JS call returning a string arg +# js row: JS sum of an 8-element array +# js row: JS sum of a 1000-element array +# js row: JS sum of a 3-key object +# js row: JS string of 128 chars, built per call +# boundary row: zeroArgs() -- boundary, no arguments +# boundary row: addInts(3, 4) -- two numbers +# boundary row: stringLength(8 chars) +# boundary row: stringLength(128 chars) +# boundary row: echoString(8 chars) -- 2 conversions +# boundary row: echoString(128 chars) -- 2 conversions +# boundary row: sumArray(8 elements) +# boundary row: sumArray(1000 elements) +# boundary row: sumObject(3 keys) +# boundary row: noop() -- void, ASYNC DISPATCH, not a crossing +# async round-trip rows + +=== RN boundary benchmark: on-device === +engine: Hermes OSS Release Version=250829098.0.16, Build=Release, Bytecode Version=98 +clock: performance.now +warmup: 100000 calls per row, iterations per timed loop: 10000 +reps: 5 per row, min-max reported, nothing discarded +module: present + +--- convergence sweep: "JS call, same shape as addInts" (no boundary: Hermes only) --- + 40 repetitions of 10000 calls, no warmup before the first + 33.41 10.65 10.66 10.64 10.65 11.10 10.71 10.71 + 10.70 10.95 10.71 10.70 10.71 10.72 21.85 10.80 + 10.73 10.82 10.63 10.63 10.77 10.75 10.63 10.65 + 10.65 10.28 10.33 10.27 10.26 10.27 10.27 10.25 + 10.29 10.26 10.26 10.27 10.27 10.37 10.63 10.63 + + plateau (mean of 2nd half): 10.42 ns/op + first repetition: 33.41 ns/op (3.21x the plateau) + flat (within +/-5%) from: 150000 calls + margin at a warmup of 100000: 0.67x <-- THIN. A slower or busier host would read this row unsettled. + +--- convergence sweep: addInts(3, 4) through the TurboModule (Hermes -> JSI -> JNI -> ART) --- + 40 repetitions of 10000 calls, no warmup before the first + 385.55 436.30 362.05 265.98 363.25 281.93 264.52 258.45 + 259.17 596.04 791.50 587.46 380.43 269.93 267.88 516.45 + 411.90 304.20 308.61 296.08 258.07 261.68 258.85 831.89 + 259.69 276.67 290.03 265.77 259.08 266.62 266.51 429.78 + 304.54 263.67 609.67 612.02 317.61 454.68 260.67 258.19 + + plateau (mean of 2nd half): 350.28 ns/op + first repetition: 385.55 ns/op (1.10x the plateau) + NEVER SETTLED within +/-5% over 40 repetitions. This sweep does not license any warmup count; lengthen it. + + Read the two sweeps together. The JS one prices Hermes, which has no JIT and is expected + to be flat from the first repetition. The boundary one prices Hermes plus a callee + running as Kotlin on ART. + ART does tier up -- so if either sweep justifies the 100000 warmup, it is this + one, and if this one shows a thin margin the warmup must be raised. + +row | min ns/op | max ns/op | reps +--------------------------------------------------------------------------------- +empty JS loop (the loop itself) | 10.68 | 31.95 | 5 +JS call, same shape as addInts | 10.84 | 10.94 | 5 +JS call returning a string arg | 13.54 | 17.71 | 5 +JS sum of an 8-element array | 73.12 | 197.00 | 5 +JS sum of a 1000-element array | 6544.72 | 10896.60 | 5 +JS sum of a 3-key object | 19.98 | 20.20 | 5 +JS string of 128 chars, built per call | 60.86 | 64.23 | 5 +zeroArgs() -- boundary, no arguments | 154.75 | 169.86 | 5 +addInts(3, 4) -- two numbers | 247.24 | 252.28 | 5 +stringLength(8 chars) | 314.08 | 328.04 | 5 +stringLength(128 chars) | 724.98 | 1084.34 | 5 +echoString(8 chars) -- 2 conversions | 378.71 | 399.79 | 5 +echoString(128 chars) -- 2 conversions | 907.00 | 1076.01 | 5 +sumArray(8 elements) | 2385.95 | 2493.87 | 5 +sumArray(1000 elements) | 177121.46 | 180255.80 | 5 +sumObject(3 keys) | 2955.39 | 3294.33 | 5 +noop() -- void, ASYNC DISPATCH, not a crossing | 1244.79 | 3918.97 | 5 +addInts' body, native loop, no crossing | 0.95 | 4.32 | 5 + +sink (proves no row was eliminated): 1 + +--- native -> JS, ROUND TRIPS (scheduler hop included; not comparable with the rows above) --- +row | min ns/op | max ns/op | reps +------------------------------------------------------------------------ +native -> JS callback round trip | 13504.13 | 369610.67 | 5 +native -> JS promise round trip | 24017.63 | 90323.25 | 5 +bare JS Promise.resolve await (floor) | 724.67 | 6346.79 | 5 + +--- ratios, every term from this run --- + addInts / a JS call of the same shape: 22.81x + addInts / zeroArgs (what two numbers cost): 1.60x + sumArray(1000) / sumArray(8): 74.24x + echoString(128) / echoString(8): 2.39x + +--- warmup verdict, from this run's own sweeps --- + JS-only (Hermes): settles at 150000 calls, so warmup 100000 has only 0.67x margin -- TOO THIN. Raise it. + boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup 100000 is not justified by this run. + + ==> AT LEAST ONE SWEEP SAYS THIS WARMUP IS INSUFFICIENT. + THE BOUNDARY ROWS ARE NOT FIGURES. They were read before the boundary settled; + that is every row that crosses into native, which is the point of this benchmark. + THE JS-ONLY BASELINE IS NOT SETTLED. That indicts the JS rows and every ratio + printed above, since those divide by one -- but NOT the absolute boundary + figures, which do not depend on it. + Raise WARMUP in bench/appRun.js and re-run, or re-run on a quieter machine. + +all 21 rows returned a positive duration + +END + +load average after: 13.66 12.34 7.48 + +=== capture check === +benchmark rows captured: 21 +END marker seen: yes +warmup verdict: INSUFFICIENT (the app's own, from its convergence sweeps) + +*** NOT QUOTABLE AS A WHOLE: the run's own warmup verdict is INSUFFICIENT. *** + +21 rows were captured and the report is complete, but at least one convergence sweep +did not settle far enough before the warmup count. WHICH sweep decides which rows are +affected, and the report says which -- read the 'warmup verdict' block above rather +than discarding the file: + + - boundary sweep thin -> the rows that cross into native are not figures. + - JS-only sweep thin -> the JS baseline and the ratios are not figures; the + absolute boundary numbers do not depend on them. + +On this machine the usual cause is load, not a genuinely short warmup: check the load +average at the top of this file, and re-run quiet before changing WARMUP. diff --git a/benchmarks/rn-benchmark/results/android-20260820-074407.txt b/benchmarks/rn-benchmark/results/android-20260820-074407.txt new file mode 100644 index 000000000..af7de434d --- /dev/null +++ b/benchmarks/rn-benchmark/results/android-20260820-074407.txt @@ -0,0 +1,139 @@ +=== conditions === +platform: android +timestamp: 2026-08-20T07:44:29+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 4.45 2.20 2.10 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 669ba30ccf61 (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ 41d0760f +counts: warmup 400000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +device: emulator-5554 Android SDK built for arm64 / API 26 / arm64-v8a / build OSR1.180418.028.A3 +apk: /Volumes/macMini/rn-benchmark/RNBench/android/app/build/outputs/apk/release/app-release.apk (52M) + +********************************************************************************* +* WARNING: 1-minute load average is 4.45, above 4.0. +* Figures from this run are NOT quotable. On this machine a single commit has read +* 672 and 1076 ns for the same row under load. Re-run on a quiet machine before +* putting anything from this file in a table. +********************************************************************************* + +--- am start -W (cold launch of a just-installed APK) --- + Starting: Intent { cmp=com.rnbench/.MainActivity } + Status: ok + Activity: com.rnbench/.MainActivity + ThisTime: 414 + TotalTime: 414 + WaitTime: 428 + Complete + +# sweep: JS-only row, cold +# sweep: boundary row addInts, cold +# js row: empty JS loop (the loop itself) +# js row: JS call, same shape as addInts +# js row: JS call returning a string arg +# js row: JS sum of an 8-element array +# js row: JS sum of a 1000-element array +# js row: JS sum of a 3-key object +# js row: JS string of 128 chars, built per call +# boundary row: zeroArgs() -- boundary, no arguments +# boundary row: addInts(3, 4) -- two numbers +# boundary row: stringLength(8 chars) +# boundary row: stringLength(128 chars) +# boundary row: echoString(8 chars) -- 2 conversions +# boundary row: echoString(128 chars) -- 2 conversions +# boundary row: sumArray(8 elements) +# boundary row: sumArray(1000 elements) +# boundary row: sumObject(3 keys) +# boundary row: noop() -- void, ASYNC DISPATCH, not a crossing +# async round-trip rows + +=== RN boundary benchmark: on-device === +engine: Hermes OSS Release Version=250829098.0.16, Build=Release, Bytecode Version=98 +clock: performance.now +warmup: 400000 calls per row, iterations per timed loop: 10000 +reps: 5 per row, min-max reported, nothing discarded +module: present + +--- convergence sweep: "JS call, same shape as addInts" (no boundary: Hermes only) --- + 40 repetitions of 10000 calls, no warmup before the first + 10.69 10.63 10.66 10.63 10.83 10.64 10.63 10.64 + 10.63 10.63 10.74 10.63 10.63 10.63 10.65 10.64 + 10.64 10.65 10.64 10.63 10.68 10.65 10.63 39.68 + 11.67 12.07 10.89 10.84 11.11 11.03 11.08 11.12 + 11.05 10.90 11.10 10.82 11.03 10.75 11.11 11.05 + + plateau (mean of 2nd half): 12.46 ns/op + first repetition: 10.69 ns/op (0.86x the plateau) + NEVER SETTLED within +/-5% over 40 repetitions. This sweep does not license any warmup count; lengthen it. + +--- convergence sweep: addInts(3, 4) through the TurboModule (Hermes -> JSI -> JNI -> ART) --- + 40 repetitions of 10000 calls, no warmup before the first + 257.22 303.76 533.18 233.54 272.45 236.33 926.88 438.16 + 252.30 235.88 330.98 776.92 519.16 443.94 1510.62 792.21 + 521.22 255.86 236.25 315.43 241.34 377.36 448.88 236.50 + 236.68 371.58 410.53 235.74 324.92 533.04 788.39 323.37 + 237.75 250.25 271.68 308.52 464.95 427.59 245.91 235.65 + + plateau (mean of 2nd half): 348.53 ns/op + first repetition: 257.22 ns/op (0.74x the plateau) + NEVER SETTLED within +/-5% over 40 repetitions. This sweep does not license any warmup count; lengthen it. + + Read the two sweeps together. The JS one prices Hermes, which has no JIT and is expected + to be flat from the first repetition. The boundary one prices Hermes plus a callee + running as Kotlin on ART. + ART does tier up -- so if either sweep justifies the 400000 warmup, it is this + one, and if this one shows a thin margin the warmup must be raised. + +row | min ns/op | max ns/op | reps +--------------------------------------------------------------------------------- +empty JS loop (the loop itself) | 11.15 | 11.43 | 5 +JS call, same shape as addInts | 10.80 | 11.00 | 5 +JS call returning a string arg | 13.57 | 13.63 | 5 +JS sum of an 8-element array | 69.48 | 72.99 | 5 +JS sum of a 1000-element array | 6493.75 | 6512.90 | 5 +JS sum of a 3-key object | 19.92 | 23.45 | 5 +JS string of 128 chars, built per call | 58.82 | 61.17 | 5 +zeroArgs() -- boundary, no arguments | 138.20 | 141.86 | 5 +addInts(3, 4) -- two numbers | 222.99 | 229.81 | 5 +stringLength(8 chars) | 283.05 | 297.12 | 5 +stringLength(128 chars) | 679.23 | 724.55 | 5 +echoString(8 chars) -- 2 conversions | 348.54 | 388.63 | 5 +echoString(128 chars) -- 2 conversions | 856.07 | 924.18 | 5 +sumArray(8 elements) | 2287.54 | 2455.00 | 5 +sumArray(1000 elements) | 174008.48 | 175011.97 | 5 +sumObject(3 keys) | 2746.70 | 2861.35 | 5 +noop() -- void, ASYNC DISPATCH, not a crossing | 3408.12 | 3843.24 | 5 +addInts' body, native loop, no crossing | 0.95 | 4.36 | 5 + +sink (proves no row was eliminated): 1 + +--- native -> JS, ROUND TRIPS (scheduler hop included; not comparable with the rows above) --- +row | min ns/op | max ns/op | reps +------------------------------------------------------------------------ +native -> JS callback round trip | 109067.54 | 392270.13 | 5 +native -> JS promise round trip | 15256.13 | 437037.79 | 5 +bare JS Promise.resolve await (floor) | 719.00 | 1305.71 | 5 + +--- ratios, every term from this run --- + addInts / a JS call of the same shape: 20.66x + addInts / zeroArgs (what two numbers cost): 1.61x + sumArray(1000) / sumArray(8): 76.07x + echoString(128) / echoString(8): 2.46x + +--- warmup verdict, from this run's own sweeps --- + JS-only (Hermes): NEVER SETTLED. Warmup 400000 is not justified by this run. + boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup 400000 is not justified by this run. + + ==> AT LEAST ONE SWEEP SAYS THIS WARMUP IS INSUFFICIENT. + THE BOUNDARY ROWS ARE NOT FIGURES. They were read before the boundary settled; + that is every row that crosses into native, which is the point of this benchmark. + THE JS-ONLY BASELINE IS NOT SETTLED. That indicts the JS rows and every ratio + printed above, since those divide by one -- but NOT the absolute boundary + figures, which do not depend on it. + Raise WARMUP in bench/appRun.js and re-run, or re-run on a quieter machine. + +all 21 rows returned a positive duration + +END diff --git a/benchmarks/rn-benchmark/results/android-20260823-112716.txt b/benchmarks/rn-benchmark/results/android-20260823-112716.txt new file mode 100644 index 000000000..9c6ed3b19 --- /dev/null +++ b/benchmarks/rn-benchmark/results/android-20260823-112716.txt @@ -0,0 +1,132 @@ +=== conditions === +platform: android +timestamp: 2026-08-23T11:27:31+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 2.71 1.85 1.68 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 669ba30ccf61 (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ 41d0760f +counts: warmup 400000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +device: emulator-5554 Android SDK built for arm64 / API 26 / arm64-v8a / build OSR1.180418.028.A3 +apk: /Volumes/macMini/rn-benchmark/RNBench/android/app/build/outputs/apk/release/app-release.apk (52M) + +--- am start -W (cold launch of a just-installed APK) --- + Starting: Intent { cmp=com.rnbench/.MainActivity } + Status: ok + Activity: com.rnbench/.MainActivity + ThisTime: 340 + TotalTime: 340 + WaitTime: 348 + Complete + +# sweep: JS-only row, cold +# sweep: boundary row addInts, cold +# js row: empty JS loop (the loop itself) +# js row: JS call, same shape as addInts +# js row: JS call returning a string arg +# js row: JS sum of an 8-element array +# js row: JS sum of a 1000-element array +# js row: JS sum of a 3-key object +# js row: JS string of 128 chars, built per call +# boundary row: zeroArgs() -- boundary, no arguments +# boundary row: addInts(3, 4) -- two numbers +# boundary row: stringLength(8 chars) +# boundary row: stringLength(128 chars) +# boundary row: echoString(8 chars) -- 2 conversions +# boundary row: echoString(128 chars) -- 2 conversions +# boundary row: sumArray(8 elements) +# boundary row: sumArray(1000 elements) +# boundary row: sumObject(3 keys) +# boundary row: noop() -- void, ASYNC DISPATCH, not a crossing +# async round-trip rows + +=== RN boundary benchmark: on-device === +engine: Hermes OSS Release Version=250829098.0.16, Build=Release, Bytecode Version=98 +clock: performance.now +warmup: 400000 calls per row, iterations per timed loop: 10000 +reps: 5 per row, min-max reported, nothing discarded +module: present + +--- convergence sweep: "JS call, same shape as addInts" (no boundary: Hermes only) --- + 40 repetitions of 10000 calls, no warmup before the first + 10.74 36.92 36.59 10.81 24.04 10.79 10.78 10.63 + 10.65 10.65 10.63 10.64 10.63 10.86 523.04 115.72 + 10.66 10.81 22.85 365.00 42.56 10.65 63.72 10.47 + 25.43 41.54 21.34 35.52 75.35 26.62 10.72 31.38 + 10.97 80.54 10.73 29.52 10.67 10.71 10.67 10.67 + + plateau (mean of 2nd half): 28.49 ns/op + first repetition: 10.74 ns/op (0.38x the plateau) + NEVER SETTLED within +/-5% over 40 repetitions. This sweep does not license any warmup count; lengthen it. + +--- convergence sweep: addInts(3, 4) through the TurboModule (Hermes -> JSI -> JNI -> ART) --- + 40 repetitions of 10000 calls, no warmup before the first + 756.00 237.73 240.87 349.10 235.98 500.76 364.52 236.13 + 272.23 301.04 236.08 236.12 288.60 503.47 265.00 885.05 + 240.74 236.10 249.97 527.62 322.47 252.07 1068.40 1080.88 + 1038.65 305.06 423.27 322.12 240.70 416.82 556.23 233.13 + 813.10 576.29 264.44 233.07 493.06 240.61 595.08 233.88 + + plateau (mean of 2nd half): 485.47 ns/op + first repetition: 756.00 ns/op (1.56x the plateau) + NEVER SETTLED within +/-5% over 40 repetitions. This sweep does not license any warmup count; lengthen it. + + Read the two sweeps together. The JS one prices Hermes, which has no JIT and is expected + to be flat from the first repetition. The boundary one prices Hermes plus a callee + running as Kotlin on ART. + ART does tier up -- so if either sweep justifies the 400000 warmup, it is this + one, and if this one shows a thin margin the warmup must be raised. + +row | min ns/op | max ns/op | reps +--------------------------------------------------------------------------------- +empty JS loop (the loop itself) | 11.12 | 11.84 | 5 +JS call, same shape as addInts | 10.80 | 10.94 | 5 +JS call returning a string arg | 13.57 | 13.78 | 5 +JS sum of an 8-element array | 71.93 | 79.17 | 5 +JS sum of a 1000-element array | 6384.15 | 6506.56 | 5 +JS sum of a 3-key object | 19.50 | 19.63 | 5 +JS string of 128 chars, built per call | 59.45 | 62.22 | 5 +zeroArgs() -- boundary, no arguments | 135.14 | 138.80 | 5 +addInts(3, 4) -- two numbers | 219.44 | 229.86 | 5 +stringLength(8 chars) | 278.25 | 318.82 | 5 +stringLength(128 chars) | 683.10 | 723.43 | 5 +echoString(8 chars) -- 2 conversions | 348.27 | 371.96 | 5 +echoString(128 chars) -- 2 conversions | 866.87 | 914.11 | 5 +sumArray(8 elements) | 2328.48 | 2372.03 | 5 +sumArray(1000 elements) | 174389.72 | 174868.40 | 5 +sumObject(3 keys) | 2735.30 | 2814.08 | 5 +noop() -- void, ASYNC DISPATCH, not a crossing | 3385.83 | 9519.85 | 5 +addInts' body, native loop, no crossing | 0.96 | 4.28 | 5 + +sink (proves no row was eliminated): 1 + +--- native -> JS, ROUND TRIPS (scheduler hop included; not comparable with the rows above) --- +row | min ns/op | max ns/op | reps +------------------------------------------------------------------------ +native -> JS callback round trip | 361045.29 | 395138.71 | 5 +native -> JS promise round trip | 24546.63 | 363063.04 | 5 +bare JS Promise.resolve await (floor) | 716.42 | 876.29 | 5 + +--- ratios, every term from this run --- + addInts / a JS call of the same shape: 20.32x + addInts / zeroArgs (what two numbers cost): 1.62x + sumArray(1000) / sumArray(8): 74.89x + echoString(128) / echoString(8): 2.49x + +--- warmup verdict, from this run's own sweeps --- + JS-only (Hermes): NEVER SETTLED. Warmup 400000 is not justified by this run. + boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup 400000 is not justified by this run. + + ==> AT LEAST ONE SWEEP SAYS THIS WARMUP IS INSUFFICIENT. + THE BOUNDARY ROWS ARE NOT FIGURES. They were read before the boundary settled; + that is every row that crosses into native, which is the point of this benchmark. + THE JS-ONLY BASELINE IS NOT SETTLED. That indicts the JS rows and every ratio + printed above, since those divide by one -- but NOT the absolute boundary + figures, which do not depend on it. + Raise WARMUP in bench/appRun.js and re-run, or re-run on a quieter machine. + +all 21 rows returned a positive duration + +END diff --git a/benchmarks/rn-benchmark/results/host-20260814-125318.txt b/benchmarks/rn-benchmark/results/host-20260814-125318.txt new file mode 100644 index 000000000..0fe99fe38 --- /dev/null +++ b/benchmarks/rn-benchmark/results/host-20260814-125318.txt @@ -0,0 +1,103 @@ +/var/folders/_5/4d4g39c14rs42vnbblvl9j580000gn/T/rnbench-hermes.v3CmXlydrq.js:45:16: warning: the variable "performance" was not declared in function "makeClock" + if (typeof performance !== 'undefined' && performance && typeof performance.now === 'function') { + ^~~~~~~~~~~ +/var/folders/_5/4d4g39c14rs42vnbblvl9j580000gn/T/rnbench-hermes.v3CmXlydrq.js:382:7: warning: the variable "console" was not declared in function "say" + console.log(line); + ^~~~~~~ +/var/folders/_5/4d4g39c14rs42vnbblvl9j580000gn/T/rnbench-hermes.v3CmXlydrq.js:397:16: warning: the variable "process" was not declared in function "engineName" + if (typeof process !== 'undefined' && process.versions && process.versions.v8) { + ^~~~~~~ + +=== RN boundary benchmark: host-side JS rows === +engine: Hermes 0.12.0 +clock: Date.now (assumed resolution 1000000 ns) +reps: 5 per row, min-max reported, nothing discarded + +This run contains no native boundary. It fixes the JS-side floor and the warmup count. + +--- convergence sweep: "JS call, same shape as addInts" --- +iterations per repetition: 8192000, repetitions: 40, warmup before the first: none + 32.59 32.59 32.59 32.71 32.59 32.59 32.59 32.59 + 32.71 32.59 32.71 32.59 32.59 32.59 32.59 32.59 + 32.71 32.59 32.59 32.59 32.71 32.59 32.59 32.59 + 32.59 32.71 32.59 32.59 32.71 32.59 32.71 32.59 + 32.59 32.71 32.59 32.59 32.59 32.71 32.59 32.59 + + first repetition: 32.59 ns/op + second half, min-max: 32.59 - 32.71 ns/op + first / second-half min: 1.00x + + A ratio near 1.00x means this engine has no tier-up to warm and a large warmup buys + nothing. A ratio well above 1 means it does, and the repetition at which the series + settles multiplied by the iteration count is the warmup the boundary rows need. + +--- the same sweep, but after 81920000 calls of warmup --- + 32.71 32.71 32.47 32.71 32.59 32.59 32.59 32.59 + 32.59 32.71 32.59 32.59 32.71 32.59 32.71 32.47 + 32.59 32.59 32.71 32.59 32.71 32.96 32.71 32.59 + 32.59 32.59 32.59 32.59 32.71 32.59 32.71 32.59 + 32.59 32.71 32.59 32.71 32.59 32.71 32.59 32.71 + + warm first repetition: 32.71 ns/op + warm, min-max overall: 32.47 - 32.96 ns/op + +--- JS-only rows (no boundary anywhere in them) --- +row | min ns/op | max ns/op | reps +------------------------------------------------------------------------------------------------------ +empty JS loop (the loop itself) [n=16384000, warmup=1000000] | 13.67 | 13.85 | 5 +JS call, same shape as addInts [n=8192000, warmup=1000000] | 32.84 | 32.84 | 5 +JS call returning a string arg [n=8192000, warmup=1000000] | 30.03 | 30.15 | 5 +JS sum of an 8-element array [n=2048000, warmup=1000000] | 162.11 | 162.60 | 5 +JS sum of a 1000-element array [n=16000, warmup=100000] | 14312.50 | 14374.99 | 5 +JS sum of a 3-key object [n=8192000, warmup=1000000] | 39.79 | 39.92 | 5 +JS string of 128 chars, built per call [n=4096000, warmup=1000000] | 66.41 | 66.65 | 5 + +sink (proves no row was eliminated): 135 + + +=== RN boundary benchmark: host-side JS rows === +engine: Node 24.19.0 / V8 13.6.233.17-node.51 +clock: performance.now (assumed resolution 1000 ns) +reps: 5 per row, min-max reported, nothing discarded + +This run contains no native boundary. It fixes the JS-side floor and the warmup count. + +--- convergence sweep: "JS call, same shape as addInts" --- +iterations per repetition: 4000, repetitions: 40, warmup before the first: none + 18.08 14.93 2342.30 28.74 20.68 20.64 20.69 20.63 + 20.59 20.65 20.66 20.67 20.63 21.30 20.68 20.58 + 20.71 23.54 9.99 9.84 9.82 11.69 10.46 9.97 + 9.80 9.80 9.85 29.88 9.83 9.80 9.83 21.29 + 10.19 9.81 9.83 9.79 9.81 17.05 11.60 10.54 + + first repetition: 18.08 ns/op + second half, min-max: 9.79 - 29.88 ns/op + first / second-half min: 1.85x + + A ratio near 1.00x means this engine has no tier-up to warm and a large warmup buys + nothing. A ratio well above 1 means it does, and the repetition at which the series + settles multiplied by the iteration count is the warmup the boundary rows need. + +--- the same sweep, but after 40000 calls of warmup --- + 10.51 9.80 11.36 9.84 9.78 9.86 14.84 12.95 + 10.35 9.83 9.83 9.81 9.80 9.79 10.15 11.60 + 10.18 10.14 10.34 9.81 9.83 9.78 9.84 9.79 + 15.05 9.84 9.77 9.80 10.17 10.01 9.80 9.97 + 10.28 9.80 9.80 10.09 11.31 9.78 9.82 9.82 + + warm first repetition: 10.51 ns/op + warm, min-max overall: 9.77 - 15.05 ns/op + +--- JS-only rows (no boundary anywhere in them) --- +row | min ns/op | max ns/op | reps +-------------------------------------------------------------------------------------------------- +empty JS loop (the loop itself) [n=16000, warmup=100000] | 10.15 | 16.24 | 5 +JS call, same shape as addInts [n=64000, warmup=100000] | 3.87 | 10.68 | 5 +JS call returning a string arg [n=4000, warmup=100000] | 3.91 | 4.44 | 5 +JS sum of an 8-element array [n=4000, warmup=100000] | 6.72 | 6.78 | 5 +JS sum of a 1000-element array [n=4000, warmup=100000] | 611.66 | 667.60 | 5 +JS sum of a 3-key object [n=4000, warmup=100000] | 4.33 | 4.49 | 5 +JS string of 128 chars, built per call [n=4000, warmup=100000] | 13.29 | 13.45 | 5 + +sink (proves no row was eliminated): 132 + diff --git a/benchmarks/rn-benchmark/results/host-20260814-183230.txt b/benchmarks/rn-benchmark/results/host-20260814-183230.txt new file mode 100644 index 000000000..71e82d17f --- /dev/null +++ b/benchmarks/rn-benchmark/results/host-20260814-183230.txt @@ -0,0 +1,119 @@ +=== conditions === +platform: host (no device: JS engine floor and the warmup sweep only) +timestamp: 2026-08-14T18:32:30+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 1.62 1.38 1.42 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 2ae233168010 (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ b393b7d7 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold + +/var/folders/_5/4d4g39c14rs42vnbblvl9j580000gn/T/rnbench-hermes.vBIAdxc7xJ.js:45:16: warning: the variable "performance" was not declared in function "makeClock" + if (typeof performance !== 'undefined' && performance && typeof performance.now === 'function') { + ^~~~~~~~~~~ +/var/folders/_5/4d4g39c14rs42vnbblvl9j580000gn/T/rnbench-hermes.vBIAdxc7xJ.js:470:7: warning: the variable "console" was not declared in function "say" + console.log(line); + ^~~~~~~ +/var/folders/_5/4d4g39c14rs42vnbblvl9j580000gn/T/rnbench-hermes.vBIAdxc7xJ.js:485:16: warning: the variable "process" was not declared in function "engineName" + if (typeof process !== 'undefined' && process.versions && process.versions.v8) { + ^~~~~~~ + +=== RN boundary benchmark: host-side JS rows === +engine: Hermes 0.12.0 +clock: Date.now (assumed resolution 1000000 ns) +reps: 5 per row, min-max reported, nothing discarded + +This run contains no native boundary. It fixes the JS-side floor and the warmup count. + +--- convergence sweep: "JS call, same shape as addInts" --- +iterations per repetition: 8192000, repetitions: 40, warmup before the first: none + 32.47 32.35 32.59 32.71 32.47 32.59 32.35 32.59 + 32.23 32.35 32.71 32.59 32.47 32.71 35.03 32.59 + 32.71 32.47 32.59 32.71 32.84 32.71 32.59 32.71 + 32.71 32.47 32.59 32.35 32.47 32.59 32.71 32.71 + 33.08 32.47 32.84 32.71 32.47 32.71 32.59 38.57 + + first repetition: 32.47 ns/op + second half, min-max: 32.35 - 38.57 ns/op + first / second-half min: 1.00x + NEVER SETTLED within +/-5% over 40 repetitions on this engine. + + A ratio near 1.00x means this engine has no tier-up to warm and a large warmup buys + nothing. A ratio well above 1 means it does, and the repetition at which the series + settles multiplied by the iteration count is the warmup the boundary rows need. + +--- the same sweep, but after 81920000 calls of warmup --- + 32.47 32.84 32.59 32.71 32.71 32.71 33.08 32.84 + 32.84 33.20 32.71 32.84 32.59 32.71 33.20 32.59 + 32.71 33.08 32.47 32.71 33.08 32.59 32.71 32.84 + 32.71 32.96 32.84 32.71 32.84 32.71 32.71 32.96 + 32.71 32.84 32.71 32.71 32.84 32.59 32.59 32.96 + + warm first repetition: 32.47 ns/op + warm, min-max overall: 32.47 - 33.20 ns/op + +--- JS-only rows (no boundary anywhere in them) --- +row | min ns/op | max ns/op | reps +------------------------------------------------------------------------------------------------------ +empty JS loop (the loop itself) [n=32768000, warmup=1000000] | 13.37 | 14.80 | 5 +JS call, same shape as addInts [n=8192000, warmup=1000000] | 32.10 | 32.47 | 5 +JS call returning a string arg [n=8192000, warmup=1000000] | 29.42 | 30.52 | 5 +JS sum of an 8-element array [n=2048000, warmup=1000000] | 160.16 | 163.09 | 5 +JS sum of a 1000-element array [n=16000, warmup=100000] | 13875.01 | 14312.50 | 5 +JS sum of a 3-key object [n=8192000, warmup=1000000] | 39.18 | 40.28 | 5 +JS string of 128 chars, built per call [n=4096000, warmup=1000000] | 65.43 | 65.43 | 5 + +sink (proves no row was eliminated): 135 + + +=== RN boundary benchmark: host-side JS rows === +engine: Node 24.19.0 / V8 13.6.233.17-node.51 +clock: performance.now (assumed resolution 1000 ns) +reps: 5 per row, min-max reported, nothing discarded + +This run contains no native boundary. It fixes the JS-side floor and the warmup count. + +--- convergence sweep: "JS call, same shape as addInts" --- +iterations per repetition: 4000, repetitions: 40, warmup before the first: none + 17.82 15.29 802.69 14.43 9.86 9.81 9.82 9.82 + 9.81 9.91 9.94 9.80 9.88 9.85 9.82 9.79 + 11.77 9.85 9.82 9.81 9.80 9.80 11.02 9.90 + 9.79 9.85 9.88 9.85 11.70 9.97 9.83 10.03 + 9.81 9.89 9.81 9.79 9.84 9.82 11.10 9.89 + + first repetition: 17.82 ns/op + second half, min-max: 9.79 - 11.70 ns/op + first / second-half min: 1.82x + flat (within +/-5%) from: 156000 calls; a warmup of 100000 has 0.64x margin + + A ratio near 1.00x means this engine has no tier-up to warm and a large warmup buys + nothing. A ratio well above 1 means it does, and the repetition at which the series + settles multiplied by the iteration count is the warmup the boundary rows need. + +--- the same sweep, but after 40000 calls of warmup --- + 11.13 9.80 9.80 10.84 18.76 9.93 10.51 9.79 + 10.49 9.85 10.00 19.34 9.95 9.84 9.81 9.85 + 9.83 14.42 9.85 9.79 9.85 9.80 9.94 17.67 + 9.84 9.84 9.78 9.89 9.80 11.51 10.10 9.85 + 9.81 9.91 11.06 22.78 15.31 9.86 9.80 9.81 + + warm first repetition: 11.13 ns/op + warm, min-max overall: 9.78 - 22.78 ns/op + +--- JS-only rows (no boundary anywhere in them) --- +row | min ns/op | max ns/op | reps +-------------------------------------------------------------------------------------------------- +empty JS loop (the loop itself) [n=16000, warmup=100000] | 10.12 | 13.67 | 5 +JS call, same shape as addInts [n=64000, warmup=100000] | 10.08 | 10.42 | 5 +JS call returning a string arg [n=4000, warmup=100000] | 3.72 | 4.31 | 5 +JS sum of an 8-element array [n=4000, warmup=100000] | 6.36 | 6.73 | 5 +JS sum of a 1000-element array [n=4000, warmup=100000] | 588.86 | 592.10 | 5 +JS sum of a 3-key object [n=4000, warmup=100000] | 4.01 | 4.03 | 5 +JS string of 128 chars, built per call [n=4000, warmup=100000] | 13.02 | 13.09 | 5 + +sink (proves no row was eliminated): 132 + + +load average after: 1.65 1.42 1.43 diff --git a/benchmarks/rn-benchmark/results/ios-20260814-160250.txt b/benchmarks/rn-benchmark/results/ios-20260814-160250.txt new file mode 100644 index 000000000..11ea6e57d --- /dev/null +++ b/benchmarks/rn-benchmark/results/ios-20260814-160250.txt @@ -0,0 +1,26 @@ +=== conditions === +platform: ios +timestamp: 2026-08-14T16:02:53+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 15.06 17.64 16.78 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: df3f74f34aff (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ 73c40da4 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold + +=== NOT RUN === +SKIPPED: Xcode offers zero eligible iOS destinations for this workspace. + Ineligible destinations for the "RNBench" scheme: + { platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device, error:iOS 26.5 is not installed. Please download and install the platform from Xcode > Settings > Components. } + +This is not a defect in this project: the same is true of every project on this machine, +including CocoaPods' own targets. Xcode 26.6 ships without the iOS platform +component, and the simulator runtimes present (from older Xcode installs) will not be used. +Clearing it needs: xcodebuild -downloadPlatform iOS +which writes several GB into /Library/Developer/CoreSimulator on the internal disk, where +7.4Gi is free. Ask before doing that -- this workspace keeps large artefacts off the +internal disk on purpose. + +No figures were produced. Do not treat any cell this run would have filled as measured. diff --git a/benchmarks/rn-benchmark/results/ios-20260814-171508.txt b/benchmarks/rn-benchmark/results/ios-20260814-171508.txt new file mode 100644 index 000000000..4333096f1 --- /dev/null +++ b/benchmarks/rn-benchmark/results/ios-20260814-171508.txt @@ -0,0 +1,22 @@ +=== conditions === +platform: ios +timestamp: 2026-08-14T17:15:25+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 34.69 11.69 6.44 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 2ae233168010 (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ ad64af56 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +simulator: iPhone 17 Pro (7A6CE089-C7B3-494D-A9EA-045EA3502E98) (Booted) [runtime: iOS 26.2] +xcode: Xcode 26.6 Build version 17F113 +app: /Volumes/macMini/rn-benchmark/DerivedData/Build/Products/Release-iphonesimulator/RNBench.app + +********************************************************************************* +* WARNING: 1-minute load average is 34.69, above 4.0. +* Figures from this run are NOT quotable. On this machine a single commit has read +* 672 and 1076 ns for the same row under load. Re-run on a quiet machine before +* putting anything from this file in a table. +********************************************************************************* + diff --git a/benchmarks/rn-benchmark/results/ios-20260814-183316.txt b/benchmarks/rn-benchmark/results/ios-20260814-183316.txt new file mode 100644 index 000000000..81152d6d7 --- /dev/null +++ b/benchmarks/rn-benchmark/results/ios-20260814-183316.txt @@ -0,0 +1,15 @@ +=== conditions === +platform: ios +timestamp: 2026-08-14T18:33:30+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 3.17 1.74 1.55 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 2ae233168010 (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ b393b7d7 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +simulator: iPhone 17 Pro (7A6CE089-C7B3-494D-A9EA-045EA3502E98) (Booted) [runtime: iOS 26.2] +xcode: Xcode 26.6 Build version 17F113 +app: /Volumes/macMini/rn-benchmark/DerivedData/Build/Products/Release-iphonesimulator/RNBench.app + diff --git a/benchmarks/rn-benchmark/results/ios-20260814-183316.txt.raw b/benchmarks/rn-benchmark/results/ios-20260814-183316.txt.raw new file mode 100644 index 000000000..e73414216 --- /dev/null +++ b/benchmarks/rn-benchmark/results/ios-20260814-183316.txt.raw @@ -0,0 +1 @@ +Filtering the log data using "composedMessage CONTAINS "RNBENCH|"" diff --git a/benchmarks/rn-benchmark/results/ios-20260814-183543.txt b/benchmarks/rn-benchmark/results/ios-20260814-183543.txt new file mode 100644 index 000000000..88ae37594 --- /dev/null +++ b/benchmarks/rn-benchmark/results/ios-20260814-183543.txt @@ -0,0 +1,19 @@ +=== conditions === +platform: ios +timestamp: 2026-08-14T18:35:54+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 2.83 2.00 1.67 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 2ae233168010 (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ b393b7d7 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +simulator: iPhone 17 Pro (7A6CE089-C7B3-494D-A9EA-045EA3502E98) (Booted) [runtime: iOS 26.2] +xcode: Xcode 26.6 Build version 17F113 +app: /Volumes/macMini/rn-benchmark/DerivedData/Build/Products/Release-iphonesimulator/RNBench.app + +bundle id: org.reactjs.native.example.RNBench (read from the installed app) +Filtering the log data using "composedMessage CONTAINS "RNBENCH|"" + +load average after: 1.05 1.29 1.46 diff --git a/benchmarks/rn-benchmark/results/ios-20260814-200329.txt b/benchmarks/rn-benchmark/results/ios-20260814-200329.txt new file mode 100644 index 000000000..447f36219 --- /dev/null +++ b/benchmarks/rn-benchmark/results/ios-20260814-200329.txt @@ -0,0 +1,135 @@ +=== conditions === +platform: ios +timestamp: 2026-08-14T20:03:40+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 5.75 6.75 6.94 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 2773a29f338e (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ 5ff77ff4 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +simulator: iPhone 17 Pro (7A6CE089-C7B3-494D-A9EA-045EA3502E98) (Booted) [runtime: iOS 26.2] +xcode: Xcode 26.6 Build version 17F113 +app: /Volumes/macMini/rn-benchmark/DerivedData/Build/Products/Release-iphonesimulator/RNBench.app + +********************************************************************************* +* WARNING: 1-minute load average is 5.69, above 4.0. +* Figures from this run are NOT quotable. On this machine a single commit has read +* 672 and 1076 ns for the same row under load. Re-run on a quiet machine before +* putting anything from this file in a table. +********************************************************************************* + +bundle id: org.reactjs.native.example.RNBench (read from the installed app) +# sweep: JS-only row, cold +# sweep: boundary row addInts, cold +# js row: empty JS loop (the loop itself) +# js row: JS call, same shape as addInts +# js row: JS call returning a string arg +# js row: JS sum of an 8-element array +# js row: JS sum of a 1000-element array +# js row: JS sum of a 3-key object +# js row: JS string of 128 chars, built per call +# boundary row: zeroArgs() -- boundary, no arguments +# boundary row: addInts(3, 4) -- two numbers +# boundary row: stringLength(8 chars) +# boundary row: stringLength(128 chars) +# boundary row: echoString(8 chars) -- 2 conversions +# boundary row: echoString(128 chars) -- 2 conversions +# boundary row: sumArray(8 elements) +# boundary row: sumArray(1000 elements) +# boundary row: sumObject(3 keys) +# boundary row: noop() -- void, ASYNC DISPATCH, not a crossing +# async round-trip rows +=== RN boundary benchmark: on-device === +engine: Hermes OSS Release Version=250829098.0.16, Build=Release, Bytecode Version=98 +clock: performance.now +warmup: 100000 calls per row, iterations per timed loop: 10000 +reps: 5 per row, min-max reported, nothing discarded +module: present +--- convergence sweep: "JS call, same shape as addInts" (no boundary: Hermes only) --- + 40 repetitions of 10000 calls, no warmup before the first + 14.11 14.12 14.17 14.25 14.06 14.06 25.13 14.26 + 16.95 14.85 17.61 14.09 14.07 14.07 14.08 14.52 + 14.57 14.08 14.08 13.75 13.97 14.08 13.98 13.57 + 13.46 13.38 13.37 13.36 14.36 13.37 13.37 13.37 + 13.37 13.37 13.37 13.37 13.37 13.82 14.00 14.06 + plateau (mean of 2nd half): 13.62 ns/op + first repetition: 14.11 ns/op (1.04x the plateau) + flat (within +/-5%) from: 290000 calls + margin at a warmup of 100000: 0.34x <-- THIN. A slower or busier host would read this row unsettled. +--- convergence sweep: addInts(3, 4) through the TurboModule (Hermes -> JSI -> Objective-C++) --- + 40 repetitions of 10000 calls, no warmup before the first + 1200.58 1167.77 1163.00 1132.96 1126.59 1134.53 1164.47 1130.55 + 1133.61 1134.77 1132.29 1129.38 1147.22 1139.83 1129.72 1133.91 + 1137.32 1177.54 1179.80 1136.74 1127.00 1131.54 1128.19 1128.84 + 1119.53 1121.95 1132.45 1121.49 1158.85 1850.12 1383.93 1415.95 + 1197.58 1193.33 1188.92 1189.44 1160.22 1167.28 1162.68 1174.60 + plateau (mean of 2nd half): 1212.69 ns/op + first repetition: 1200.58 ns/op (0.99x the plateau) + flat (within +/-5%) from: 320000 calls + margin at a warmup of 100000: 0.31x <-- THIN. A slower or busier host would read this row unsettled. + Read the two sweeps together. The JS one prices Hermes, which has no JIT and is expected + to be flat from the first repetition. The boundary one prices Hermes plus a callee + running as Objective-C++, AOT. + On iOS that callee is AOT-compiled and does NOT tier up, so neither side of this + boundary has a warm-up curve to climb: a sweep that fails to settle here is evidence + of a busy machine, not of a warmup that is too short. Check the load average. +row | min ns/op | max ns/op | reps +--------------------------------------------------------------------------------- +empty JS loop (the loop itself) | 14.43 | 14.50 | 5 +JS call, same shape as addInts | 14.07 | 14.24 | 5 +JS call returning a string arg | 15.41 | 15.61 | 5 +JS sum of an 8-element array | 146.04 | 152.45 | 5 +JS sum of a 1000-element array | 12560.42 | 13095.86 | 5 +JS sum of a 3-key object | 25.71 | 26.63 | 5 +JS string of 128 chars, built per call | 77.26 | 80.54 | 5 +zeroArgs() -- boundary, no arguments | 1015.42 | 1257.29 | 5 +addInts(3, 4) -- two numbers | 1387.16 | 1763.59 | 5 +stringLength(8 chars) | 1464.78 | 2362.12 | 5 +stringLength(128 chars) | 1891.07 | 2022.42 | 5 +echoString(8 chars) -- 2 conversions | 1513.91 | 2795.54 | 5 +echoString(128 chars) -- 2 conversions | 1995.99 | 2058.75 | 5 +sumArray(8 elements) | 2176.47 | 3033.63 | 5 +sumArray(1000 elements) | 66209.52 | 68477.94 | 5 +sumObject(3 keys) | 2229.01 | 2279.45 | 5 +noop() -- void, ASYNC DISPATCH, not a crossing | 1487.14 | 1700.50 | 5 +addInts' body, native loop, no crossing | 3.51 | 3.54 | 5 +sink (proves no row was eliminated): 1 +--- native -> JS, ROUND TRIPS (scheduler hop included; not comparable with the rows above) --- +row | min ns/op | max ns/op | reps +------------------------------------------------------------------------ +native -> JS callback round trip | 13806.04 | 14742.04 | 5 +native -> JS promise round trip | 13933.67 | 15424.33 | 5 +bare JS Promise.resolve await (floor) | 808.50 | 1414.83 | 5 +--- ratios, every term from this run --- + addInts / a JS call of the same shape: 98.55x + addInts / zeroArgs (what two numbers cost): 1.37x + sumArray(1000) / sumArray(8): 30.42x + echoString(128) / echoString(8): 1.32x +--- warmup verdict, from this run's own sweeps --- + JS-only (Hermes): settles at 290000 calls, so warmup 100000 has only 0.34x margin -- TOO THIN. Raise it. + boundary (Hermes+JSI+ObjC++): settles at 320000 calls, so warmup 100000 has only 0.31x margin -- TOO THIN. Raise it. + ==> AT LEAST ONE SWEEP SAYS THIS WARMUP IS INSUFFICIENT. + The boundary rows above are readings taken before the host settled. Do not quote + them; raise WARMUP in bench/appRun.js and re-run. +all 21 rows returned a positive duration +END + +load average after: 5.49 6.48 6.82 + +=== capture check === +benchmark rows captured: 21 +END marker seen: yes +warmup verdict: INSUFFICIENT (the app's own, from its convergence sweeps) + +*** NOT QUOTABLE: the run's own warmup verdict is INSUFFICIENT. *** + +21 rows were captured and the report is complete, but the app's convergence sweep +says the boundary rows were read before the host settled. Read the sweep above: if it +NEVER SETTLED, the machine was probably busy (check the load average at the top of +this file) or WARMUP in RNBench/bench/appRun.js is genuinely too small. + +The JS-only rows and the sweeps themselves are unaffected. The boundary rows -- every +row that crosses into native, which is the entire point of this benchmark -- are not +figures. Do not put them in a table. diff --git a/benchmarks/rn-benchmark/results/ios-20260814-200616.txt b/benchmarks/rn-benchmark/results/ios-20260814-200616.txt new file mode 100644 index 000000000..5bb6c1a4a --- /dev/null +++ b/benchmarks/rn-benchmark/results/ios-20260814-200616.txt @@ -0,0 +1,128 @@ +=== conditions === +platform: ios +timestamp: 2026-08-14T20:06:27+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 2.65 5.09 6.22 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 2773a29f338e (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ 5ff77ff4 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +simulator: iPhone 17 Pro (7A6CE089-C7B3-494D-A9EA-045EA3502E98) (Booted) [runtime: iOS 26.2] +xcode: Xcode 26.6 Build version 17F113 +app: /Volumes/macMini/rn-benchmark/DerivedData/Build/Products/Release-iphonesimulator/RNBench.app + +bundle id: org.reactjs.native.example.RNBench (read from the installed app) +# sweep: JS-only row, cold +# sweep: boundary row addInts, cold +# js row: empty JS loop (the loop itself) +# js row: JS call, same shape as addInts +# js row: JS call returning a string arg +# js row: JS sum of an 8-element array +# js row: JS sum of a 1000-element array +# js row: JS sum of a 3-key object +# js row: JS string of 128 chars, built per call +# boundary row: zeroArgs() -- boundary, no arguments +# boundary row: addInts(3, 4) -- two numbers +# boundary row: stringLength(8 chars) +# boundary row: stringLength(128 chars) +# boundary row: echoString(8 chars) -- 2 conversions +# boundary row: echoString(128 chars) -- 2 conversions +# boundary row: sumArray(8 elements) +# boundary row: sumArray(1000 elements) +# boundary row: sumObject(3 keys) +# boundary row: noop() -- void, ASYNC DISPATCH, not a crossing +# async round-trip rows +=== RN boundary benchmark: on-device === +engine: Hermes OSS Release Version=250829098.0.16, Build=Release, Bytecode Version=98 +clock: performance.now +warmup: 100000 calls per row, iterations per timed loop: 10000 +reps: 5 per row, min-max reported, nothing discarded +module: present +--- convergence sweep: "JS call, same shape as addInts" (no boundary: Hermes only) --- + 40 repetitions of 10000 calls, no warmup before the first + 14.15 14.26 14.34 14.17 14.07 14.14 15.60 14.09 + 13.66 13.58 13.58 13.58 13.58 13.57 13.57 13.57 + 13.99 14.06 13.69 13.37 13.37 13.45 13.95 14.06 + 14.05 13.57 13.58 13.57 13.57 13.58 13.58 13.58 + 13.58 13.58 13.57 14.05 14.06 14.07 14.07 13.85 + plateau (mean of 2nd half): 13.74 ns/op + first repetition: 14.15 ns/op (1.03x the plateau) + flat (within +/-5%) from: 70000 calls + margin at a warmup of 100000: 1.43x <-- THIN. A slower or busier host would read this row unsettled. +--- convergence sweep: addInts(3, 4) through the TurboModule (Hermes -> JSI -> Objective-C++) --- + 40 repetitions of 10000 calls, no warmup before the first + 1151.02 1117.18 1131.25 1107.71 1111.76 1112.40 1114.88 1120.70 + 1110.63 1104.80 1107.63 1112.63 1112.04 1109.33 1105.54 1102.96 + 1115.94 1112.93 1141.89 1145.70 1112.67 1104.23 1107.90 1125.91 + 1116.33 1118.61 1103.81 1121.80 1121.69 1111.92 1110.43 1111.49 + 1122.71 1113.77 1113.52 1100.66 1083.66 1084.01 1086.40 1087.90 + plateau (mean of 2nd half): 1107.97 ns/op + first repetition: 1151.02 ns/op (1.04x the plateau) + flat (within +/-5%) from: 0 calls + margin at a warmup of 100000: Infinityx + Read the two sweeps together. The JS one prices Hermes, which has no JIT and is expected + to be flat from the first repetition. The boundary one prices Hermes plus a callee + running as Objective-C++, AOT. + On iOS that callee is AOT-compiled and does NOT tier up, so neither side of this + boundary has a warm-up curve to climb: a sweep that fails to settle here is evidence + of a busy machine, not of a warmup that is too short. Check the load average. +row | min ns/op | max ns/op | reps +--------------------------------------------------------------------------------- +empty JS loop (the loop itself) | 13.70 | 14.21 | 5 +JS call, same shape as addInts | 13.39 | 13.55 | 5 +JS call returning a string arg | 14.64 | 15.39 | 5 +JS sum of an 8-element array | 139.51 | 142.13 | 5 +JS sum of a 1000-element array | 12469.30 | 12738.87 | 5 +JS sum of a 3-key object | 25.29 | 25.96 | 5 +JS string of 128 chars, built per call | 75.11 | 79.22 | 5 +zeroArgs() -- boundary, no arguments | 955.64 | 965.90 | 5 +addInts(3, 4) -- two numbers | 1102.41 | 1108.60 | 5 +stringLength(8 chars) | 1367.85 | 1404.35 | 5 +stringLength(128 chars) | 1775.69 | 1865.47 | 5 +echoString(8 chars) -- 2 conversions | 1416.98 | 1438.11 | 5 +echoString(128 chars) -- 2 conversions | 1887.72 | 1900.86 | 5 +sumArray(8 elements) | 1997.22 | 2041.35 | 5 +sumArray(1000 elements) | 64784.43 | 66721.84 | 5 +sumObject(3 keys) | 2208.39 | 2261.97 | 5 +noop() -- void, ASYNC DISPATCH, not a crossing | 1356.33 | 1599.78 | 5 +addInts' body, native loop, no crossing | 3.50 | 3.55 | 5 +sink (proves no row was eliminated): 1 +--- native -> JS, ROUND TRIPS (scheduler hop included; not comparable with the rows above) --- +row | min ns/op | max ns/op | reps +------------------------------------------------------------------------ +native -> JS callback round trip | 13502.46 | 13864.96 | 5 +native -> JS promise round trip | 13730.25 | 14792.25 | 5 +bare JS Promise.resolve await (floor) | 801.17 | 1362.29 | 5 +--- ratios, every term from this run --- + addInts / a JS call of the same shape: 82.35x + addInts / zeroArgs (what two numbers cost): 1.15x + sumArray(1000) / sumArray(8): 32.44x + echoString(128) / echoString(8): 1.33x +--- warmup verdict, from this run's own sweeps --- + JS-only (Hermes): settles at 70000 calls, so warmup 100000 has only 1.43x margin -- TOO THIN. Raise it. + boundary (Hermes+JSI+ObjC++): settles at 0 calls; warmup 100000 has Infinityx margin. Adequate. + ==> AT LEAST ONE SWEEP SAYS THIS WARMUP IS INSUFFICIENT. + The boundary rows above are readings taken before the host settled. Do not quote + them; raise WARMUP in bench/appRun.js and re-run. +all 21 rows returned a positive duration +END + +load average after: 2.68 4.71 6.01 + +=== capture check === +benchmark rows captured: 21 +END marker seen: yes +warmup verdict: INSUFFICIENT (the app's own, from its convergence sweeps) + +*** NOT QUOTABLE: the run's own warmup verdict is INSUFFICIENT. *** + +21 rows were captured and the report is complete, but the app's convergence sweep +says the boundary rows were read before the host settled. Read the sweep above: if it +NEVER SETTLED, the machine was probably busy (check the load average at the top of +this file) or WARMUP in RNBench/bench/appRun.js is genuinely too small. + +The JS-only rows and the sweeps themselves are unaffected. The boundary rows -- every +row that crosses into native, which is the entire point of this benchmark -- are not +figures. Do not put them in a table. diff --git a/benchmarks/rn-benchmark/results/ios-20260814-200903.txt b/benchmarks/rn-benchmark/results/ios-20260814-200903.txt new file mode 100644 index 000000000..ae8181835 --- /dev/null +++ b/benchmarks/rn-benchmark/results/ios-20260814-200903.txt @@ -0,0 +1,136 @@ +=== conditions === +platform: ios +timestamp: 2026-08-14T20:09:19+0900 +host: Darwin 25.5.0 arm64, 8 cores, 16 GB +load average: 3.79 4.24 5.65 (before the run) +react-native: 0.87.0 +hermes: reported by the app itself -- see the 'engine:' line in the report below +harness digest: 0c397d62e51c (rn-benchmark is not a git repository) +reference repo: /Volumes/macMini/thisisthepy/PythonMultiplatform @ 5ff77ff4 +counts: warmup 100000, 10000 iterations per timed loop, 5 repetitions per row + convergence sweep: 40 repetitions x 10000, cold +simulator: iPhone 17 Pro (7A6CE089-C7B3-494D-A9EA-045EA3502E98) (Booted) [runtime: iOS 26.2] +xcode: Xcode 26.6 Build version 17F113 +app: /Volumes/macMini/rn-benchmark/DerivedData/Build/Products/Release-iphonesimulator/RNBench.app + +bundle id: org.reactjs.native.example.RNBench (read from the installed app) +# sweep: JS-only row, cold +# sweep: boundary row addInts, cold +# js row: empty JS loop (the loop itself) +# js row: JS call, same shape as addInts +# js row: JS call returning a string arg +# js row: JS sum of an 8-element array +# js row: JS sum of a 1000-element array +# js row: JS sum of a 3-key object +# js row: JS string of 128 chars, built per call +# boundary row: zeroArgs() -- boundary, no arguments +# boundary row: addInts(3, 4) -- two numbers +# boundary row: stringLength(8 chars) +# boundary row: stringLength(128 chars) +# boundary row: echoString(8 chars) -- 2 conversions +# boundary row: echoString(128 chars) -- 2 conversions +# boundary row: sumArray(8 elements) +# boundary row: sumArray(1000 elements) +# boundary row: sumObject(3 keys) +# boundary row: noop() -- void, ASYNC DISPATCH, not a crossing +# async round-trip rows +=== RN boundary benchmark: on-device === +engine: Hermes OSS Release Version=250829098.0.16, Build=Release, Bytecode Version=98 +clock: performance.now +warmup: 100000 calls per row, iterations per timed loop: 10000 +reps: 5 per row, min-max reported, nothing discarded +module: present +--- convergence sweep: "JS call, same shape as addInts" (no boundary: Hermes only) --- + 40 repetitions of 10000 calls, no warmup before the first + 14.12 14.13 14.25 14.13 14.10 14.07 14.15 14.10 + 15.37 14.07 14.08 14.08 14.08 14.16 14.17 14.19 + 14.08 14.10 14.26 14.07 14.07 14.07 13.77 13.57 + 13.57 13.58 13.58 13.63 13.59 13.57 13.57 13.85 + 13.57 14.02 13.72 14.20 14.07 14.06 14.09 14.10 + plateau (mean of 2nd half): 13.81 ns/op + first repetition: 14.12 ns/op (1.02x the plateau) + flat (within +/-5%) from: 90000 calls + margin at a warmup of 100000: 1.11x <-- THIN. A slower or busier host would read this row unsettled. +--- convergence sweep: addInts(3, 4) through the TurboModule (Hermes -> JSI -> Objective-C++) --- + 40 repetitions of 10000 calls, no warmup before the first + 1168.88 1117.55 1120.53 1139.41 1140.29 1131.98 1122.16 1126.92 + 1109.50 1119.71 1128.45 1119.94 1117.99 1138.89 1153.46 1107.35 + 1122.39 1116.71 1156.35 1131.19 1109.51 1105.70 1105.92 1107.69 + 1109.72 1133.28 1159.75 1122.45 1107.68 1105.83 1104.75 1106.83 + 1105.61 1105.61 1102.64 1105.48 1105.65 1103.33 1105.82 1106.63 + plateau (mean of 2nd half): 1110.99 ns/op + first repetition: 1168.88 ns/op (1.05x the plateau) + flat (within +/-5%) from: 10000 calls + margin at a warmup of 100000: 10.00x + Read the two sweeps together. The JS one prices Hermes, which has no JIT and is expected + to be flat from the first repetition. The boundary one prices Hermes plus a callee + running as Objective-C++, AOT. + On iOS that callee is AOT-compiled and does NOT tier up, so neither side of this + boundary has a warm-up curve to climb: a sweep that fails to settle here is evidence + of a busy machine, not of a warmup that is too short. Check the load average. +row | min ns/op | max ns/op | reps +--------------------------------------------------------------------------------- +empty JS loop (the loop itself) | 13.68 | 14.08 | 5 +JS call, same shape as addInts | 13.37 | 13.39 | 5 +JS call returning a string arg | 14.63 | 14.65 | 5 +JS sum of an 8-element array | 139.68 | 140.70 | 5 +JS sum of a 1000-element array | 12481.35 | 12698.35 | 5 +JS sum of a 3-key object | 25.68 | 26.11 | 5 +JS string of 128 chars, built per call | 76.02 | 80.42 | 5 +zeroArgs() -- boundary, no arguments | 975.82 | 985.27 | 5 +addInts(3, 4) -- two numbers | 1101.28 | 1119.40 | 5 +stringLength(8 chars) | 1376.30 | 1377.83 | 5 +stringLength(128 chars) | 1783.75 | 1844.78 | 5 +echoString(8 chars) -- 2 conversions | 1428.03 | 1479.65 | 5 +echoString(128 chars) -- 2 conversions | 1882.72 | 1916.37 | 5 +sumArray(8 elements) | 2024.79 | 2065.26 | 5 +sumArray(1000 elements) | 64584.08 | 65445.09 | 5 +sumObject(3 keys) | 2250.80 | 2270.83 | 5 +noop() -- void, ASYNC DISPATCH, not a crossing | 1421.74 | 1514.50 | 5 +addInts' body, native loop, no crossing | 3.50 | 3.54 | 5 +sink (proves no row was eliminated): 1 +--- native -> JS, ROUND TRIPS (scheduler hop included; not comparable with the rows above) --- +row | min ns/op | max ns/op | reps +------------------------------------------------------------------------ +native -> JS callback round trip | 13642.42 | 14032.71 | 5 +native -> JS promise round trip | 13868.88 | 14646.50 | 5 +bare JS Promise.resolve await (floor) | 805.33 | 1351.58 | 5 +--- ratios, every term from this run --- + addInts / a JS call of the same shape: 82.39x + addInts / zeroArgs (what two numbers cost): 1.13x + sumArray(1000) / sumArray(8): 31.90x + echoString(128) / echoString(8): 1.32x +--- warmup verdict, from this run's own sweeps --- + JS-only (Hermes): settles at 90000 calls, so warmup 100000 has only 1.11x margin -- TOO THIN. Raise it. + boundary (Hermes+JSI+ObjC++): settles at 10000 calls; warmup 100000 has 10.00x margin. Adequate. + ==> AT LEAST ONE SWEEP SAYS THIS WARMUP IS INSUFFICIENT. + THE JS-ONLY BASELINE IS NOT SETTLED. That indicts the JS rows and every ratio + printed above, since those divide by one -- but NOT the absolute boundary + figures, which do not depend on it. + Note what this verdict does NOT say: the boundary sweep above settled. Read its + "flat from" line before discarding the boundary rows -- an unsettled Hermes + baseline on a machine with frequency scaling is not a statement about them. + Raise WARMUP in bench/appRun.js and re-run, or re-run on a quieter machine. +all 21 rows returned a positive duration +END + +load average after: 3.33 4.10 5.51 + +=== capture check === +benchmark rows captured: 21 +END marker seen: yes +warmup verdict: INSUFFICIENT (the app's own, from its convergence sweeps) + +*** NOT QUOTABLE AS A WHOLE: the run's own warmup verdict is INSUFFICIENT. *** + +21 rows were captured and the report is complete, but at least one convergence sweep +did not settle far enough before the warmup count. WHICH sweep decides which rows are +affected, and the report says which -- read the 'warmup verdict' block above rather +than discarding the file: + + - boundary sweep thin -> the rows that cross into native are not figures. + - JS-only sweep thin -> the JS baseline and the ratios are not figures; the + absolute boundary numbers do not depend on them. + +On this machine the usual cause is load, not a genuinely short warmup: check the load +average at the top of this file, and re-run quiet before changing WARMUP. diff --git a/benchmarks/rn-benchmark/run-bench.sh b/benchmarks/rn-benchmark/run-bench.sh new file mode 100755 index 000000000..702dc4ad1 --- /dev/null +++ b/benchmarks/rn-benchmark/run-bench.sh @@ -0,0 +1,604 @@ +#!/bin/bash +# One command, one table. +# +# ./run-bench.sh host JS-only rows + the warmup sweep, under Hermes and Node. +# No device. Safe to run at any time. +# ./run-bench.sh android Build, install, launch, capture the report from logcat. +# Needs one connected device or a running emulator. +# ./run-bench.sh ios Build, install, launch, capture the report from the simulator log. +# Needs one BOOTED simulator. Will not boot one for you. +# ./run-bench.sh build Build both platforms and stop. Touches no device. +# ./run-bench.sh all host, then android, then ios. Never stops early: a platform that +# cannot run writes down why and the next one still runs. +# +# Output goes to results/<platform>-<timestamp>.txt as well as to the terminal. Every file opens +# with the conditions the run was taken under -- load average, device, engine, versions, counts -- +# because a per-call figure without them is not a measurement, and this workspace has already lost +# a bisect to numbers recorded without a load average beside them. +# +# Environment: +# ANDROID_SERIAL=emulator-5554 pick a device when more than one is attached. Setting this is +# your assertion that that device is free; the script cannot tell. +# RN_BENCH_REQUIRE_QUIET=1 refuse to run at all if the 1-minute load average is above +# RN_BENCH_MAX_LOAD (default 4.0) instead of merely warning. +# 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. +# +# Nothing here averages across runs and nothing discards an outlier: each invocation produces one +# report whose rows are min-max over the repetitions inside that single process. Comparing two +# invocations of this script is comparing two processes, which the reference project's docs are +# explicit is not a measurement -- if you want a range, read the min-max inside one report. +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/env.sh" + +# Android and iOS do NOT share an id, and assuming they did cost one whole run: simctl launch +# failed with FBSOpenApplicationServiceErrorDomain code=4, which reads like a broken build and is +# really "no such app". `applicationId` in android/app/build.gradle is com.rnbench, while the iOS +# target still carries React Native's template default. Read from the built app rather than +# restated here, so this cannot drift again. +ANDROID_APP_ID=com.rnbench +IOS_APP_ID_DEFAULT=org.reactjs.native.example.RNBench +APP_ID="$ANDROID_APP_ID" +RESULTS="$HERE/results" +# This benchmark lives inside the reference repository (benchmarks/rn-benchmark), so the repo is +# two levels up. RN_BENCH_REFERENCE_REPO overrides it. +REFERENCE_REPO="${RN_BENCH_REFERENCE_REPO:-$(cd "$HERE/../.." && pwd)}" +mkdir -p "$RESULTS" +STAMP="$(date +%Y%m%d-%H%M%S)" +MAX_LOAD="${RN_BENCH_MAX_LOAD:-4.0}" +CAPTURE_SECS="${RN_BENCH_CAPTURE_SECS:-900}" + +die() { echo "$*" >&2; exit 1; } + +# --- conditions ----------------------------------------------------------------------------------- +# +# Written into every results file, ahead of the report. The three that have actually caught problems +# in this workspace, and are therefore not optional: +# +# - load average, because the reference project watched one commit read 672 and 1076 ns on a +# loaded machine and lost a bisect to it; +# - the reference repo's commit, because the table in COMPARISON.md quotes it and the quote goes +# stale silently (it already did once: a whole column was re-measured upstream); +# - a digest of the harness sources, because the harness can be edited without a commit and there +# is otherwise no way to tell two runs of "the benchmark" apart. + +load_1min() { uptime | sed -e 's/.*load averages*: *//' -e 's/,//g' | awk '{print $1}'; } + +harness_digest() { + # Everything that can change a number: the JS, the two native modules, the spec. + find "$HERE/RNBench/bench" "$HERE/RNBench/modules/bench/src" \ + "$HERE/RNBench/modules/bench/android" "$HERE/RNBench/modules/bench/ios" \ + "$HERE/RNBench/App.tsx" \ + -type f \( -name '*.js' -o -name '*.ts' -o -name '*.tsx' -o -name '*.kt' -o -name '*.mm' -o -name '*.h' \) \ + 2>/dev/null | sort | xargs shasum -a 256 2>/dev/null | shasum -a 256 | awk '{print substr($1,1,12)}' +} + +reference_commit() { + if [ -d "$REFERENCE_REPO/.git" ]; then + local c d + c="$(git -C "$REFERENCE_REPO" rev-parse --short HEAD 2>/dev/null)" + d="$(git -C "$REFERENCE_REPO" status --porcelain 2>/dev/null | head -1)" + printf '%s%s' "$c" "$([ -n "$d" ] && echo ' (working tree dirty)')" + else + printf 'not found at %s' "$REFERENCE_REPO" + fi +} + +rn_version() { + awk -F'"' '/"react-native": *"/ {print $4; exit}' "$HERE/RNBench/package.json" 2>/dev/null +} + +# $1 platform label, $2 extra lines (may be empty) +conditions_block() { + local platform="$1" + echo "=== conditions ===" + echo "platform: $platform" + echo "timestamp: $(date '+%Y-%m-%dT%H:%M:%S%z')" + echo "host: $(uname -srm), $(sysctl -n hw.ncpu) cores, $(( $(sysctl -n hw.memsize) / 1073741824 )) GB" + echo "load average: $(uptime | sed -e 's/.*load averages*: *//') (before the run)" + echo "react-native: $(rn_version)" + echo "hermes: reported by the app itself -- see the 'engine:' line in the report below" + echo "harness digest: $(harness_digest) (rn-benchmark is not a git repository)" + echo "reference repo: $REFERENCE_REPO @ $(reference_commit)" + echo "counts: warmup $(app_const WARMUP), $(app_const N) iterations per timed loop, $(app_const REPS) repetitions per row" + echo " convergence sweep: $(app_const SWEEP_REPS) repetitions x $(app_const N), cold" + if [ $# -gt 1 ] && [ -n "$2" ]; then + printf '%s\n' "$2" + fi + echo +} + +# Read a count out of the app rather than restating it here. These two used to be separate +# constants -- this block said "warmup 100000" as a literal while `bench/appRun.js` decided what +# the app actually did -- so a results file could state conditions the run was not taken under, +# which is the one thing a conditions block exists to prevent. It has already happened: the +# 2026-08-19T00:50 report's header says 100000 while its own verdict block reports 400000. +# A missing constant is a hard failure, not a silent blank: a conditions line that quietly says +# nothing is the same defect in a quieter font. +app_const() { + local name="$1" value + value=$(sed -n "s/^const ${name} = \([0-9][0-9]*\);.*/\1/p" "$HERE/RNBench/bench/appRun.js" | head -1) + if [ -z "$value" ]; then + echo "run-bench.sh: cannot read const ${name} from RNBench/bench/appRun.js" >&2 + exit 2 + fi + printf '%s' "$value" +} + +# Loud, and refusable. The point is that a number taken on a busy machine reaches the reader +# already labelled as unquotable, rather than being labelled later by someone who remembers. +# +# Called TWICE, and the two calls are not interchangeable: +# +# `require_quiet_before_work` runs before anything this script does costs CPU. That is the one +# that may refuse, because it is the only moment the load average describes the machine rather +# than describing this script. It used to be called only after `build_android`, so the number it +# judged was one the Gradle build had just created: a run started on a load of 1.79 measured 4.45 +# and reported itself unquotable for its own build's sake. +# +# `load_guard` runs inside the report, to write the conditions down. Its result is discarded +# there on purpose -- it sits in a `| tee` pipeline where a return value cannot stop anything, +# which is exactly how `RN_BENCH_REQUIRE_QUIET=1` came to print "refusing to run" and then run +# the whole benchmark anyway. Refusing is not its job any more. +require_quiet_before_work() { + local l + l="$(load_1min)" + if awk -v a="$l" -v b="$MAX_LOAD" 'BEGIN {exit !(a > b)}'; then + if [ "${RN_BENCH_REQUIRE_QUIET:-0}" = "1" ]; then + echo "1-minute load average is $l, above $MAX_LOAD, and RN_BENCH_REQUIRE_QUIET=1." >&2 + echo "Refusing to start: nothing measured here would be quotable. Wait for the machine to" >&2 + echo "settle, or unset RN_BENCH_REQUIRE_QUIET to take an explicitly unquotable reading." >&2 + return 1 + fi + fi + return 0 +} + +load_guard() { + local l + l="$(load_1min)" + if awk -v a="$l" -v b="$MAX_LOAD" 'BEGIN {exit !(a > b)}'; then + echo "*********************************************************************************" + echo "* WARNING: 1-minute load average is $l, above $MAX_LOAD." + echo "* Figures from this run are NOT quotable. On this machine a single commit has read" + echo "* 672 and 1076 ns for the same row under load. Re-run on a quiet machine before" + echo "* putting anything from this file in a table." + echo "*********************************************************************************" + echo + fi + return 0 +} + +cmd_host() { + local out="$RESULTS/host-$STAMP.txt" + { conditions_block "host (no device: JS engine floor and the warmup sweep only)" + load_guard || true + } | tee "$out" + "$HERE/run-host-sweep.sh" both 2>&1 | tee -a "$out" + { echo; echo "load average after: $(uptime | sed -e 's/.*load averages*: *//')"; } | tee -a "$out" + echo "written: $out" +} + +# Release, not debug, and that is a measurement decision rather than a packaging one: +# +# - a debug APK loads JS from a Metro server over `adb reverse`, so a run depends on a process +# nobody captured and on whatever Metro happened to have cached. A release APK carries +# `assets/index.android.bundle` -- Hermes bytecode, compiled at build time -- so the run is +# reproducible from the artefact alone. +# - debug JS is unminified and runs with dev-mode assertions on. Those are not the conditions any +# shipped app runs in, and they inflate the JS-only floor rows that everything else is read +# against. +# +# The release build is signed with the template's debug keystore, so it installs on a device +# without any further setup. +build_android() { + ( cd "$HERE/RNBench/android" && ./gradlew :app:assembleRelease --console=plain ) \ + > "$HERE/android-build.log" 2>&1 + local rc=$? + if [ $rc -ne 0 ]; then + echo "android build FAILED (exit $rc); see $HERE/android-build.log" >&2 + grep -E "^(FAILURE|\* What went wrong)" -A6 "$HERE/android-build.log" | head -30 >&2 + fi + return $rc +} + +# Release, for the same reason Android is built Release: a Debug iOS build fetches its JS from a +# Metro server that nobody in this script starts or captures, while a Release build carries +# `main.jsbundle` inside the .app. Both platforms therefore run bytecode compiled at build time, so +# the two columns are taken under the same conditions rather than one of them running unminified +# dev-mode JS. +build_ios() { + "$HERE/build-ios.sh" Release > "$HERE/ios-build.log" 2>&1 + local rc=$? + if [ $rc -ne 0 ]; then + echo "ios build FAILED (exit $rc); see $HERE/ios-build.log" >&2 + grep -E "error:" "$HERE/ios-build.log" | head -20 >&2 + fi + return $rc +} + +# --- did the run actually produce anything? ------------------------------------------------------- +# +# This exists because it did not, twice, and the script exited 0 both times. `./run-bench.sh ios` +# wrote results/ios-20260814-171508.txt (22 lines) and results/ios-20260814-183543.txt (19 lines), +# each containing the conditions block and not one benchmark row, and reported success. A file that +# says nothing is worse than a missing file: it looks like a measurement until someone reads it. +# +# So: zero rows is a failure. Both platforms are checked by the same function, and both `cmd_ios` +# and `cmd_android` return its status, so `all`'s summary and the process exit code both tell the +# truth about an empty capture. +# +# A "row" is a line of the report table: `name | min ns/op | max ns/op | reps`, whose last three +# fields are numbers. That is matched rather than a mere line count because the log capture also +# yields progress (`#`) lines and prose, and a run that printed only progress lines before dying is +# exactly as empty as one that printed nothing. +report_row_count() { + grep -cE '\| +[0-9]+\.[0-9]+ \| +[0-9]+\.[0-9]+ \| +[0-9]+$' "$1" 2>/dev/null +} + +# $1 platform label, $2 results file. Appends the verdict to the file and returns non-zero if the +# run is not quotable. +verify_report() { + local platform="$1" out="$2" rows ended warmup rc=0 + rows="$(report_row_count "$out")"; rows="${rows:-0}" + ended=0; grep -qx 'END' "$out" 2>/dev/null && ended=1 + # The app decides this, not the harness: section 6 of bench/appRun.js reads its own convergence + # sweeps and prints the verdict. All that happens here is that the verdict is given the same + # weight as an empty capture, for the same reason -- see below. + warmup=ok; grep -q 'AT LEAST ONE SWEEP SAYS THIS WARMUP IS INSUFFICIENT' "$out" 2>/dev/null && warmup=INSUFFICIENT + + { echo + echo "=== capture check ===" + echo "benchmark rows captured: $rows" + echo "END marker seen: $([ "$ended" = 1 ] && echo yes || echo 'NO')" + echo "warmup verdict: $warmup (the app's own, from its convergence sweeps)" + } | tee -a "$out" + + if [ "$rows" -eq 0 ]; then + { echo + echo "*** FAILED: this run captured NO benchmark rows. ***" + echo + echo "Everything above the capture check is the conditions the run was attempted under; none" + echo "of it is a measurement. Do not fill any cell from this file, and do not read the" + echo "script's exit status as anything but failure." + echo + echo "Where to look first, in order of how often each has actually been the cause here:" + echo " 1. the capture filter. The $platform capture reads the device log through a filter;" + echo " if the app's log level or tag changed, the filter matches nothing while the app" + echo " runs the whole benchmark perfectly. This was the cause both times it happened:" + echo " \`log stream\` defaults to --level default, which drops the Info-level messages" + echo " React Native's console.log produces on iOS." + echo " 2. the app crashed before printing. Check the '#' progress lines above for the last" + echo " row it reached; if there are none, it died before the first row." + echo " 3. the capture window (${CAPTURE_SECS}s) was too short. The 1000-element rows take" + echo " tens of seconds each." + } | tee -a "$out" + rc=1 + elif [ "$ended" = 0 ]; then + { echo + echo "*** INCOMPLETE: $rows rows were captured but the END marker never arrived. ***" + echo "The report is cut short -- the capture window (${CAPTURE_SECS}s) may be too short, or" + echo "the app died partway. Rows present above were measured; rows absent were not run." + } | tee -a "$out" + rc=1 + elif ! grep -q "all .* rows returned a positive duration" "$out"; then + { echo + echo "*** SUSPECT: the report finished but did not assert that every row was positive. ***" + echo "Look for an 'UNUSABLE ROWS' line above: those rows measured <= 0 and are not figures." + } | tee -a "$out" + rc=1 + fi + + # A full report whose own warmup verdict says the boundary rows were taken before the host + # settled is the same failure as an empty capture, and was being reported the same way an empty + # capture used to be: exit 0. It is worse than empty, in fact -- an empty file makes a reader go + # looking, while a complete-looking table with a warning eleven lines above it does not. The app + # states this verdict precisely because the reference project published two boundary costs taken + # under a too-small warmup and neither reproduced; having it printed and then exiting 0 would put + # this harness back in that position. + # + # This does not discard the file. The JS-only rows and the sweeps are unaffected -- what the + # verdict indicts is the boundary rows -- and the sweep printed above is what says how much more + # warmup is needed. It is the exit status that changes, so nothing downstream reads the run as + # quotable on its own. + if [ "$warmup" = INSUFFICIENT ]; then + { echo + echo "*** NOT QUOTABLE AS A WHOLE: the run's own warmup verdict is INSUFFICIENT. ***" + echo + echo "$rows rows were captured and the report is complete, but at least one convergence sweep" + echo "did not settle far enough before the warmup count. WHICH sweep decides which rows are" + echo "affected, and the report says which -- read the 'warmup verdict' block above rather" + echo "than discarding the file:" + echo + echo " - boundary sweep thin -> the rows that cross into native are not figures." + echo " - JS-only sweep thin -> the JS baseline and the ratios are not figures; the" + echo " absolute boundary numbers do not depend on them." + echo + echo "On this machine the usual cause is load, not a genuinely short warmup: check the load" + echo "average at the top of this file, and re-run quiet before changing WARMUP." + } | tee -a "$out" + rc=1 + fi + return $rc +} + +# Writes the conditions block plus a stated reason into the results file and returns non-zero. +# A platform that could not run must leave a file saying so: a missing file is indistinguishable +# from a file nobody looked for, and "quietly skipped" is how an empty column becomes a filled one +# in someone's memory. +record_skip() { + local platform="$1" out="$2" reason="$3" + { conditions_block "$platform" + echo "=== NOT RUN ===" + printf '%s\n' "$reason" + echo + echo "No figures were produced. Do not treat any cell this run would have filled as measured." + } | tee "$out" + echo "written: $out" >&2 + return 1 +} + +# Exactly one device, or the one named by ANDROID_SERIAL. Prints the serial on stdout. +pick_android_device() { + local devices count + devices="$(adb devices | awk 'NR>1 && $2=="device" {print $1}')" + if [ -z "$devices" ]; then + return 1 + fi + if [ -n "${ANDROID_SERIAL:-}" ]; then + if echo "$devices" | grep -qx "$ANDROID_SERIAL"; then + printf '%s' "$ANDROID_SERIAL" + return 0 + fi + return 2 + fi + count="$(echo "$devices" | wc -l | tr -d ' ')" + if [ "$count" != "1" ]; then + return 3 + fi + printf '%s' "$devices" + return 0 +} + +cmd_android() { + local out="$RESULTS/android-$STAMP.txt" + + local serial rc + serial="$(pick_android_device)"; rc=$? + case $rc in + 1) record_skip "android" "$out" \ + "SKIPPED: no adb device is attached. +Start an emulator or connect a device and re-run: + \$ANDROID_HOME/emulator/emulator -avd <name> & + ./run-bench.sh android +Available AVDs: $(ls ~/.android/avd/*.ini 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/\.ini$//' | tr '\n' ' ')" + return 1 ;; + 2) record_skip "android" "$out" \ + "SKIPPED: ANDROID_SERIAL=$ANDROID_SERIAL is set but that device is not attached. +Attached: $(adb devices | awk 'NR>1 && $2=="device" {print $1}' | tr '\n' ' ')" + return 1 ;; + 3) record_skip "android" "$out" \ + "SKIPPED: more than one device is attached and ANDROID_SERIAL is not set. +Attached: $(adb devices | awk 'NR>1 && $2=="device" {print $1}' | tr '\n' ' ') +The Android app id ($ANDROID_APP_ID) is the same on every device, so a multi-device run would +install over itself. Re-run with the one you may use, e.g.: + ANDROID_SERIAL=emulator-5554 ./run-bench.sh android +On this machine the emulators are shared, so setting it is your assertion that that one is free." + return 1 ;; + esac + export ANDROID_SERIAL="$serial" + + require_quiet_before_work || { + record_skip "android" "$out" \ + "SKIPPED: the 1-minute load average was above $MAX_LOAD before this run built anything, and RN_BENCH_REQUIRE_QUIET=1." + return 1 + } + + build_android || { + record_skip "android" "$out" "SKIPPED: the release build failed. See $HERE/android-build.log" + return 1 + } + + local apk="$HERE/RNBench/android/app/build/outputs/apk/release/app-release.apk" + [ -f "$apk" ] || { record_skip "android" "$out" "SKIPPED: no release APK at $apk"; return 1; } + + # Identify the device in the report. `am start -W` gives the launch figure section 5b of + # COMPARISON.md asks for, and costs nothing to take here. + local dev_desc + dev_desc="device: $serial $(adb shell getprop ro.product.model 2>/dev/null | tr -d '\r') \ +/ API $(adb shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r') \ +/ $(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r') \ +/ build $(adb shell getprop ro.build.id 2>/dev/null | tr -d '\r')" + + { conditions_block "android" "$dev_desc +apk: $apk ($(du -h "$apk" | awk '{print $1}'))" + load_guard || true + } | tee "$out" + + adb install -r "$apk" > /dev/null || { echo "adb install failed" >&2; return 1; } + + adb logcat -c + # -W blocks until the first frame, so this is time-to-first-render on a cold start of a + # freshly installed APK. Recorded, not compared: the benchmark itself runs after it. + { echo "--- am start -W (cold launch of a just-installed APK) ---" + adb shell am start -W -n "$APP_ID/.MainActivity" 2>&1 | sed 's/^/ /' + echo + } | tee -a "$out" + + echo "capturing (up to ${CAPTURE_SECS} s; '#' lines are progress, not results)..." + # A watchdog, because the alternative is a pipe that hangs forever when the app crashes before + # printing END, and a hang looks exactly like a slow row. + ( sleep "$CAPTURE_SECS"; adb shell am force-stop "$APP_ID" >/dev/null 2>&1 ) & + local watchdog=$! + adb logcat -v raw ReactNativeJS:V '*:S' | while IFS= read -r line; do + case "$line" in + *RNBENCH\|*) + printf '%s\n' "${line#*RNBENCH| }" + case "$line" in *"RNBENCH| END"*) break ;; esac + ;; + esac + done | tee -a "$out" + kill $watchdog 2>/dev/null + + { echo; echo "load average after: $(uptime | sed -e 's/.*load averages*: *//')"; } | tee -a "$out" + # Same check as iOS, and it returns non-zero rather than only printing: the previous version here + # printed INCOMPLETE and still exited 0, so `all`'s summary would say "produced figures". + verify_report "android" "$out"; local vrc=$? + echo "written: $out" + return $vrc +} + +cmd_ios() { + local out="$RESULTS/ios-$STAMP.txt" + + # Checked before building, because the build takes minutes and these failures are instant. + # + # Note what is NOT checked here: `xcodebuild -showdestinations`. It reports zero iOS destinations + # on this machine for every project, and an earlier pass read that as "iOS cannot be built here". + # It is not: it is Xcode's scheme-destination layer refusing an SDK with no matching platform + # component, and build-ios.sh sidesteps it by building the target against -sdk iphonesimulator. + # The things that would actually stop a run are the simulator SDK and an installed runtime. + if ! xcrun --sdk iphonesimulator --show-sdk-path >/dev/null 2>&1; then + record_skip "ios" "$out" "SKIPPED: no iOS simulator SDK. \`xcrun --sdk iphonesimulator --show-sdk-path\` failed." + return 1 + fi + if [ "$(xcrun simctl list runtimes 2>/dev/null | grep -c '^iOS ')" = "0" ]; then + record_skip "ios" "$out" "SKIPPED: no iOS simulator runtime is installed. \`xcrun simctl list runtimes\` lists none." + return 1 + fi + + build_ios || { record_skip "ios" "$out" "SKIPPED: the iOS build failed. See $HERE/ios-build.log"; return 1; } + + local booted + booted="$(xcrun simctl list devices booted | grep -c "Booted")" + if [ "$booted" != "1" ]; then + record_skip "ios" "$out" \ + "SKIPPED: expected exactly one BOOTED simulator, found $booted. +This script will not boot one: the simulators on this machine are shared with other work. +Boot the one you may use, then re-run: + xcrun simctl boot <udid> && ./run-bench.sh ios" + return 1 + fi + + local app + app="$(find "$HERE/DerivedData/Build/Products" -name 'RNBench.app' -maxdepth 3 | head -1)" + [ -n "$app" ] || { record_skip "ios" "$out" "SKIPPED: no RNBench.app under $HERE/DerivedData/Build/Products"; return 1; } + + # The runtime version, not just the device name. COMPARISON.md pairs this column against the + # reference project's iosSimulatorArm64 column, and that pairing is only a pairing if both ran on + # the same runtime -- so the runtime has to be in the file, not in someone's memory. + local sim_desc + sim_desc="$(xcrun simctl list devices booted \ + | awk '/^-- /{rt=$0; gsub(/^-- | --$/,"",rt)} /Booted/{sub(/^ +/,""); print $0" [runtime: "rt"]"}' | head -1)" + { conditions_block "ios" \ + "simulator: $sim_desc +xcode: $(xcodebuild -version | tr '\n' ' ') +app: $app" + load_guard || true + } | tee "$out" + + xcrun simctl install booted "$app" || { echo "simctl install failed" >&2; return 1; } + + # Read the id out of the app that was just installed, rather than trusting a constant. The two + # platforms disagree, and a stale constant fails as "device failed to launch", which reads like + # the build is broken when the build is fine. + local ios_app_id + ios_app_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Info.plist" 2>/dev/null)" + [ -n "$ios_app_id" ] || ios_app_id="$IOS_APP_ID_DEFAULT" + APP_ID="$ios_app_id" + echo "bundle id: $APP_ID (read from the installed app)" | tee -a "$out" + + xcrun simctl terminate booted "$APP_ID" >/dev/null 2>&1 + + # The log stream is started first and killed on the END marker, so this terminates on its own + # rather than needing a Ctrl-C -- a run that needs a human to stop it is a run nobody repeats. + # + # `--level info` is load-bearing and was missing for two entire runs, each of which exited 0 with + # an empty table. `log stream` defaults to `--level default`, which streams Default, Error and + # Fault and *silently drops Info and Debug*. React Native's iOS logger emits JS `console.log` at + # Info (`[com.facebook.react.log:javascript]`, type `I`), so the predicate below was being applied + # to a stream the messages had already been excluded from -- the app was running the benchmark + # correctly the whole time and nothing was listening at the right level. + # + # Confirmed by A/B on one launch, same predicate, same app: without `--level info`, 0 matching + # lines; with it, 18. The Android path needs no equivalent because logcat has no such default + # filter -- `ReactNativeJS:V` already asks for verbose. + xcrun simctl spawn booted log stream --style compact --level info \ + --predicate 'eventMessage CONTAINS "RNBENCH|"' > "$out.raw" 2>/dev/null & + local logpid=$! + + # Wait for the stream to actually be live rather than sleeping a guessed interval. `log stream` + # prints "Filtering the log data using ..." once it has attached, so that banner -- the same one + # the grep below is careful to exclude from the results -- is the readiness signal. A fixed sleep + # here is a race whose loss looks exactly like the bug this script already has a section about: + # an empty capture of a healthy run. The app's first line is a progress line emitted within + # milliseconds of launch, so there is no slack to lose. + local ready=0 waited_ready=0 + while [ $waited_ready -lt 30 ]; do + grep -q 'Filtering the log data' "$out.raw" 2>/dev/null && { ready=1; break; } + sleep 1 + waited_ready=$((waited_ready + 1)) + done + if [ "$ready" = 0 ]; then + kill $logpid 2>/dev/null + record_skip "ios" "$out" "SKIPPED: \`log stream\` never reported it had attached (waited 30 s). +Nothing was captured because nothing was listening; this is not a result about the app." + return 1 + fi + + xcrun simctl launch booted "$APP_ID" > /dev/null || { kill $logpid 2>/dev/null; echo "simctl launch failed" >&2; return 1; } + + local waited=0 + while [ $waited -lt "$CAPTURE_SECS" ]; do + grep -q "RNBENCH| END" "$out.raw" 2>/dev/null && break + sleep 2 + waited=$((waited + 2)) + done + kill $logpid 2>/dev/null + + # Only the app's own lines. `log stream` prefaces every stream with its own banner ("Filtering the + # log data using ...") which itself contains the string RNBENCH, and prints a termination notice + # when killed; an unfiltered `sed` copied both into the results file, which is how a file with + # zero rows still looked like it had captured something. The trailing space in `RNBENCH| ` is what + # excludes the banner, whose copy of the tag is quoted (`"RNBENCH|"`). + grep 'RNBENCH| ' "$out.raw" | sed -e 's/.*RNBENCH| //' >> "$out" + rm -f "$out.raw" + { echo; echo "load average after: $(uptime | sed -e 's/.*load averages*: *//')"; } | tee -a "$out" + verify_report "ios" "$out"; local vrc=$? + echo "written: $out" + return $vrc +} + +case "${1:-host}" in + host) cmd_host ;; + android) cmd_android ;; + ios) cmd_ios ;; + build) + rc=0 + build_android || rc=1 + build_ios || rc=1 + if [ $rc -eq 0 ]; then echo "both platforms built"; fi + exit $rc + ;; + all) + # Each platform is attempted regardless of what the previous one did, and the summary says + # which produced figures. An `exit` in the middle of this used to mean a missing device on + # Android silently prevented iOS from being attempted at all. + host_rc=0; android_rc=0; ios_rc=0 + cmd_host || host_rc=$? + cmd_android || android_rc=$? + cmd_ios || ios_rc=$? + echo + echo "=== summary ===" + st() { [ "$1" = "0" ] && echo "produced figures" || echo "NOT RUN -- see the results file for why"; } + echo " host: $(st $host_rc)" + echo " android: $(st $android_rc)" + echo " ios: $(st $ios_rc)" + echo " files: $RESULTS/*-$STAMP.txt" + [ "$host_rc$android_rc$ios_rc" = "000" ] || echo + [ "$host_rc$android_rc$ios_rc" = "000" ] || echo " Not every platform ran. Leave the cells they would have filled as 미측정." + ;; + *) die "usage: $0 [host|android|ios|build|all]" ;; +esac diff --git a/benchmarks/rn-benchmark/run-host-sweep.sh b/benchmarks/rn-benchmark/run-host-sweep.sh new file mode 100755 index 000000000..21a211af9 --- /dev/null +++ b/benchmarks/rn-benchmark/run-host-sweep.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# The host-side half of the benchmark: JS-only rows and the warmup convergence sweep, run under +# BOTH the standalone Hermes VM and Node/V8 from the same source files. +# +# ./run-host-sweep.sh both engines +# ./run-host-sweep.sh hermes Hermes only +# ./run-host-sweep.sh node Node only +# +# No device, no emulator and no simulator is involved, so this is safe to run at any time. It +# measures no boundary -- see bench/host-sweep.js for what it does settle. +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/env.sh" + +BENCH="$HERE/RNBench/bench" +HERMES="$HERE/tools/node_modules/hermes-engine-cli/osx-bin/hermes" +WHICH="${1:-both}" + +run_hermes() { + if [ ! -x "$HERMES" ]; then + echo "no standalone Hermes at $HERMES -- run: (cd $HERE/tools && npm install)" >&2 + return 1 + fi + # Standalone Hermes has no module system, so the three files are concatenated in load order. + # The UMD wrapper in each of them falls back to assigning a global when `module` is absent. + local tmp + tmp="$(mktemp -t rnbench-hermes)".js + cat "$BENCH/harness.js" "$BENCH/jsRows.js" "$BENCH/host-sweep.js" > "$tmp" + "$HERMES" "$tmp" + local rc=$? + rm -f "$tmp" + return $rc +} + +run_node() { + node "$BENCH/host-sweep.js" +} + +rc=0 +case "$WHICH" in + hermes) run_hermes || rc=$? ;; + node) run_node || rc=$? ;; + both) + run_hermes || rc=$? + run_node || rc=$? + ;; + *) + echo "usage: $0 [hermes|node|both]" >&2 + rc=2 + ;; +esac +exit $rc diff --git a/benchmarks/rn-benchmark/tools/package-lock.json b/benchmarks/rn-benchmark/tools/package-lock.json new file mode 100644 index 000000000..fc8d0b5a9 --- /dev/null +++ b/benchmarks/rn-benchmark/tools/package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "tools", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tools", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "hermes-engine-cli": "^0.12.0" + } + }, + "node_modules/hermes-engine-cli": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/hermes-engine-cli/-/hermes-engine-cli-0.12.0.tgz", + "integrity": "sha512-72a5yvwSC2qWtxJ80nlcoCCfNTkpYI8VVAfOro4FL9O105NM8Vpigz/A5Nq1rECKdP7M1N81HHiYL6jU5N1gYw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + } + } +} diff --git a/benchmarks/rn-benchmark/tools/package.json b/benchmarks/rn-benchmark/tools/package.json new file mode 100644 index 000000000..06bb28fed --- /dev/null +++ b/benchmarks/rn-benchmark/tools/package.json @@ -0,0 +1,16 @@ +{ + "name": "tools", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "hermes-engine-cli": "^0.12.0" + } +} diff --git a/benchmarks/typedpython/.gitignore b/benchmarks/typedpython/.gitignore new file mode 100644 index 000000000..4977b6307 --- /dev/null +++ b/benchmarks/typedpython/.gitignore @@ -0,0 +1,5 @@ +target/ +java-out/ +__pycache__/ +*.class +tp-build/ diff --git a/benchmarks/typedpython/java/BinaryTrees.java b/benchmarks/typedpython/java/BinaryTrees.java new file mode 100644 index 000000000..5a4dedc1c --- /dev/null +++ b/benchmarks/typedpython/java/BinaryTrees.java @@ -0,0 +1,33 @@ +public class BinaryTrees { + static final class Node { + final Node left, right; + Node(Node left, Node right) { this.left = left; this.right = right; } + } + + static Node make(int depth) { + if (depth > 0) return new Node(make(depth - 1), make(depth - 1)); + return new Node(null, null); + } + + static int check(Node node) { + Node left = node.left, right = node.right; + if (left == null || right == null) return 1; + return 1 + check(left) + check(right); + } + + public static void main(String[] args) { + int n = Integer.parseInt(args[0]); + int minDepth = 4; + int maxDepth = Math.max(minDepth + 2, n); + int stretch = check(make(maxDepth + 1)); + Node longLived = make(maxDepth); + long total = 0; + for (int depth = minDepth; depth <= maxDepth; depth += 2) { + int iterations = 1 << (maxDepth - depth + minDepth); + long chk = 0; + for (int i = 0; i < iterations; i++) chk += check(make(depth)); + total += chk; + } + System.out.println(stretch + " " + total + " " + check(longLived)); + } +} diff --git a/benchmarks/typedpython/java/Fannkuch.java b/benchmarks/typedpython/java/Fannkuch.java new file mode 100644 index 000000000..2920f348e --- /dev/null +++ b/benchmarks/typedpython/java/Fannkuch.java @@ -0,0 +1,39 @@ +public class Fannkuch { + public static void main(String[] args) { + int n = Integer.parseInt(args[0]); + int[] perm1 = new int[n]; + for (int i = 0; i < n; i++) perm1[i] = i; + int[] count = new int[n]; + int maxFlips = 0, checksum = 0, permCount = 0, r = n; + while (true) { + while (r != 1) { count[r - 1] = r; r--; } + int[] perm = perm1.clone(); + int flips = 0; + int k = perm[0]; + while (k != 0) { + int lo = 0, hi = k; + while (lo < hi) { + int t = perm[lo]; perm[lo] = perm[hi]; perm[hi] = t; + lo++; hi--; + } + flips++; + k = perm[0]; + } + if (flips > maxFlips) maxFlips = flips; + if (permCount % 2 == 0) checksum += flips; else checksum -= flips; + boolean done = false; + while (true) { + if (r == n) { done = true; break; } + int p0 = perm1[0]; + for (int i = 0; i < r; i++) perm1[i] = perm1[i + 1]; + perm1[r] = p0; + count[r]--; + if (count[r] > 0) break; + r++; + } + if (done) break; + permCount++; + } + System.out.println(checksum + " " + maxFlips); + } +} diff --git a/benchmarks/typedpython/java/Nbody.java b/benchmarks/typedpython/java/Nbody.java new file mode 100644 index 000000000..97be4c0c3 --- /dev/null +++ b/benchmarks/typedpython/java/Nbody.java @@ -0,0 +1,74 @@ +import java.util.Locale; + +public class Nbody { + static final double PI = 3.141592653589793; + static final double SOLAR_MASS = 4 * PI * PI; + static final double DAYS = 365.24; + static final double[] x = {0.0, 4.84143144246472090e+00, 8.34336671824457987e+00, 1.28943695621391310e+01, 1.53796971148509165e+01}; + static final double[] y = {0.0, -1.16032004402742839e+00, 4.12479856412430479e+00, -1.51111514016986312e+01, -2.59193146099879641e+01}; + static final double[] z = {0.0, -1.03622044471123109e-01, -4.03523417114321381e-01, -2.23307578892655734e-01, 1.79258772950371181e-01}; + static final double[] vx = {0.0, 1.66007664274403694e-03 * DAYS, -2.76742510726862411e-03 * DAYS, 2.96460137564761618e-03 * DAYS, 2.68067772490389322e-03 * DAYS}; + static final double[] vy = {0.0, 7.69901118419740425e-03 * DAYS, 4.99852801234917238e-03 * DAYS, 2.37847173959480950e-03 * DAYS, 1.62824170038242295e-03 * DAYS}; + static final double[] vz = {0.0, -6.90460016972063023e-05 * DAYS, 2.30417297573763929e-05 * DAYS, -2.96589568540237556e-05 * DAYS, -9.51592254519715870e-05 * DAYS}; + static final double[] mass = {SOLAR_MASS, 9.54791938424326609e-04 * SOLAR_MASS, 2.85885980666130812e-04 * SOLAR_MASS, 4.36624404335156298e-05 * SOLAR_MASS, 5.15138902046611451e-05 * SOLAR_MASS}; + + static void offsetMomentum() { + double px = 0.0, py = 0.0, pz = 0.0; + for (int i = 0; i < 5; i++) { + px += vx[i] * mass[i]; + py += vy[i] * mass[i]; + pz += vz[i] * mass[i]; + } + vx[0] = -px / SOLAR_MASS; + vy[0] = -py / SOLAR_MASS; + vz[0] = -pz / SOLAR_MASS; + } + + static void advance(double dt, int steps) { + for (int s = 0; s < steps; s++) { + for (int i = 0; i < 5; i++) { + for (int j = i + 1; j < 5; j++) { + double dx = x[i] - x[j]; + double dy = y[i] - y[j]; + double dz = z[i] - z[j]; + double d2 = dx * dx + dy * dy + dz * dz; + double mag = dt / (d2 * Math.sqrt(d2)); + vx[i] -= dx * mass[j] * mag; + vy[i] -= dy * mass[j] * mag; + vz[i] -= dz * mass[j] * mag; + vx[j] += dx * mass[i] * mag; + vy[j] += dy * mass[i] * mag; + vz[j] += dz * mass[i] * mag; + } + } + for (int i = 0; i < 5; i++) { + x[i] += dt * vx[i]; + y[i] += dt * vy[i]; + z[i] += dt * vz[i]; + } + } + } + + static double energy() { + double e = 0.0; + for (int i = 0; i < 5; i++) { + e += 0.5 * mass[i] * (vx[i] * vx[i] + vy[i] * vy[i] + vz[i] * vz[i]); + for (int j = i + 1; j < 5; j++) { + double dx = x[i] - x[j]; + double dy = y[i] - y[j]; + double dz = z[i] - z[j]; + e -= mass[i] * mass[j] / Math.sqrt(dx * dx + dy * dy + dz * dz); + } + } + return e; + } + + public static void main(String[] args) { + int n = Integer.parseInt(args[0]); + offsetMomentum(); + double before = energy(); + advance(0.01, n); + double after = energy(); + System.out.println(String.format(Locale.ROOT, "%.9f %.9f", before, after)); + } +} diff --git a/benchmarks/typedpython/java/SpectralNorm.java b/benchmarks/typedpython/java/SpectralNorm.java new file mode 100644 index 000000000..1227c7819 --- /dev/null +++ b/benchmarks/typedpython/java/SpectralNorm.java @@ -0,0 +1,46 @@ +import java.util.Locale; + +public class SpectralNorm { + static double evalA(int i, int j) { + return 1.0 / ((i + j) * (i + j + 1) / 2 + i + 1); + } + + static void mulAv(int n, double[] v, double[] out) { + for (int i = 0; i < n; i++) { + double s = 0.0; + for (int j = 0; j < n; j++) s += evalA(i, j) * v[j]; + out[i] = s; + } + } + + static void mulAtv(int n, double[] v, double[] out) { + for (int i = 0; i < n; i++) { + double s = 0.0; + for (int j = 0; j < n; j++) s += evalA(j, i) * v[j]; + out[i] = s; + } + } + + static void mulAtAv(int n, double[] v, double[] out, double[] tmp) { + mulAv(n, v, tmp); + mulAtv(n, tmp, out); + } + + public static void main(String[] args) { + int n = Integer.parseInt(args[0]); + double[] u = new double[n]; + double[] v = new double[n]; + double[] tmp = new double[n]; + java.util.Arrays.fill(u, 1.0); + for (int k = 0; k < 10; k++) { + mulAtAv(n, u, v, tmp); + mulAtAv(n, v, u, tmp); + } + double vbv = 0.0, vv = 0.0; + for (int i = 0; i < n; i++) { + vbv += u[i] * v[i]; + vv += v[i] * v[i]; + } + System.out.println(String.format(Locale.ROOT, "%.9f", Math.sqrt(vbv / vv))); + } +} diff --git a/benchmarks/typedpython/java/Wordfreq.java b/benchmarks/typedpython/java/Wordfreq.java new file mode 100644 index 000000000..259c7ec07 --- /dev/null +++ b/benchmarks/typedpython/java/Wordfreq.java @@ -0,0 +1,51 @@ +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Wordfreq { + static final long VOCAB = 50000; + static final long MOD = 2147483647L; + + static long nextState(long s) { return (s * 48271) % MOD; } + + static String wordOf(long id) { + long x = id + 17576; + StringBuilder sb = new StringBuilder(); + while (x > 0) { + sb.append((char) (97 + x % 26)); + x /= 26; + } + return sb.reverse().toString(); + } + + public static void main(String[] args) { + int n = Integer.parseInt(args[0]); + Map<String, Integer> counts = new HashMap<>(); + long s = 12345; + for (int i = 0; i < n; i++) { + s = nextState(s); + long a = s % VOCAB; + s = nextState(s); + long b = s % VOCAB; + counts.merge(wordOf(a * b / VOCAB), 1, Integer::sum); + } + long weighted = 0; + for (int c : counts.values()) weighted += (long) c * c; + List<String> taken = new ArrayList<>(); + List<String> parts = new ArrayList<>(); + for (int k = 0; k < 5; k++) { + String bestWord = ""; + int bestCount = -1; + for (Map.Entry<String, Integer> e : counts.entrySet()) { + String w = e.getKey(); + int c = e.getValue(); + if (taken.contains(w)) continue; + if (c > bestCount || (c == bestCount && w.compareTo(bestWord) < 0)) { bestWord = w; bestCount = c; } + } + taken.add(bestWord); + parts.add(bestWord + ":" + bestCount); + } + System.out.println(counts.size() + " " + weighted + " " + String.join(",", parts)); + } +} diff --git a/benchmarks/typedpython/js/binary_trees.mjs b/benchmarks/typedpython/js/binary_trees.mjs new file mode 100644 index 000000000..e0d6b45a9 --- /dev/null +++ b/benchmarks/typedpython/js/binary_trees.mjs @@ -0,0 +1,25 @@ +class Node { + constructor(left, right) { this.left = left; this.right = right; } +} +function make(depth) { + if (depth > 0) return new Node(make(depth - 1), make(depth - 1)); + return new Node(null, null); +} +function check(node) { + const left = node.left, right = node.right; + if (left === null || right === null) return 1; + return 1 + check(left) + check(right); +} +const n = parseInt(process.argv[2], 10); +const minDepth = 4; +const maxDepth = Math.max(minDepth + 2, n); +const stretch = check(make(maxDepth + 1)); +const longLived = make(maxDepth); +let total = 0; +for (let depth = minDepth; depth <= maxDepth; depth += 2) { + const iterations = 1 << (maxDepth - depth + minDepth); + let chk = 0; + for (let i = 0; i < iterations; i++) chk += check(make(depth)); + total += chk; +} +console.log(`${stretch} ${total} ${check(longLived)}`); diff --git a/benchmarks/typedpython/js/fannkuch.mjs b/benchmarks/typedpython/js/fannkuch.mjs new file mode 100644 index 000000000..d9f0d4bf6 --- /dev/null +++ b/benchmarks/typedpython/js/fannkuch.mjs @@ -0,0 +1,34 @@ +function fannkuch(n) { + const perm1 = Array.from({ length: n }, (_, i) => i); + const count = new Array(n).fill(0); + let maxFlips = 0, checksum = 0, permCount = 0, r = n; + for (;;) { + while (r !== 1) { count[r - 1] = r; r--; } + const perm = perm1.slice(); + let flips = 0; + let k = perm[0]; + while (k !== 0) { + let lo = 0, hi = k; + while (lo < hi) { + const t = perm[lo]; perm[lo] = perm[hi]; perm[hi] = t; + lo++; hi--; + } + flips++; + k = perm[0]; + } + if (flips > maxFlips) maxFlips = flips; + if (permCount % 2 === 0) checksum += flips; else checksum -= flips; + for (;;) { + if (r === n) return [checksum, maxFlips]; + const p0 = perm1[0]; + for (let i = 0; i < r; i++) perm1[i] = perm1[i + 1]; + perm1[r] = p0; + count[r]--; + if (count[r] > 0) break; + r++; + } + permCount++; + } +} +const [checksum, maxFlips] = fannkuch(parseInt(process.argv[2], 10)); +console.log(`${checksum} ${maxFlips}`); diff --git a/benchmarks/typedpython/js/nbody.mjs b/benchmarks/typedpython/js/nbody.mjs new file mode 100644 index 000000000..41f8dac8d --- /dev/null +++ b/benchmarks/typedpython/js/nbody.mjs @@ -0,0 +1,68 @@ +const PI = 3.141592653589793; +const SOLAR_MASS = 4 * PI * PI; +const DAYS = 365.24; +const x = [0.0, 4.84143144246472090e+00, 8.34336671824457987e+00, 1.28943695621391310e+01, 1.53796971148509165e+01]; +const y = [0.0, -1.16032004402742839e+00, 4.12479856412430479e+00, -1.51111514016986312e+01, -2.59193146099879641e+01]; +const z = [0.0, -1.03622044471123109e-01, -4.03523417114321381e-01, -2.23307578892655734e-01, 1.79258772950371181e-01]; +const vx = [0.0, 1.66007664274403694e-03 * DAYS, -2.76742510726862411e-03 * DAYS, 2.96460137564761618e-03 * DAYS, 2.68067772490389322e-03 * DAYS]; +const vy = [0.0, 7.69901118419740425e-03 * DAYS, 4.99852801234917238e-03 * DAYS, 2.37847173959480950e-03 * DAYS, 1.62824170038242295e-03 * DAYS]; +const vz = [0.0, -6.90460016972063023e-05 * DAYS, 2.30417297573763929e-05 * DAYS, -2.96589568540237556e-05 * DAYS, -9.51592254519715870e-05 * DAYS]; +const mass = [SOLAR_MASS, 9.54791938424326609e-04 * SOLAR_MASS, 2.85885980666130812e-04 * SOLAR_MASS, 4.36624404335156298e-05 * SOLAR_MASS, 5.15138902046611451e-05 * SOLAR_MASS]; + +function offsetMomentum() { + let px = 0.0, py = 0.0, pz = 0.0; + for (let i = 0; i < 5; i++) { + px += vx[i] * mass[i]; + py += vy[i] * mass[i]; + pz += vz[i] * mass[i]; + } + vx[0] = -px / SOLAR_MASS; + vy[0] = -py / SOLAR_MASS; + vz[0] = -pz / SOLAR_MASS; +} + +function advance(dt, steps) { + for (let s = 0; s < steps; s++) { + for (let i = 0; i < 5; i++) { + for (let j = i + 1; j < 5; j++) { + const dx = x[i] - x[j]; + const dy = y[i] - y[j]; + const dz = z[i] - z[j]; + const d2 = dx * dx + dy * dy + dz * dz; + const mag = dt / (d2 * Math.sqrt(d2)); + vx[i] -= dx * mass[j] * mag; + vy[i] -= dy * mass[j] * mag; + vz[i] -= dz * mass[j] * mag; + vx[j] += dx * mass[i] * mag; + vy[j] += dy * mass[i] * mag; + vz[j] += dz * mass[i] * mag; + } + } + for (let i = 0; i < 5; i++) { + x[i] += dt * vx[i]; + y[i] += dt * vy[i]; + z[i] += dt * vz[i]; + } + } +} + +function energy() { + let e = 0.0; + for (let i = 0; i < 5; i++) { + e += 0.5 * mass[i] * (vx[i] * vx[i] + vy[i] * vy[i] + vz[i] * vz[i]); + for (let j = i + 1; j < 5; j++) { + const dx = x[i] - x[j]; + const dy = y[i] - y[j]; + const dz = z[i] - z[j]; + e -= mass[i] * mass[j] / Math.sqrt(dx * dx + dy * dy + dz * dz); + } + } + return e; +} + +const n = parseInt(process.argv[2], 10); +offsetMomentum(); +const before = energy(); +advance(0.01, n); +const after = energy(); +console.log(`${before.toFixed(9)} ${after.toFixed(9)}`); diff --git a/benchmarks/typedpython/js/spectral_norm.mjs b/benchmarks/typedpython/js/spectral_norm.mjs new file mode 100644 index 000000000..705dd13c7 --- /dev/null +++ b/benchmarks/typedpython/js/spectral_norm.mjs @@ -0,0 +1,35 @@ +function evalA(i, j) { + return 1.0 / (Math.floor((i + j) * (i + j + 1) / 2) + i + 1); +} +function mulAv(n, v, out) { + for (let i = 0; i < n; i++) { + let s = 0.0; + for (let j = 0; j < n; j++) s += evalA(i, j) * v[j]; + out[i] = s; + } +} +function mulAtv(n, v, out) { + for (let i = 0; i < n; i++) { + let s = 0.0; + for (let j = 0; j < n; j++) s += evalA(j, i) * v[j]; + out[i] = s; + } +} +function mulAtAv(n, v, out, tmp) { + mulAv(n, v, tmp); + mulAtv(n, tmp, out); +} +const n = parseInt(process.argv[2], 10); +const u = new Array(n).fill(1.0); +const v = new Array(n).fill(0.0); +const tmp = new Array(n).fill(0.0); +for (let k = 0; k < 10; k++) { + mulAtAv(n, u, v, tmp); + mulAtAv(n, v, u, tmp); +} +let vbv = 0.0, vv = 0.0; +for (let i = 0; i < n; i++) { + vbv += u[i] * v[i]; + vv += v[i] * v[i]; +} +console.log(Math.sqrt(vbv / vv).toFixed(9)); diff --git a/benchmarks/typedpython/js/wordfreq.mjs b/benchmarks/typedpython/js/wordfreq.mjs new file mode 100644 index 000000000..539d01b93 --- /dev/null +++ b/benchmarks/typedpython/js/wordfreq.mjs @@ -0,0 +1,38 @@ +const VOCAB = 50000; +const MOD = 2147483647; +function nextState(s) { return (s * 48271) % MOD; } +function wordOf(id) { + let x = id + 17576; + let w = ""; + while (x > 0) { + w = String.fromCharCode(97 + (x % 26)) + w; + x = Math.floor(x / 26); + } + return w; +} +const n = parseInt(process.argv[2], 10); +const counts = new Map(); +let s = 12345; +for (let i = 0; i < n; i++) { + s = nextState(s); + const a = s % VOCAB; + s = nextState(s); + const b = s % VOCAB; + const word = wordOf(Math.floor((a * b) / VOCAB)); + counts.set(word, (counts.get(word) ?? 0) + 1); +} +let weighted = 0; +for (const [, c] of counts) weighted += c * c; +const taken = []; +const parts = []; +for (let k = 0; k < 5; k++) { + let bestWord = ""; + let bestCount = -1; + for (const [w, c] of counts) { + if (taken.includes(w)) continue; + if (c > bestCount || (c === bestCount && w < bestWord)) { bestWord = w; bestCount = c; } + } + taken.push(bestWord); + parts.push(`${bestWord}:${bestCount}`); +} +console.log(`${counts.size} ${weighted} ${parts.join(",")}`); diff --git a/benchmarks/typedpython/py/binary_trees.py b/benchmarks/typedpython/py/binary_trees.py new file mode 100644 index 000000000..9ab44c379 --- /dev/null +++ b/benchmarks/typedpython/py/binary_trees.py @@ -0,0 +1,44 @@ +import sys + + +class Node: + left: "Node | None" + right: "Node | None" + + def __init__(self, left: "Node | None", right: "Node | None") -> None: + self.left = left + self.right = right + + +def make(depth: int) -> Node: + if depth > 0: + return Node(make(depth - 1), make(depth - 1)) + return Node(None, None) + + +def check(node: Node) -> int: + left: Node | None = node.left + right: Node | None = node.right + if left is None or right is None: + return 1 + return 1 + check(left) + check(right) + + +def main() -> None: + n: int = int(sys.argv[1]) + min_depth: int = 4 + max_depth: int = max(min_depth + 2, n) + stretch: int = check(make(max_depth + 1)) + long_lived: Node = make(max_depth) + total: int = 0 + for depth in range(min_depth, max_depth + 1, 2): + iterations: int = 1 << (max_depth - depth + min_depth) + chk: int = 0 + for _ in range(iterations): + chk += check(make(depth)) + total += chk + print(f"{stretch} {total} {check(long_lived)}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/typedpython/py/fannkuch.py b/benchmarks/typedpython/py/fannkuch.py new file mode 100644 index 000000000..cdd9f5b4c --- /dev/null +++ b/benchmarks/typedpython/py/fannkuch.py @@ -0,0 +1,56 @@ +import sys + + +def fannkuch(n: int) -> tuple[int, int]: + perm1: list[int] = list(range(n)) + count: list[int] = [0] * n + max_flips: int = 0 + checksum: int = 0 + perm_count: int = 0 + r: int = n + while True: + while r != 1: + count[r - 1] = r + r -= 1 + perm: list[int] = perm1[:] + flips: int = 0 + k: int = perm[0] + while k != 0: + lo: int = 0 + hi: int = k + while lo < hi: + t: int = perm[lo] + perm[lo] = perm[hi] + perm[hi] = t + lo += 1 + hi -= 1 + flips += 1 + k = perm[0] + if flips > max_flips: + max_flips = flips + if perm_count % 2 == 0: + checksum += flips + else: + checksum -= flips + while True: + if r == n: + return checksum, max_flips + p0: int = perm1[0] + for i in range(r): + perm1[i] = perm1[i + 1] + perm1[r] = p0 + count[r] -= 1 + if count[r] > 0: + break + r += 1 + perm_count += 1 + + +def main() -> None: + n: int = int(sys.argv[1]) + checksum, max_flips = fannkuch(n) + print(f"{checksum} {max_flips}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/typedpython/py/nbody.py b/benchmarks/typedpython/py/nbody.py new file mode 100644 index 000000000..c67838312 --- /dev/null +++ b/benchmarks/typedpython/py/nbody.py @@ -0,0 +1,106 @@ +import math +import sys + +PI: float = 3.141592653589793 +SOLAR_MASS: float = 4 * PI * PI +DAYS_PER_YEAR: float = 365.24 + + +def make_x() -> list[float]: + return [0.0, 4.84143144246472090e+00, 8.34336671824457987e+00, 1.28943695621391310e+01, 1.53796971148509165e+01] + + +def make_y() -> list[float]: + return [0.0, -1.16032004402742839e+00, 4.12479856412430479e+00, -1.51111514016986312e+01, -2.59193146099879641e+01] + + +def make_z() -> list[float]: + return [0.0, -1.03622044471123109e-01, -4.03523417114321381e-01, -2.23307578892655734e-01, 1.79258772950371181e-01] + + +def make_vx() -> list[float]: + return [0.0, 1.66007664274403694e-03 * DAYS_PER_YEAR, -2.76742510726862411e-03 * DAYS_PER_YEAR, + 2.96460137564761618e-03 * DAYS_PER_YEAR, 2.68067772490389322e-03 * DAYS_PER_YEAR] + + +def make_vy() -> list[float]: + return [0.0, 7.69901118419740425e-03 * DAYS_PER_YEAR, 4.99852801234917238e-03 * DAYS_PER_YEAR, + 2.37847173959480950e-03 * DAYS_PER_YEAR, 1.62824170038242295e-03 * DAYS_PER_YEAR] + + +def make_vz() -> list[float]: + return [0.0, -6.90460016972063023e-05 * DAYS_PER_YEAR, 2.30417297573763929e-05 * DAYS_PER_YEAR, + -2.96589568540237556e-05 * DAYS_PER_YEAR, -9.51592254519715870e-05 * DAYS_PER_YEAR] + + +def make_mass() -> list[float]: + return [SOLAR_MASS, 9.54791938424326609e-04 * SOLAR_MASS, 2.85885980666130812e-04 * SOLAR_MASS, + 4.36624404335156298e-05 * SOLAR_MASS, 5.15138902046611451e-05 * SOLAR_MASS] + + +def offset_momentum(vx: list[float], vy: list[float], vz: list[float], mass: list[float]) -> None: + px: float = 0.0 + py: float = 0.0 + pz: float = 0.0 + for i in range(5): + px += vx[i] * mass[i] + py += vy[i] * mass[i] + pz += vz[i] * mass[i] + vx[0] = -px / SOLAR_MASS + vy[0] = -py / SOLAR_MASS + vz[0] = -pz / SOLAR_MASS + + +def advance(dt: float, steps: int, x: list[float], y: list[float], z: list[float], + vx: list[float], vy: list[float], vz: list[float], mass: list[float]) -> None: + for _ in range(steps): + for i in range(5): + for j in range(i + 1, 5): + dx: float = x[i] - x[j] + dy: float = y[i] - y[j] + dz: float = z[i] - z[j] + d2: float = dx * dx + dy * dy + dz * dz + mag: float = dt / (d2 * math.sqrt(d2)) + vx[i] -= dx * mass[j] * mag + vy[i] -= dy * mass[j] * mag + vz[i] -= dz * mass[j] * mag + vx[j] += dx * mass[i] * mag + vy[j] += dy * mass[i] * mag + vz[j] += dz * mass[i] * mag + for i in range(5): + x[i] += dt * vx[i] + y[i] += dt * vy[i] + z[i] += dt * vz[i] + + +def energy(x: list[float], y: list[float], z: list[float], + vx: list[float], vy: list[float], vz: list[float], mass: list[float]) -> float: + e: float = 0.0 + for i in range(5): + e += 0.5 * mass[i] * (vx[i] * vx[i] + vy[i] * vy[i] + vz[i] * vz[i]) + for j in range(i + 1, 5): + dx: float = x[i] - x[j] + dy: float = y[i] - y[j] + dz: float = z[i] - z[j] + e -= mass[i] * mass[j] / math.sqrt(dx * dx + dy * dy + dz * dz) + return e + + +def main() -> None: + n: int = int(sys.argv[1]) + x = make_x() + y = make_y() + z = make_z() + vx = make_vx() + vy = make_vy() + vz = make_vz() + mass = make_mass() + offset_momentum(vx, vy, vz, mass) + before: float = energy(x, y, z, vx, vy, vz, mass) + advance(0.01, n, x, y, z, vx, vy, vz, mass) + after: float = energy(x, y, z, vx, vy, vz, mass) + print(f"{before:.9f} {after:.9f}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/typedpython/py/spectral_norm.py b/benchmarks/typedpython/py/spectral_norm.py new file mode 100644 index 000000000..e4d29091a --- /dev/null +++ b/benchmarks/typedpython/py/spectral_norm.py @@ -0,0 +1,47 @@ +import math +import sys + + +def eval_a(i: int, j: int) -> float: + return 1.0 / ((i + j) * (i + j + 1) // 2 + i + 1) + + +def mul_av(n: int, v: list[float], out: list[float]) -> None: + for i in range(n): + s: float = 0.0 + for j in range(n): + s += eval_a(i, j) * v[j] + out[i] = s + + +def mul_atv(n: int, v: list[float], out: list[float]) -> None: + for i in range(n): + s: float = 0.0 + for j in range(n): + s += eval_a(j, i) * v[j] + out[i] = s + + +def mul_atav(n: int, v: list[float], out: list[float], tmp: list[float]) -> None: + mul_av(n, v, tmp) + mul_atv(n, tmp, out) + + +def main() -> None: + n: int = int(sys.argv[1]) + u: list[float] = [1.0] * n + v: list[float] = [0.0] * n + tmp: list[float] = [0.0] * n + for _ in range(10): + mul_atav(n, u, v, tmp) + mul_atav(n, v, u, tmp) + vbv: float = 0.0 + vv: float = 0.0 + for i in range(n): + vbv += u[i] * v[i] + vv += v[i] * v[i] + print(f"{math.sqrt(vbv / vv):.9f}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/typedpython/py/wordfreq.py b/benchmarks/typedpython/py/wordfreq.py new file mode 100644 index 000000000..8e3542481 --- /dev/null +++ b/benchmarks/typedpython/py/wordfreq.py @@ -0,0 +1,51 @@ +import sys + +VOCAB: int = 50000 +MOD: int = 2147483647 + + +def next_state(s: int) -> int: + return (s * 48271) % MOD + + +def word_of(word_id: int) -> str: + x: int = word_id + 17576 + w: str = "" + while x > 0: + w = chr(97 + x % 26) + w + x //= 26 + return w + + +def main() -> None: + n: int = int(sys.argv[1]) + counts: dict[str, int] = {} + s: int = 12345 + for _ in range(n): + s = next_state(s) + a: int = s % VOCAB + s = next_state(s) + b: int = s % VOCAB + word: str = word_of(a * b // VOCAB) + counts[word] = counts.get(word, 0) + 1 + weighted: int = 0 + for w, c in counts.items(): + weighted += c * c + taken: list[str] = [] + parts: list[str] = [] + for _ in range(5): + best_word: str = "" + best_count: int = -1 + for w, c in counts.items(): + if w in taken: + continue + if c > best_count or (c == best_count and w < best_word): + best_word = w + best_count = c + taken.append(best_word) + parts.append(f"{best_word}:{best_count}") + print(f"{len(counts)} {weighted} {','.join(parts)}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/typedpython/results/2026-10-03-irackui-Macmini.local-interleaved.json b/benchmarks/typedpython/results/2026-10-03-irackui-Macmini.local-interleaved.json new file mode 100644 index 000000000..bc94b7aa9 --- /dev/null +++ b/benchmarks/typedpython/results/2026-10-03-irackui-Macmini.local-interleaved.json @@ -0,0 +1,499 @@ +{ + "date": "2026-10-03T18:53:55", + "host": "irackui-Macmini.local", + "method": "interleaved A/B", + "pairs": 11, + "results": { + "nbody": { + "size": "1000000", + "compiled_functions": [ + "offset_momentum", + "advance", + "energy" + ], + "pairs": [ + { + "cpython_s": 6.8829106669873, + "compiled_s": 0.5073095830157399, + "ratio": 13.56747614754549, + "load": [ + 35.9130859375, + 65.2080078125, + 57.60107421875 + ] + }, + { + "cpython_s": 9.880680541973561, + "compiled_s": 0.21239254099782556, + "ratio": 46.52084529689165, + "load": [ + 34.23828125, + 64.3740234375, + 57.35107421875 + ] + }, + { + "cpython_s": 7.175678791012615, + "compiled_s": 0.22394145803991705, + "ratio": 32.04265460187174, + "load": [ + 29.453125, + 61.8271484375, + 56.56396484375 + ] + }, + { + "cpython_s": 5.955677040969022, + "compiled_s": 0.20967479201499373, + "ratio": 28.4043541130263, + "load": [ + 29.49658203125, + 61.29833984375, + 56.408203125 + ] + }, + { + "cpython_s": 6.802065708092414, + "compiled_s": 0.173232915927656, + "ratio": 39.26543446819906, + "load": [ + 27.69482421875, + 60.396484375, + 56.11865234375 + ] + }, + { + "cpython_s": 7.18817412503995, + "compiled_s": 0.1662108750315383, + "ratio": 43.24731533767572, + "load": [ + 23.97802734375, + 58.5224609375, + 55.50341796875 + ] + }, + { + "cpython_s": 6.393004250014201, + "compiled_s": 0.231951666995883, + "ratio": 27.561794803258184, + "load": [ + 23.1787109375, + 57.783203125, + 55.259765625 + ] + }, + { + "cpython_s": 6.450308125000447, + "compiled_s": 0.40894424996804446, + "ratio": 15.77307450955597, + "load": [ + 22.04296875, + 56.97314453125, + 54.98828125 + ] + }, + { + "cpython_s": 6.900172666995786, + "compiled_s": 0.28140733297914267, + "ratio": 24.520230492739906, + "load": [ + 23.5185546875, + 56.134765625, + 54.7138671875 + ] + }, + { + "cpython_s": 8.56268395902589, + "compiled_s": 0.30038412497378886, + "ratio": 28.505780589347253, + "load": [ + 22.99609375, + 55.48486328125, + 54.49267578125 + ] + }, + { + "cpython_s": 7.601551624946296, + "compiled_s": 0.18389916594605893, + "ratio": 41.33543284897754, + "load": [ + 21.09033203125, + 54.00390625, + 53.978515625 + ] + } + ], + "median_ratio": 28.505780589347253, + "min_ratio": 13.56747614754549, + "max_ratio": 46.52084529689165 + }, + "spectral-norm": { + "size": "800", + "compiled_functions": [ + "eval_a", + "mul_av", + "mul_atv" + ], + "pairs": [ + { + "cpython_s": 4.924908375018276, + "compiled_s": 4.0018195828888565, + "ratio": 1.2306672684786693, + "load": [ + 18.984375, + 52.47119140625, + 53.43505859375 + ] + }, + { + "cpython_s": 9.198930458980612, + "compiled_s": 0.32776887505315244, + "ratio": 28.065295881095707, + "load": [ + 17.8642578125, + 51.6826171875, + 53.15087890625 + ] + }, + { + "cpython_s": 6.263924417085946, + "compiled_s": 0.398825958953239, + "ratio": 15.705909498785582, + "load": [ + 16.84716796875, + 50.34423828125, + 52.658203125 + ] + }, + { + "cpython_s": 6.872534624999389, + "compiled_s": 0.49479795806109905, + "ratio": 13.889577580170105, + "load": [ + 15.78759765625, + 49.01513671875, + 52.15966796875 + ] + }, + { + "cpython_s": 7.051057124976069, + "compiled_s": 0.3943999590119347, + "ratio": 17.877935744822683, + "load": [ + 15.40380859375, + 48.3837890625, + 51.91845703125 + ] + }, + { + "cpython_s": 8.140147833968513, + "compiled_s": 0.2962452080100775, + "ratio": 27.4777367325098, + "load": [ + 15.10107421875, + 47.23388671875, + 51.46875 + ] + }, + { + "cpython_s": 8.249426209018566, + "compiled_s": 0.29253941599745303, + "ratio": 28.199366505504983, + "load": [ + 15.0927734375, + 46.69873046875, + 51.2548828125 + ] + }, + { + "cpython_s": 5.723211084026843, + "compiled_s": 0.3193328339839354, + "ratio": 17.922400940188815, + "load": [ + 16.6826171875, + 46.0, + 50.953125 + ] + }, + { + "cpython_s": 10.588757708086632, + "compiled_s": 0.31157975003588945, + "ratio": 33.984101042724895, + "load": [ + 16.6279296875, + 45.501953125, + 50.748046875 + ] + }, + { + "cpython_s": 5.798110875068232, + "compiled_s": 0.3391578750452027, + "ratio": 17.09561033868214, + "load": [ + 17.20361328125, + 44.6640625, + 50.388671875 + ] + }, + { + "cpython_s": 6.154926875024103, + "compiled_s": 0.3255474999314174, + "ratio": 18.906386552870938, + "load": [ + 21.20751953125, + 44.6103515625, + 50.30224609375 + ] + } + ], + "median_ratio": 17.922400940188815, + "min_ratio": 1.2306672684786693, + "max_ratio": 33.984101042724895 + }, + "fannkuch-redux": { + "size": "10", + "compiled_functions": [ + "fannkuch" + ], + "pairs": [ + { + "cpython_s": 9.915836833999492, + "compiled_s": 0.8964975839480758, + "ratio": 11.060639773652538, + "load": [ + 23.1123046875, + 44.61669921875, + 50.27099609375 + ] + }, + { + "cpython_s": 10.107461291947402, + "compiled_s": 0.32782216591294855, + "ratio": 30.832147252151902, + "load": [ + 21.8037109375, + 43.62548828125, + 49.8525390625 + ] + }, + { + "cpython_s": 9.16554791701492, + "compiled_s": 0.4036110829329118, + "ratio": 22.708860842997257, + "load": [ + 20.40185546875, + 42.60107421875, + 49.41552734375 + ] + }, + { + "cpython_s": 10.748112207977101, + "compiled_s": 0.37328437506221235, + "ratio": 28.79336218181058, + "load": [ + 20.79443359375, + 41.955078125, + 49.1064453125 + ] + }, + { + "cpython_s": 10.753968499950133, + "compiled_s": 0.4365679590264335, + "ratio": 24.632977014465226, + "load": [ + 19.36181640625, + 40.95166015625, + 48.66650390625 + ] + }, + { + "cpython_s": 9.816678624949418, + "compiled_s": 0.4199373750016093, + "ratio": 23.37652995261924, + "load": [ + 18.376953125, + 40.0302734375, + 48.24951171875 + ] + }, + { + "cpython_s": 12.343774041044526, + "compiled_s": 0.4121953749563545, + "ratio": 29.946415682979346, + "load": [ + 17.77099609375, + 39.1884765625, + 47.85498046875 + ] + }, + { + "cpython_s": 14.80802058300469, + "compiled_s": 0.4219444999471307, + "ratio": 35.09471170938387, + "load": [ + 17.26904296875, + 38.0078125, + 47.28076171875 + ] + }, + { + "cpython_s": 11.679267625091597, + "compiled_s": 0.3056710830423981, + "ratio": 38.20861138988285, + "load": [ + 18.07421875, + 37.16015625, + 46.8154296875 + ] + }, + { + "cpython_s": 11.083661083946936, + "compiled_s": 0.34054337500128895, + "ratio": 32.54698783643929, + "load": [ + 20.28173828125, + 37.005859375, + 46.64794921875 + ] + }, + { + "cpython_s": 8.800149749964476, + "compiled_s": 0.3017941250000149, + "ratio": 29.15944685790037, + "load": [ + 18.33642578125, + 35.74951171875, + 46.0302734375 + ] + } + ], + "median_ratio": 29.15944685790037, + "min_ratio": 11.060639773652538, + "max_ratio": 38.20861138988285 + }, + "binary-trees": { + "skipped": "n/a (nothing compiled)" + }, + "wordfreq": { + "size": "3000000", + "compiled_functions": [ + "next_state" + ], + "pairs": [ + { + "cpython_s": 3.5079852089984342, + "compiled_s": 3.2369775410043076, + "ratio": 1.0837224431004373, + "load": [ + 16.8203125, + 34.8515625, + 45.59130859375 + ] + }, + { + "cpython_s": 2.966251999954693, + "compiled_s": 3.1815978749655187, + "ratio": 0.9323151814045137, + "load": [ + 15.95361328125, + 34.37255859375, + 45.35888671875 + ] + }, + { + "cpython_s": 3.2128454169724137, + "compiled_s": 3.2285989999072626, + "ratio": 0.9951206133263061, + "load": [ + 15.396484375, + 33.951171875, + 45.1455078125 + ] + }, + { + "cpython_s": 3.67526158306282, + "compiled_s": 3.305175666930154, + "ratio": 1.1119716328047404, + "load": [ + 14.8037109375, + 33.52001953125, + 44.927734375 + ] + }, + { + "cpython_s": 3.4512535840040073, + "compiled_s": 3.7556770410155877, + "ratio": 0.9189431216563658, + "load": [ + 14.0712890625, + 32.74560546875, + 44.51904296875 + ] + }, + { + "cpython_s": 3.1576330839889124, + "compiled_s": 2.782175458036363, + "ratio": 1.134951095506228, + "load": [ + 13.6650390625, + 32.35107421875, + 44.310546875 + ] + }, + { + "cpython_s": 3.071971583063714, + "compiled_s": 2.6286917090183124, + "ratio": 1.168631366137243, + "load": [ + 13.69140625, + 32.04638671875, + 44.1328125 + ] + }, + { + "cpython_s": 3.205945707974024, + "compiled_s": 3.273688666988164, + "ratio": 0.9793068413324519, + "load": [ + 12.26806640625, + 31.138671875, + 43.66943359375 + ] + }, + { + "cpython_s": 3.042461832985282, + "compiled_s": 2.450655375025235, + "ratio": 1.2414890579847249, + "load": [ + 13.28759765625, + 31.03662109375, + 43.5595703125 + ] + }, + { + "cpython_s": 3.65517991699744, + "compiled_s": 2.2709438339807093, + "ratio": 1.6095421922392157, + "load": [ + 12.78369140625, + 30.63720703125, + 43.34521484375 + ] + }, + { + "cpython_s": 4.242163749993779, + "compiled_s": 4.385258833062835, + "ratio": 0.9673690679350133, + "load": [ + 12.0, + 30.17822265625, + 43.1083984375 + ] + } + ], + "median_ratio": 1.0837224431004373, + "min_ratio": 0.9189431216563658, + "max_ratio": 1.6095421922392157 + } + } +} diff --git a/benchmarks/typedpython/results/2026-10-03-irackui-Macmini.local.json b/benchmarks/typedpython/results/2026-10-03-irackui-Macmini.local.json new file mode 100644 index 000000000..605b49ecb --- /dev/null +++ b/benchmarks/typedpython/results/2026-10-03-irackui-Macmini.local.json @@ -0,0 +1,298 @@ +{ + "date": "2026-10-03T12:06:11", + "host": "irackui-Macmini.local", + "load_average": [ + 1.60009765625, + 1.54541015625, + 1.46533203125 + ], + "repeat": 5, + "python": "Python 3.13.0", + "results": { + "nbody": { + "size": "1000000", + "CPython": { + "median_s": 4.143098374945112, + "runs_s": [ + 4.254897207953036, + 4.221641207928769, + 4.143098374945112, + 4.065442958031781, + 4.0204505829606205 + ], + "output": [ + "-0.169075164 -0.169086185" + ] + }, + "Node": { + "median_s": 0.13044004200492054, + "runs_s": [ + 0.24463354202453047, + 0.11540012492332608, + 0.11569995793979615, + 0.13044004200492054, + 0.13507441605906934 + ], + "output": [ + "-0.169075164 -0.169086185" + ] + }, + "Java": { + "median_s": 0.18665479100309312, + "runs_s": [ + 0.18403883394785225, + 0.18665479100309312, + 0.18962366704363376, + 0.19042083295062184, + 0.17468633397947997 + ], + "output": [ + "-0.169075164 -0.169086185" + ] + }, + "Rust": { + "median_s": 0.06139945797622204, + "runs_s": [ + 0.6079301250865683, + 0.06139945797622204, + 0.061590292025357485, + 0.06120649992953986, + 0.060862750047817826 + ], + "output": [ + "-0.169075164 -0.169086185" + ] + }, + "TypedPython @compiled": "n/a (not built)", + "outputs_agree": true + }, + "spectral-norm": { + "size": "800", + "CPython": { + "median_s": 3.440628374926746, + "runs_s": [ + 3.2999370829202235, + 3.4090167910326272, + 3.440628374926746, + 3.4689366659149528, + 3.444350333069451 + ], + "output": [ + "1.274224144" + ] + }, + "Node": { + "median_s": 0.07610966707579792, + "runs_s": [ + 0.08152287499979138, + 0.07610966707579792, + 0.07956900005228817, + 0.07009887497406453, + 0.07070591708179563 + ], + "output": [ + "1.274224144" + ] + }, + "Java": { + "median_s": 0.11629029200412333, + "runs_s": [ + 0.22726075001992285, + 0.11629029200412333, + 0.11338383296970278, + 0.11177445796784014, + 0.12072145799174905 + ], + "output": [ + "1.274224144" + ] + }, + "Rust": { + "median_s": 0.027435958036221564, + "runs_s": [ + 0.38848837499972433, + 0.027392458054237068, + 0.027403999934904277, + 0.027435958036221564, + 0.027970083989202976 + ], + "output": [ + "1.274224144" + ] + }, + "TypedPython @compiled": "n/a (not built)", + "outputs_agree": true + }, + "fannkuch-redux": { + "size": "10", + "CPython": { + "median_s": 6.196417958009988, + "runs_s": [ + 6.150234500062652, + 6.2536906249588355, + 6.23115054203663, + 6.0902174999937415, + 6.196417958009988 + ], + "output": [ + "73196 38" + ] + }, + "Node": { + "median_s": 0.27098512498196214, + "runs_s": [ + 0.3849453340517357, + 0.28581020794808865, + 0.27098512498196214, + 0.26284683402627707, + 0.26294095802586526 + ], + "output": [ + "73196 38" + ] + }, + "Java": { + "median_s": 0.2690964579815045, + "runs_s": [ + 0.3637276670197025, + 0.2642790839308873, + 0.28995737503282726, + 0.2690964579815045, + 0.25848933297675103 + ], + "output": [ + "73196 38" + ] + }, + "Rust": { + "median_s": 0.2610693749738857, + "runs_s": [ + 0.6989039160544053, + 0.2595427499618381, + 0.2610693749738857, + 0.2637084999587387, + 0.2530785839771852 + ], + "output": [ + "73196 38" + ] + }, + "TypedPython @compiled": "n/a (not built)", + "outputs_agree": true + }, + "binary-trees": { + "size": "16", + "CPython": { + "median_s": 3.9383531670318916, + "runs_s": [ + 3.9383531670318916, + 4.0666959170484915, + 3.971159166889265, + 3.920735542080365, + 3.825213333009742 + ], + "output": [ + "262143 14592688 131071" + ] + }, + "Node": { + "median_s": 0.17200924991630018, + "runs_s": [ + 0.17211587505880743, + 0.17382529203314334, + 0.17200924991630018, + 0.17031329206656665, + 0.17060462501831353 + ], + "output": [ + "262143 14592688 131071" + ] + }, + "Java": { + "median_s": 0.16285908303689212, + "runs_s": [ + 0.1594726659823209, + 0.16640029195696115, + 0.1694065419724211, + 0.16285908303689212, + 0.1576562919653952 + ], + "output": [ + "262143 14592688 131071" + ] + }, + "Rust": { + "median_s": 0.2658698750892654, + "runs_s": [ + 0.6191935000242665, + 0.2656571250408888, + 0.2635289159370586, + 0.2658698750892654, + 0.2694279169663787 + ], + "output": [ + "262143 14592688 131071" + ] + }, + "TypedPython @compiled": "n/a (not built)", + "outputs_agree": true + }, + "wordfreq": { + "size": "3000000", + "CPython": { + "median_s": 2.5091819160152227, + "runs_s": [ + 2.477464916999452, + 2.5331434999825433, + 2.43217316607479, + 2.5359187909634784, + 2.5091819160152227 + ], + "output": [ + "49165 362711646 baaa:808,baab:653,baad:613,baac:576,baaf:566" + ] + }, + "Node": { + "median_s": 0.9722600829554722, + "runs_s": [ + 0.9722600829554722, + 0.9506856669904664, + 1.0822530830046162, + 0.974125292035751, + 0.9677200420992449 + ], + "output": [ + "49165 362711646 baaa:808,baab:653,baad:613,baac:576,baaf:566" + ] + }, + "Java": { + "median_s": 0.3431811250047758, + "runs_s": [ + 0.3431811250047758, + 0.3633901249850169, + 0.33033525000791997, + 0.3405763750197366, + 0.3798126250039786 + ], + "output": [ + "49165 362711646 baaa:808,baab:653,baad:613,baac:576,baaf:566" + ] + }, + "Rust": { + "median_s": 0.16358300007414073, + "runs_s": [ + 0.5581229169620201, + 0.2117745839059353, + 0.16358300007414073, + 0.1619741659378633, + 0.1581742090638727 + ], + "output": [ + "49165 362711646 baaa:808,baab:653,baad:613,baac:576,baaf:566" + ] + }, + "TypedPython @compiled": "n/a (not built)", + "outputs_agree": true + } + } +} diff --git a/benchmarks/typedpython/run.py b/benchmarks/typedpython/run.py new file mode 100644 index 000000000..288cfd746 --- /dev/null +++ b/benchmarks/typedpython/run.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""TypedPython benchmark runner (issue #24). Stdlib only. + + python3 run.py verify + python3 run.py measure [--repeat N] [--python PY] [--force] [--write] + +`verify` runs every implementation at a small size and requires identical output. +`measure` reports the median wall time over N runs at benchmark size. The Java column runs +pre-compiled classes (javac -d java-out), so javac time is excluded; JVM startup is included. +The "TypedPython @compiled" column runs only with `--typedpython <dir>` (the directory that holds +the `typedpython` package, python-multiplatform-ksp/src/main/python) and `--typedpython-python` (an +interpreter with the gate's dependencies, e.g. that module's .venv): each benchmark is copied with +the `# typedpython: compiled` marker, compiled through `typedpython.pipeline`, and its `main()` is +run from the built extension. Without the option the column reports n/a. +""" +import argparse +import datetime +import json +import os +import socket +import statistics +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +TARGET = ROOT / "target" +TP_BUILD = ROOT / "tp-build" +JAVA_OUT = ROOT / "java-out" +CARGO = os.environ.get("CARGO", "/Users/ibrew/.cargo/bin/cargo") +NODE = os.environ.get("NODE", "/Users/ibrew/.local/bin/node") +LOAD_LIMIT = 4.0 + +# name -> (py file, js file, java class, rust bin, verify size, benchmark size) +BENCHMARKS = { + "nbody": ("nbody.py", "nbody.mjs", "Nbody", "nbody", "5000", "1000000"), + "spectral-norm": ("spectral_norm.py", "spectral_norm.mjs", "SpectralNorm", "spectral_norm", "100", "800"), + "fannkuch-redux": ("fannkuch.py", "fannkuch.mjs", "Fannkuch", "fannkuch", "7", "10"), + "binary-trees": ("binary_trees.py", "binary_trees.mjs", "BinaryTrees", "binary_trees", "10", "16"), + "wordfreq": ("wordfreq.py", "wordfreq.mjs", "Wordfreq", "wordfreq", "100000", "3000000"), +} +COLUMNS = ["CPython", "Node", "Java", "Rust", "TypedPython @compiled"] +NOT_BUILT = "n/a (not built)" + + +def run_checked(cmd: list[str], cwd: Path | None = None, env: dict[str, str] | None = None) -> None: + result = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True) + if result.returncode != 0: + sys.stderr.write(f"command failed ({result.returncode}): {' '.join(cmd)}\n{result.stdout}{result.stderr}") + sys.exit(1) + + +def build() -> None: + env = dict(os.environ, CARGO_TARGET_DIR=str(TARGET)) + run_checked([CARGO, "build", "--release", "--quiet"], cwd=ROOT / "rust", env=env) + JAVA_OUT.mkdir(exist_ok=True) + sources = [str(p) for p in sorted((ROOT / "java").glob("*.java"))] + run_checked(["javac", "-d", str(JAVA_OUT), *sources]) + + +COMPILE_SNIPPET = """ +import json, sys +from pathlib import Path +from typedpython import pipeline +r = pipeline.compile_module(Path(sys.argv[1]), Path(sys.argv[2])) +print(json.dumps({"extension": str(r.extension) if r.extension else None, + "compiled": [f.name for f in r.module.functions], "skipped": r.skipped})) +""" + +RUN_SNIPPET = """ +import importlib.machinery, importlib.util, sys +name, path = sys.argv[1], sys.argv[2] +loader = importlib.machinery.ExtensionFileLoader(name, path) +spec = importlib.util.spec_from_file_location(name, path, loader=loader) +mod = importlib.util.module_from_spec(spec) +loader.exec_module(mod) +sys.argv = [name] + sys.argv[3:] +mod.main() +""" + + +def build_compiled(tp_dir: Path, tp_python: str) -> dict[str, tuple[str, list[str]] | str]: + """Compile each benchmark with TypedPython; name -> (extension, compiled functions) or a reason.""" + built: dict[str, tuple[str, list[str]] | str] = {} + env = dict(os.environ, PYTHONPATH=str(tp_dir)) + for name, (py, *_rest) in BENCHMARKS.items(): + work = TP_BUILD / name + work.mkdir(parents=True, exist_ok=True) + source = work / py + source.write_text("# typedpython: compiled\n" + (ROOT / "py" / py).read_text()) + result = subprocess.run([tp_python, "-c", COMPILE_SNIPPET, str(source), str(work / "out")], + capture_output=True, text=True, env=env) + if result.returncode != 0: + built[name] = f"n/a (compile failed: {result.stderr.strip().splitlines()[-1:]})" + continue + info = json.loads(result.stdout.strip().splitlines()[-1]) + built[name] = (info["extension"], info["compiled"]) if info["extension"] else "n/a (nothing compiled)" + return built + + +def commands(name: str, size: str, python: str, java_source: bool, + compiled: dict[str, tuple[str, list[str]] | str] | None = None) -> dict[str, list[str]]: + py, js, cls, rs, _, _ = BENCHMARKS[name] + java = ["java", str(ROOT / "java" / f"{cls}.java"), size] if java_source \ + else ["java", "-cp", str(JAVA_OUT), cls, size] + cmds = { + "CPython": [python, str(ROOT / "py" / py), size], + "Node": [NODE, str(ROOT / "js" / js), size], + "Java": java, + "Rust": [str(TARGET / "release" / rs), size], + } + entry = (compiled or {}).get(name) + if isinstance(entry, tuple): + cmds["TypedPython @compiled"] = [python, "-c", RUN_SNIPPET, Path(py).stem, entry[0], size] + return cmds + + +def run_once(cmd: list[str]) -> tuple[float, str]: + start = time.perf_counter() + result = subprocess.run(cmd, capture_output=True, text=True) + elapsed = time.perf_counter() - start + if result.returncode != 0: + sys.stderr.write(f"failed ({result.returncode}): {' '.join(cmd)}\n{result.stderr}") + sys.exit(1) + return elapsed, result.stdout.strip() + + +def verify(args: argparse.Namespace) -> int: + build() + compiled = build_compiled(Path(args.typedpython), args.typedpython_python) if args.typedpython else None + bad = 0 + for name, spec in BENCHMARKS.items(): + outputs = {col: run_once(cmd)[1] + for col, cmd in commands(name, spec[4], args.python, True, compiled).items()} + distinct = set(outputs.values()) + status = "OK " if len(distinct) == 1 else "FAIL" + print(f"{status} {name} (size {spec[4]}): {next(iter(outputs.values())) if len(distinct) == 1 else ''}") + if len(distinct) != 1: + bad += 1 + for col, out in outputs.items(): + print(f" {col}: {out}") + print("all outputs agree" if bad == 0 else f"{bad} benchmark(s) disagree") + return 1 if bad else 0 + + +def measure(args: argparse.Namespace) -> int: + load = os.getloadavg() + print(f"load average: {load[0]:.2f} {load[1]:.2f} {load[2]:.2f}") + print(subprocess.run(["uptime"], capture_output=True, text=True).stdout.strip()) + if load[0] > LOAD_LIMIT and not args.force: + print(f"refusing to measure: 1-minute load {load[0]:.2f} > {LOAD_LIMIT} (use --force to override)") + return 2 + build() + compiled = build_compiled(Path(args.typedpython), args.typedpython_python) if args.typedpython else None + results: dict[str, dict[str, object]] = {} + for name, spec in BENCHMARKS.items(): + size = args.size.get(name, spec[5]) + row: dict[str, object] = {"size": size} + entry = (compiled or {}).get(name, NOT_BUILT) + if isinstance(entry, tuple): + row["compiled_functions"] = entry[1] + for col, cmd in commands(name, size, args.python, False, compiled).items(): + runs = [run_once(cmd) for _ in range(args.repeat)] + outs = {out for _, out in runs} + row[col] = {"median_s": statistics.median(t for t, _ in runs), + "runs_s": [t for t, _ in runs], "output": sorted(outs)} + if len(outs) != 1: + print(f"warning: {name}/{col} produced differing outputs across runs") + if not isinstance(entry, tuple): + row["TypedPython @compiled"] = entry + measured = [c for c in COLUMNS if isinstance(row.get(c), dict)] + outs_all = {tuple(row[c]["output"]) for c in measured} # type: ignore[index] + row["outputs_agree"] = len(outs_all) == 1 + results[name] = row + print(f"measured {name}", file=sys.stderr) + + print() + print("| benchmark | size | " + " | ".join(COLUMNS) + " |") + print("|---|---|" + "---|" * len(COLUMNS)) + for name, row in results.items(): + cells = [f"{row[c]['median_s']:.3f} s" if isinstance(row.get(c), dict) else str(row.get(c, NOT_BUILT)) + for c in COLUMNS] # type: ignore[index] + flag = "" if row["outputs_agree"] else " (OUTPUTS DIFFER)" + print(f"| {name}{flag} | {row['size']} | " + " | ".join(cells) + " |") + print(f"\nmedian of {args.repeat} runs, wall time; Java excludes javac, includes JVM startup.") + + if args.write: + out_dir = ROOT / "results" + out_dir.mkdir(exist_ok=True) + path = out_dir / f"{datetime.date.today().isoformat()}-{socket.gethostname()}.json" + payload = {"date": datetime.datetime.now().isoformat(timespec="seconds"), + "host": socket.gethostname(), "load_average": load, "repeat": args.repeat, + "python": subprocess.run([args.python, "--version"], capture_output=True, text=True).stdout.strip(), + "results": results} + path.write_text(json.dumps(payload, indent=2) + "\n") + print(f"wrote {path}") + return 0 + + +def interleaved(args: argparse.Namespace) -> int: + """CPython vs TypedPython @compiled, alternated A/B in the same time window (leader decision, + 2026-10-03): when the machine is never quiet, load hits both sides of each pair alike, so the + per-pair ratio is stable even though absolute times are not. Reports the median ratio, never + absolute times as the result, with the load average recorded at every pair.""" + if not args.typedpython: + print("interleaved needs --typedpython") + return 2 + compiled = build_compiled(Path(args.typedpython), args.typedpython_python) + results: dict[str, dict[str, object]] = {} + for name, spec in BENCHMARKS.items(): + entry = compiled.get(name) + if not isinstance(entry, tuple): + results[name] = {"skipped": entry} + continue + size = args.size.get(name, spec[5]) + cmds = commands(name, size, args.python, False, compiled) + a_cmd, b_cmd = cmds["CPython"], cmds["TypedPython @compiled"] + pairs = [] + for i in range(args.pairs): + order = (a_cmd, b_cmd) if i % 2 == 0 else (b_cmd, a_cmd) # alternate which runs first + load = os.getloadavg() + (t1, o1), (t2, o2) = run_once(order[0]), run_once(order[1]) + t_a, o_a, t_b, o_b = (t1, o1, t2, o2) if order[0] is a_cmd else (t2, o2, t1, o1) + if o_a != o_b: + print(f"{name}: OUTPUTS DIFFER in pair {i}: {o_a!r} vs {o_b!r}") + return 1 + pairs.append({"cpython_s": t_a, "compiled_s": t_b, "ratio": t_a / t_b, "load": load}) + ratios = [p["ratio"] for p in pairs] + results[name] = {"size": size, "compiled_functions": entry[1], "pairs": pairs, + "median_ratio": statistics.median(ratios), "min_ratio": min(ratios), + "max_ratio": max(ratios)} + print(f"measured {name}", file=sys.stderr) + + print() + print("| benchmark | size | speedup vs CPython (median of pairs) | range | load during pairs (1-min) |") + print("|---|---|---|---|---|") + for name, r in results.items(): + if "skipped" in r: + 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 " + f"| {min(loads):.1f}–{max(loads):.1f} |") + print(f"\n{args.pairs} A/B pairs per benchmark, order alternated; ratio = CPython time / compiled time.") + if args.write: + out_dir = ROOT / "results" + out_dir.mkdir(exist_ok=True) + path = out_dir / f"{datetime.date.today().isoformat()}-{socket.gethostname()}-interleaved.json" + path.write_text(json.dumps({"date": datetime.datetime.now().isoformat(timespec="seconds"), + "host": socket.gethostname(), "method": "interleaved A/B", + "pairs": args.pairs, "results": results}, indent=2) + "\n") + print(f"wrote {path}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="cmd", required=True) + v = sub.add_parser("verify") + v.add_argument("--python", default=sys.executable) + m = sub.add_parser("measure") + m.add_argument("--python", default=sys.executable) + ab = sub.add_parser("interleaved", help="CPython vs @compiled alternated A/B; reports median ratios") + ab.add_argument("--python", default=sys.executable) + ab.add_argument("--pairs", type=int, default=11) + ab.add_argument("--write", action="store_true") + ab.add_argument("--size", action="append", default=[], metavar="NAME=N") + for p in (v, m, ab): + p.add_argument("--typedpython", metavar="DIR", + help="directory holding the typedpython package; enables the @compiled column") + p.add_argument("--typedpython-python", default=sys.executable, + help="interpreter for compiling (needs pyrefly 1.3.2)") + m.add_argument("--repeat", type=int, default=5) + m.add_argument("--force", action="store_true") + m.add_argument("--write", action="store_true") + m.add_argument("--size", action="append", default=[], metavar="NAME=N", + help="override a benchmark size (for runner smoke tests only)") + args = parser.parse_args() + if args.cmd in ("measure", "interleaved"): + args.size = dict(s.split("=", 1) for s in args.size) + if args.cmd == "interleaved": + return interleaved(args) + return verify(args) if args.cmd == "verify" else measure(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/typedpython/rust/Cargo.lock b/benchmarks/typedpython/rust/Cargo.lock new file mode 100644 index 000000000..f0c0de11c --- /dev/null +++ b/benchmarks/typedpython/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "typedpython-bench" +version = "0.1.0" diff --git a/benchmarks/typedpython/rust/Cargo.toml b/benchmarks/typedpython/rust/Cargo.toml new file mode 100644 index 000000000..59dba371d --- /dev/null +++ b/benchmarks/typedpython/rust/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "typedpython-bench" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "nbody" +path = "src/bin/nbody.rs" + +[[bin]] +name = "spectral_norm" +path = "src/bin/spectral_norm.rs" + +[[bin]] +name = "fannkuch" +path = "src/bin/fannkuch.rs" + +[[bin]] +name = "binary_trees" +path = "src/bin/binary_trees.rs" + +[[bin]] +name = "wordfreq" +path = "src/bin/wordfreq.rs" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 diff --git a/benchmarks/typedpython/rust/src/bin/binary_trees.rs b/benchmarks/typedpython/rust/src/bin/binary_trees.rs new file mode 100644 index 000000000..cb8579d3e --- /dev/null +++ b/benchmarks/typedpython/rust/src/bin/binary_trees.rs @@ -0,0 +1,39 @@ +struct Node { + left: Option<Box<Node>>, + right: Option<Box<Node>>, +} + +fn make(depth: u32) -> Box<Node> { + if depth > 0 { + Box::new(Node { left: Some(make(depth - 1)), right: Some(make(depth - 1)) }) + } else { + Box::new(Node { left: None, right: None }) + } +} + +fn check(node: &Node) -> i64 { + match (&node.left, &node.right) { + (Some(l), Some(r)) => 1 + check(l) + check(r), + _ => 1, + } +} + +fn main() { + let n: u32 = std::env::args().nth(1).unwrap().parse().unwrap(); + let min_depth = 4u32; + let max_depth = std::cmp::max(min_depth + 2, n); + let stretch = check(&make(max_depth + 1)); + let long_lived = make(max_depth); + let mut total = 0i64; + let mut depth = min_depth; + while depth <= max_depth { + let iterations = 1u64 << (max_depth - depth + min_depth); + let mut chk = 0i64; + for _ in 0..iterations { + chk += check(&make(depth)); + } + total += chk; + depth += 2; + } + println!("{} {} {}", stretch, total, check(&long_lived)); +} diff --git a/benchmarks/typedpython/rust/src/bin/fannkuch.rs b/benchmarks/typedpython/rust/src/bin/fannkuch.rs new file mode 100644 index 000000000..e62828379 --- /dev/null +++ b/benchmarks/typedpython/rust/src/bin/fannkuch.rs @@ -0,0 +1,55 @@ +fn fannkuch(n: usize) -> (i64, i64) { + let mut perm1: Vec<usize> = (0..n).collect(); + let mut count = vec![0usize; n]; + let (mut max_flips, mut checksum, mut perm_count) = (0i64, 0i64, 0i64); + let mut r = n; + loop { + while r != 1 { + count[r - 1] = r; + r -= 1; + } + let mut perm = perm1.clone(); + let mut flips = 0i64; + let mut k = perm[0]; + while k != 0 { + let (mut lo, mut hi) = (0usize, k); + while lo < hi { + perm.swap(lo, hi); + lo += 1; + hi -= 1; + } + flips += 1; + k = perm[0]; + } + if flips > max_flips { + max_flips = flips; + } + if perm_count % 2 == 0 { + checksum += flips; + } else { + checksum -= flips; + } + loop { + if r == n { + return (checksum, max_flips); + } + let p0 = perm1[0]; + for i in 0..r { + perm1[i] = perm1[i + 1]; + } + perm1[r] = p0; + count[r] -= 1; + if count[r] > 0 { + break; + } + r += 1; + } + perm_count += 1; + } +} + +fn main() { + let n: usize = std::env::args().nth(1).unwrap().parse().unwrap(); + let (checksum, max_flips) = fannkuch(n); + println!("{} {}", checksum, max_flips); +} diff --git a/benchmarks/typedpython/rust/src/bin/nbody.rs b/benchmarks/typedpython/rust/src/bin/nbody.rs new file mode 100644 index 000000000..0ccc8d733 --- /dev/null +++ b/benchmarks/typedpython/rust/src/bin/nbody.rs @@ -0,0 +1,78 @@ +const PI: f64 = 3.141592653589793; +const SOLAR_MASS: f64 = 4.0 * PI * PI; +const DAYS: f64 = 365.24; + +struct Sys { + x: [f64; 5], y: [f64; 5], z: [f64; 5], + vx: [f64; 5], vy: [f64; 5], vz: [f64; 5], + mass: [f64; 5], +} + +fn offset_momentum(s: &mut Sys) { + let (mut px, mut py, mut pz) = (0.0f64, 0.0f64, 0.0f64); + for i in 0..5 { + px += s.vx[i] * s.mass[i]; + py += s.vy[i] * s.mass[i]; + pz += s.vz[i] * s.mass[i]; + } + s.vx[0] = -px / SOLAR_MASS; + s.vy[0] = -py / SOLAR_MASS; + s.vz[0] = -pz / SOLAR_MASS; +} + +fn advance(dt: f64, steps: i64, s: &mut Sys) { + for _ in 0..steps { + for i in 0..5 { + for j in (i + 1)..5 { + let dx = s.x[i] - s.x[j]; + let dy = s.y[i] - s.y[j]; + let dz = s.z[i] - s.z[j]; + let d2 = dx * dx + dy * dy + dz * dz; + let mag = dt / (d2 * d2.sqrt()); + s.vx[i] -= dx * s.mass[j] * mag; + s.vy[i] -= dy * s.mass[j] * mag; + s.vz[i] -= dz * s.mass[j] * mag; + s.vx[j] += dx * s.mass[i] * mag; + s.vy[j] += dy * s.mass[i] * mag; + s.vz[j] += dz * s.mass[i] * mag; + } + } + for i in 0..5 { + s.x[i] += dt * s.vx[i]; + s.y[i] += dt * s.vy[i]; + s.z[i] += dt * s.vz[i]; + } + } +} + +fn energy(s: &Sys) -> f64 { + let mut e = 0.0f64; + for i in 0..5 { + e += 0.5 * s.mass[i] * (s.vx[i] * s.vx[i] + s.vy[i] * s.vy[i] + s.vz[i] * s.vz[i]); + for j in (i + 1)..5 { + let dx = s.x[i] - s.x[j]; + let dy = s.y[i] - s.y[j]; + let dz = s.z[i] - s.z[j]; + e -= s.mass[i] * s.mass[j] / (dx * dx + dy * dy + dz * dz).sqrt(); + } + } + e +} + +fn main() { + let n: i64 = std::env::args().nth(1).unwrap().parse().unwrap(); + let mut s = Sys { + x: [0.0, 4.84143144246472090e+00, 8.34336671824457987e+00, 1.28943695621391310e+01, 1.53796971148509165e+01], + y: [0.0, -1.16032004402742839e+00, 4.12479856412430479e+00, -1.51111514016986312e+01, -2.59193146099879641e+01], + z: [0.0, -1.03622044471123109e-01, -4.03523417114321381e-01, -2.23307578892655734e-01, 1.79258772950371181e-01], + vx: [0.0, 1.66007664274403694e-03 * DAYS, -2.76742510726862411e-03 * DAYS, 2.96460137564761618e-03 * DAYS, 2.68067772490389322e-03 * DAYS], + vy: [0.0, 7.69901118419740425e-03 * DAYS, 4.99852801234917238e-03 * DAYS, 2.37847173959480950e-03 * DAYS, 1.62824170038242295e-03 * DAYS], + vz: [0.0, -6.90460016972063023e-05 * DAYS, 2.30417297573763929e-05 * DAYS, -2.96589568540237556e-05 * DAYS, -9.51592254519715870e-05 * DAYS], + mass: [SOLAR_MASS, 9.54791938424326609e-04 * SOLAR_MASS, 2.85885980666130812e-04 * SOLAR_MASS, 4.36624404335156298e-05 * SOLAR_MASS, 5.15138902046611451e-05 * SOLAR_MASS], + }; + offset_momentum(&mut s); + let before = energy(&s); + advance(0.01, n, &mut s); + let after = energy(&s); + println!("{:.9} {:.9}", before, after); +} diff --git a/benchmarks/typedpython/rust/src/bin/spectral_norm.rs b/benchmarks/typedpython/rust/src/bin/spectral_norm.rs new file mode 100644 index 000000000..5fe23a77b --- /dev/null +++ b/benchmarks/typedpython/rust/src/bin/spectral_norm.rs @@ -0,0 +1,45 @@ +fn eval_a(i: usize, j: usize) -> f64 { + 1.0 / (((i + j) * (i + j + 1) / 2 + i + 1) as f64) +} + +fn mul_av(n: usize, v: &[f64], out: &mut [f64]) { + for i in 0..n { + let mut s = 0.0f64; + for j in 0..n { + s += eval_a(i, j) * v[j]; + } + out[i] = s; + } +} + +fn mul_atv(n: usize, v: &[f64], out: &mut [f64]) { + for i in 0..n { + let mut s = 0.0f64; + for j in 0..n { + s += eval_a(j, i) * v[j]; + } + out[i] = s; + } +} + +fn mul_atav(n: usize, v: &[f64], out: &mut [f64], tmp: &mut [f64]) { + mul_av(n, v, tmp); + mul_atv(n, tmp, out); +} + +fn main() { + let n: usize = std::env::args().nth(1).unwrap().parse().unwrap(); + let mut u = vec![1.0f64; n]; + let mut v = vec![0.0f64; n]; + let mut tmp = vec![0.0f64; n]; + for _ in 0..10 { + mul_atav(n, &u, &mut v, &mut tmp); + mul_atav(n, &v, &mut u, &mut tmp); + } + let (mut vbv, mut vv) = (0.0f64, 0.0f64); + for i in 0..n { + vbv += u[i] * v[i]; + vv += v[i] * v[i]; + } + println!("{:.9}", (vbv / vv).sqrt()); +} diff --git a/benchmarks/typedpython/rust/src/bin/wordfreq.rs b/benchmarks/typedpython/rust/src/bin/wordfreq.rs new file mode 100644 index 000000000..b6f3e0dc3 --- /dev/null +++ b/benchmarks/typedpython/rust/src/bin/wordfreq.rs @@ -0,0 +1,54 @@ +use std::collections::HashMap; + +const VOCAB: u64 = 50000; +const MOD: u64 = 2147483647; + +fn next_state(s: u64) -> u64 { + (s * 48271) % MOD +} + +fn word_of(id: u64) -> String { + let mut x = id + 17576; + let mut bytes: Vec<u8> = Vec::new(); + while x > 0 { + bytes.push(97 + (x % 26) as u8); + x /= 26; + } + bytes.reverse(); + String::from_utf8(bytes).unwrap() +} + +fn main() { + let n: u64 = std::env::args().nth(1).unwrap().parse().unwrap(); + let mut counts: HashMap<String, i64> = HashMap::new(); + let mut s: u64 = 12345; + for _ in 0..n { + s = next_state(s); + let a = s % VOCAB; + s = next_state(s); + let b = s % VOCAB; + *counts.entry(word_of(a * b / VOCAB)).or_insert(0) += 1; + } + let mut weighted = 0i64; + for c in counts.values() { + weighted += c * c; + } + let mut taken: Vec<String> = Vec::new(); + let mut parts: Vec<String> = Vec::new(); + for _ in 0..5 { + let mut best_word = String::new(); + let mut best_count = -1i64; + for (w, &c) in &counts { + if taken.contains(w) { + continue; + } + if c > best_count || (c == best_count && *w < best_word) { + best_word = w.clone(); + best_count = c; + } + } + parts.push(format!("{}:{}", best_word, best_count)); + taken.push(best_word); + } + println!("{} {} {}", counts.len(), weighted, parts.join(",")); +} diff --git a/binary/download.md b/binary/download.md index 2fc0772d9..510fdb136 100644 --- a/binary/download.md +++ b/binary/download.md @@ -1 +1 @@ -These stand-alone python binaries were downloaded from https://github.com/indygreg/python-build-standalone/releases/tag/20241008 +These stand-alone python binaries were downloaded from https://github.com/astral-sh/python-build-standalone/releases/tag/20241008 diff --git a/build.gradle.kts b/build.gradle.kts index c9b08b91f..2eb7b2272 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,9 +6,37 @@ plugins { alias(libs.plugins.jetpack.compose).apply(false) alias(libs.plugins.compose.compiler).apply(false) alias(libs.plugins.kotlin.multiplatform).apply(false) + alias(libs.plugins.kotlin.jvm).apply(false) + alias(libs.plugins.ksp).apply(false) } allprojects { group = "io.github.thisisthepy" version = "3.13.0" // Official Python release version } + +// ROADMAP §15a/§15e item 3: `python-multiplatform-gradle-plugin` is a separate included build +// (see `pluginManagement { includeBuild(...) }` in settings.gradle.kts), so its tasks never join +// the root task graph -- `./gradlew tasks --all` from here lists none of them, and a coordinator +// publishing only what the root build shows would silently skip it. This task does not change how +// any of the three components publish (each already has its own `maven-publish` config); it only +// gives the root build one entry point that reaches all three. +tasks.register("publishAllToMavenLocal") { + group = "publishing" + description = "Publishes python-multiplatform, python-multiplatform-ksp, and " + + "python-multiplatform-gradle-plugin (including its plugin-marker publication) to mavenLocal()." + dependsOn(":python-multiplatform:publishToMavenLocal") + dependsOn(":python-multiplatform-ksp:publishToMavenLocal") + dependsOn(gradle.includedBuild("python-multiplatform-gradle-plugin").task(":publishToMavenLocal")) +} + +// The build's single Kotlin/Wasm npm lock (one workspace for every wasm module) lives next to the +// version catalog instead of a root `kotlin-js-store/` (issue #110). KGP only allows this on the +// root project; the `*Property` forms are the non-deprecated API from Kotlin 2.4 on. Mismatch +// reporting keeps its default (FAIL), so CI notices a drifted lock. +plugins.withType<org.jetbrains.kotlin.gradle.targets.wasm.yarn.WasmYarnPlugin> { + extensions.getByType<org.jetbrains.kotlin.gradle.targets.wasm.yarn.WasmYarnRootExtension>().apply { + lockFileDirectoryProperty.set(layout.projectDirectory.dir("gradle")) + lockFileNameProperty.set("wasm-yarn.lock") + } +} diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 000000000..0659efaab --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,46 @@ +# Archive + +Superseded design documents and the long narratives of investigations, kept for history. +**Nothing here is current.** Every file starts with a "Superseded by … on <date>" header that names +the document that replaced it. When an archived file disagrees with [`../INTENT.md`](../INTENT.md), +[`../SPEC.md`](../SPEC.md), a current document in `docs/design/`, `docs/platforms/` or +`docs/investigations/`, or the code, the archived file is the one that is wrong. + +Archived on 2026-10-03 (issue #15). Documents that were merged rather than archived, such as the +three upcall documents now in [`../design/upcall.md`](../design/upcall.md), are not copied here. Their +full text is in git (`git log -- docs/design/upcall-design.md`). + +## Superseded designs + +| File | Replaced by | Why it is here | +|---|---|---| +| [`pythonx-adapter-design.md`](pythonx-adapter-design.md) | SPEC U-7/U-8, `KotlinSurface.kt`, [`../design/kotlin-extensions-in-python.md`](../design/kotlin-extensions-in-python.md) | The design in which the binder synthesised `pythonx.*` modules and renamed to snake_case. AGENTS.md §12.1–12.2 forbid both: Kotlin names stay Kotlin names, and `pythonx` is a real package in pythonx-compose. Its measurements of Compose (synthetic parameters, `pointerInput`, `draggable`, `layoutId`) are history; the behaviour is asserted by the `ksp-fixtures/compose` tests. | +| [`pyi-generation-pythonic-stubs.md`](pyi-generation-pythonic-stubs.md) | [`../design/pyi-generation-design.md`](../design/pyi-generation-design.md), SPEC B-7 | The Pythonic stub product: snake_case names, `pythonx` stubs, the stub manifest, `@overload` sets, and the `Modifier` metaclass/Protocol analysis. The plugin now emits Kotlin-name stubs only, and the Pythonic product belongs to pythonx-compose. | +| [`kotlin-extensions-in-python-pyi.md`](kotlin-extensions-in-python-pyi.md) | [`../design/pyi-generation-design.md`](../design/pyi-generation-design.md), SPEC B-7 | The original §4.5 of `kotlin-extensions-in-python.md`, which proposed Pythonic stubs. | +| [`android-ffm-design.md`](android-ffm-design.md) | [`../design/downcall-design.md`](../design/downcall-design.md), `python-multiplatform/src/androidMain/README.md` | The PanamaPort-based Android FFM plan. It was never built: AGENTS.md §12.9 forbids PanamaPort, and Android binds through JNI `RegisterNatives` (SPEC C-3). | +| [`threading-and-abi-3.15t-gate.md`](threading-and-abi-3.15t-gate.md) | [`../design/threading-and-abi.md`](../design/threading-and-abi.md) | The reasoning that gated free-threading on 3.15t. Free-threaded 3.14t now runs the desktop suite as an opt-in (`-PpythonFreeThreaded=true`, SPEC T-2). | +| [`downcall-design-android-first-diagnosis.md`](downcall-design-android-first-diagnosis.md) | [`../design/downcall-design.md`](../design/downcall-design.md) | The first diagnosis of the Android JNI argument-shift bug. The bug is fixed. | +| [`downcall-design-benchmark-drift.md`](downcall-design-benchmark-drift.md) | [`../design/downcall-design.md`](../design/downcall-design.md) ("Warmup"), [`../design/upcall.md`](../design/upcall.md) §7.2 | The investigation into benchmark figures that turned out to measure the benchmark itself (missing warmup). | +| [`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/` + +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, +with its step-by-step reasoning, the hypotheses it rejected, and its corrections. + +| File | Current | +|---|---| +| [`investigations/android-unregistered-surface.md`](investigations/android-unregistered-surface.md) | [`../investigations/android-unregistered-surface.md`](../investigations/android-unregistered-surface.md). The migration is done; this copy keeps the call-graph traces. | +| [`investigations/gc-scheduling-investigation.md`](investigations/gc-scheduling-investigation.md) | [`../investigations/gc-scheduling-investigation.md`](../investigations/gc-scheduling-investigation.md) | +| [`investigations/gil-parking-investigation.md`](investigations/gil-parking-investigation.md) | [`../investigations/gil-parking-investigation.md`](../investigations/gil-parking-investigation.md). This copy ends with the cause open; the current file names it: a borrowed pointer wrapped as owned, causing a double free. | +| [`investigations/jni-call-convention-audit.md`](investigations/jni-call-convention-audit.md) | [`../investigations/jni-call-convention-audit.md`](../investigations/jni-call-convention-audit.md). This copy keeps the 71-row classification table. | + +## Not archived, for reference + +- [`../design/typedpython.md`](../design/typedpython.md) is a live design proposal from another session + and stays in `docs/design/`. A note at its top says that the stub manifest it relies on moved to + pythonx-compose. +- [`../roadmap/ROADMAP.md`](../roadmap/ROADMAP.md) now lists only the work that is left. The old + progress log is in git: `git show 4ed98143:docs/roadmap/ROADMAP.md`. diff --git a/docs/archive/android-ffm-design.md b/docs/archive/android-ffm-design.md new file mode 100644 index 000000000..5c1e10f12 --- /dev/null +++ b/docs/archive/android-ffm-design.md @@ -0,0 +1,196 @@ +> **Superseded** by [`../design/downcall-design.md`](../design/downcall-design.md) and +> [`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 +> 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 +> accurate and became `jvmMain/.../ShapeDowncalls.kt`. The closing "Open constraint: GraalVM Native +> Image" section is also resolved: native-image upcalls are verified manually +> (`docs/platforms/graal-native-image-verification.md`, SPEC U-6) and the desktop FFM reachability +> metadata is guarded by `ReachabilityMetadataTest.kt` (SPEC C-2). + +# Android FFM Design Document (PanamaPort Analysis) + +This document provides an implementation design for replacing the existing Kotlin/Native JNI bridge on Android with a Panama-style FFM approach, based on an analysis of the PanamaPort reference implementation. + +## 1. The Bootstrap Problem + +**Question:** Calling `libLLVM.so` from Java itself requires an FFI mechanism, but FFI is what LLVM is being used to build. How does PanamaPort break this circularity? + +**Answer:** PanamaPort breaks the circularity by bypassing the need for a native stub entirely for simple function signatures. In `BulkLinker.java` (lines 436-492), the method `requireNativeStub` evaluates whether an LLVM trampoline is necessary. For functions whose signatures map cleanly to primitive types and pointers (like `LONG_AS_WORD` or `BOOL_AS_INT`) and use standard JNI or `@CriticalNative` (`CallType.CRITICAL`), `requireNativeStub` evaluates to `false`. + +For these functions, the "irreducible primitive" is `ArtMethodUtils.registerNativeMethod()`. This method takes a Java `Method` object and a raw C memory address (from `dlsym`), and directly injects the C pointer into the internal Android Runtime (ART) `ArtMethod` struct, replacing its native entry point. This bottoms out entirely on Unsafe memory writes and reflection to locate the struct in memory. It requires zero native FFI machinery to bootstrap. + +## 2. ART Method Patching + +**Question:** How does `ArtMethodUtils.registerNativeMethod` actually work? What ART-internal structures does it touch, how does it locate them, and how version-sensitive is this across Android releases? + +**Answer:** +It works by directly modifying the C++ `ArtMethod` struct used by ART. +1. It locates the `ArtMethod` memory address by parsing the `java.lang.reflect.Method` object using `Reflection.getArtMethod(Executable)` (which uses Unsafe to extract the hidden `artMethod` pointer). +2. It overwrites the `data_` (or `entry_point_from_jni_`) field of the struct with the raw C function pointer using `AndroidUnsafe.putWordN()`. + +This is **extremely version-sensitive**. In `ArtMethodUtils.java` (lines 48-115), PanamaPort hardcodes four different memory layouts for the `ArtMethod` struct: +- `art_method_8xx_layout` (Android 8.0, 8.1) +- `art_method_9_layout` (Android 9) +- `art_method_11_10_layout` (Android 10, 11) +- `art_method_16p1_12_layout` (Android 12 to 15/16) + +These layouts explicitly track C++ field additions/removals across ART versions (e.g., `dex_cache_resolved_methods_` or `hotness_count_` unions). + +## 3. Hidden API Restrictions + +**Question:** Android restricts reflective access to non-SDK interfaces. How does PanamaPort get around that, and what are the consequences? + +**Answer:** PanamaPort completely bypasses Android's Hidden API checks by avoiding standard Java reflection (`Class.getDeclaredMethods`, `VMRuntime.setHiddenApiExemptions`) entirely. +In `Reflection.java` (lines 48-100 and 350-650), it manually parses the internal C++ `mirror::Class` struct (`ClassMirror`) to extract the native arrays of `methods` and `fields`. It then uses Unsafe to instantiate a fake `MethodHandleImpl`, sets its internal pointer to the hidden `ArtMethod`, and uses `MethodHandles.reflectAs` to convert it back into a usable `Method` or `Field` object. + +**Consequences:** +- It requires **no manifest flags**. +- It **will break** on any future Android version where the internal memory layout of `mirror::Class` or `MethodHandleImpl` changes, requiring continuous maintenance of offset tables. + +## 4. Minimum Viable Subset for THIS Project + +### Downcalls +Required for ~330 CPython functions. Since these signatures only use `void`, `Int`, `Long`, `Double`, opaque pointers, and `String` (which can be passed as `const char*` pointers), they map perfectly to Java `@CriticalNative` methods (using `long` for pointers). LLVM is not required for this. + +### Upcalls +Required for letting Python call back into Kotlin. +PanamaPort's upcall path (`_AndroidLinkerImpl.java` lines 954-1100) generates an LLVM C stub that: +1. Obtains the `JNIEnv*` (via `ENVGetter.java` which looks up thread-locals or calls `AttachCurrentThread`). +2. Packages the native registers/arguments. +3. Invokes JNI `CallStaticVoidMethod` on a generated Java stub. +4. The Java stub converts the raw pointers to `MemorySegment` objects, invokes the target `MethodHandle`, and writes the return value back to native memory using raw Unsafe writes. +5. The LLVM stub checks for exceptions via JNI `ExceptionCheck` and aborts if one is found. + +### VarHandle +**No, a VarHandle backport is NOT unavoidable.** +In PanamaPort's own upcall path (`_AndroidLinkerImpl.java` lines 1334-1353), the return value is written back to native memory using `UpcallHelper.putInt`, `putAddress`, etc., which rely exclusively on `AndroidUnsafe.putIntN`. It does not use `VarHandle` for argument marshalling. Since this target project has its own `NativePointer` abstraction, we can avoid `VarHandle` entirely and use direct Unsafe accesses. + +### Struct-by-value and variadic calls +CPython's Stable ABI intentionally avoids struct-by-value to maintain binary compatibility, relying exclusively on opaque pointers (e.g., `PyObject*`, `PyThreadState*`). Variadic calls (like `PyArg_ParseTuple`) exist, but modern bridges generally prefer array-based or vectorcall alternatives (`PyObject_Vectorcall`, `PyObject_CallObject` with tuples). The Stable ABI does not strictly force us to implement struct-by-value or variadic FFI handling. + +### Memory-lifetime management +Upcall stubs must outlive individual calls, effectively matching the lifetime of the Python interpreter, as CPython stores these function pointers in long-lived structures (like `PyTypeObject.tp_new`). The minimum required is a simple list of allocated native memory blocks (stubs) that are freed upon `Py_Finalize`. + +**What can be dropped from PanamaPort:** +- Everything in the `LLVM/` module (JIT is unnecessary, see Question 6). +- All 40 files in `Core/src/openjdk/` (`Arena`, `MemorySegment`, `GroupLayout`, etc.). +- The `VarHandles` module. + +**What is kept:** +- `Unsafe/src/main/java/com/v7878/unsafe/ArtMethodUtils.java` (Method patching). +- `Unsafe/src/main/java/com/v7878/unsafe/Reflection.java` (Hidden API bypass). +- `Unsafe/src/main/java/com/v7878/unsafe/foreign/LibDL.java` (ELF parsing to locate `dlopen`/`dlsym`). +- `AndroidUnsafe.java`. + +## 5. A Cheaper Bootstrap for this Project? + +**Question:** Could the existing Kotlin/Native layer supply `dlopen`/`dlsym` and the LLVM entry points directly? + +**Answer:** Yes, technically Kotlin/Native could export a generic `@CName("my_dlopen")` and `@CName("my_dlsym")` wrapper. +**Why it would NOT work (or is highly undesirable):** The primary motivation for adopting a Panama-style FFM approach on Android is to entirely eliminate the Kotlin/Native Android build target (and the associated JNI bridge maintenance). If we retain `libmultiplatform_python3.13.so` simply to bootstrap FFI, we fail to eliminate the Kotlin/Native Android compilation step. Furthermore, as shown in Question 1, `dlopen` can be bootstrapped via `LibDL.java` parsing the Android linker's ELF symbol table, meaning the Kotlin/Native bridge is completely unnecessary. + +## 6. Is LLVM Even Necessary Here? + +### Downcalls +**No.** The ~330 CPython functions have a small set of fixed signature shapes. Rather than generating one `@CriticalNative` declaration per CPython function, which fails to support a unified `FfiBackend` because method patching leaves nowhere to pass the target function pointer, we will use a small set of pregenerated native trampolines. + +Based on an analysis of the ~330 functions in `EmbedAPI.kt`, there are exactly 14 distinct ABI shapes across the entire Stable ABI (e.g., 123 functions use `I_I`, 87 use `II_I`). All signatures use either exclusively integer-class arguments (pointers/ints) or exclusively floating-class arguments, with no mixed-class orderings to worry about. We only need 14 pregenerated C trampolines to cover the entire Downcall API, allowing both Desktop and Android to share the same dynamic `FfiBackend` interface. +- **Binary size:** Drops `libLLVM.so` dependency. 14 tiny C trampolines compile to almost nothing. +- **Startup cost:** Instant (no JIT compilation). +- **Android-version fragility:** Avoids relying on the undocumented/restricted `libLLVM.so` on Android. + +### 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 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. + +**Hybrid viability:** A tiny NDK-compiled C library containing ~14 static trampolines can serve both dynamic downcalls and data-routed upcalls. This is the most robust, performant, and maintainable solution, and it enables a unified multiplatform architecture. + +## 7. Licensing + +**Question:** Which of the 40 vendored OpenJDK files (under GPLv2) in `Core/src/openjdk/` would a reimplementation actually need? + +**Answer:** **None.** +Because this project has its own `NativePointer` abstraction and does not need to expose a public FFM API, we do not need `MemorySegment`, `Arena`, `MemorySessionImpl`, or `ValueLayout`. Memory reads/writes can be done directly using `Unsafe`, exactly as PanamaPort's internal `UpcallHelper` does. + +## 8. Recommended Plan + +**Stage 1: Core Patching Machinery** +- Port `AndroidUnsafe`, `Reflection`, `ArtMethodUtils`, and `LibDL` from PanamaPort. +- Verify that `LibDL` can locate `dlopen`/`dlsym` and that `ArtMethodUtils` can bind `Py_GetVersion` to a `@CriticalNative` Java method. +- *Risk/Effort:* Moderate. *Verification:* Requires a real Android emulator/device to verify `ArtMethodUtils` struct offsets. + +**Stage 2: Downcall Generation** +- Create the NDK C project containing the 14 generic C trampolines. +- Write the Kotlin initialization code that binds these trampolines via `ArtMethodUtils` to power the shared `FfiBackend`. +- *Risk/Effort:* Low. *Verification:* Emulator/device required. + +**Stage 3: Upcall Trampolines** +- Create a minimal NDK C project containing generic C trampolines for the required CPython callback shapes (e.g., `PyCFunction`). +- Implement the JNI lookup to route the C call back to the Kotlin `NativePointer` callback registry. +- *Risk/Effort:* Moderate. (Requires re-introducing a tiny NDK build, but vastly simpler than LLVM JIT). + +**Stage 4: Migration & Cleanup** +- Reroute all Python calls in the Kotlin shared code to use the new `@CriticalNative` downcalls. +- Delete the old Kotlin/Native Android JNI bridge code and Android-specific Kotlin/Native build targets. +- *Risk/Effort:* High effort, low risk. *Verification:* Run the existing multiplatform test suite on Android. + +## 9. Current State + +Desktop's reflective Panama backend is fully implemented and `compileKotlinDesktop` now succeeds. The next step is promoting an `FfiBackend` interface into `jvmMain` so that Android can implement it using the shape-trampoline architecture designed above, unifying the JVM-side FFI. + +--- + +## Open constraint: GraalVM Native Image + +A design discussion recorded in an earlier session (`JNI Free 구현`, now installed under this +project's Claude Code sessions) establishes a target this document had not accounted for: +**GraalVM Native Image support is a goal for the desktop side.** + +That has two consequences, both of which cut against work already committed here. + +### Runtime reflection is unavailable + +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 +**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 +exported automatically, with an explicit `@PythonInternal`-style annotation to opt *out*. Opt-in +annotation on every exported member was considered and rejected as too noisy. + +Two problems from that discussion remain unresolved: + +- **Tree shaking.** A table that references every public function keeps all of them reachable, which + defeats Native Image's dead-code elimination and inflates the binary. No answer was reached. +- **Dispatch cost.** `KFunction.call` runs about 50–100 ns because it re-checks arity and types and + boxes arguments on every call; `MethodHandle` is roughly 10–20 ns once the JIT has optimised it. + The stated target is Objective-C message dispatch, about 5–10 ns on a cache hit. + +### The reflective Panama backend conflicts with it + +`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** — +an unregistered one fails at run time with +`ForeignFunctionsRuntime$UnregisteredForeignStubException: Cannot perform downcall ... as it was not +registered at compilation time`. + +So "works on every JDK via reflection" and "works under Native Image" pull in opposite directions, +and the current backend satisfies only the first. + +The shape-trampoline design helps here rather than hurting: the signature census found the whole +CPython Stable ABI collapses into **14 shapes**, and registering fourteen downcalls at build time is +entirely practical, whereas registering arbitrary runtime-constructed signatures is not. + +**This needs a decision before the desktop backend is built on further**: is Native Image support +confirmed? If so, the backend has to move from runtime reflective selection to build-time +registration of the fourteen shapes. diff --git a/docs/archive/downcall-design-android-first-diagnosis.md b/docs/archive/downcall-design-android-first-diagnosis.md new file mode 100644 index 000000000..38b98df65 --- /dev/null +++ b/docs/archive/downcall-design-android-first-diagnosis.md @@ -0,0 +1,46 @@ +# 다운콜 설계 초안의 Android 진단 (2026-08 초) + +> **Superseded** by [`../design/downcall-design.md`](../design/downcall-design.md) on 2026-10-03; kept for history. +> 아래 "확인된 버그" 는 **수정되었다** — Android 는 `RegisterNatives` 로 전부 바인딩한다 +> (`python-multiplatform/src/androidMain/README.md`, ROADMAP §2). 호출 규약 표의 전환 비용(115/35/25 ns)은 +> 외부 벤치마크에서 가져온 값이며, 이 프로젝트가 실측한 값(API 26-36)과 반대 경향이다 — 실측은 본 문서의 +> "Measured" 절과 `androidMain/README.md` 에 있다. + +### 확인된 버그: 현재 Android JNI 배선이 어긋나 있다 + +`@CName` 이 만드는 C 함수는 선언한 Kotlin 인자만 받는다: + +``` +Java_python_native_ffi_bindings_PyList_1Size(jlong list) +``` + +그런데 `androidMain/bindings.kt` 는 이를 **일반 JNI 메서드**로 선언한다. 일반 JNI 메서드를 ART 는 +이렇게 호출한다: + +``` +Java_..._PyList_1Size(JNIEnv* env, jobject thiz, jlong list) +``` + +**인자가 두 칸 밀린다.** `list` 자리에 `JNIEnv*` 가 들어간다. 확인 결과 `@CName` 익스포트 중 +`JNIEnv` 를 받는 것은 0개이고, `@CriticalNative`/`@FastNative` 도 어디에도 없다. + +즉 **Android JVM 경로는 컴파일만 되고 런타임에 동작하지 않는다.** 실행된 적이 없어 드러나지 않았다. + +### ART 의 세 가지 호출 규약 + +| | 일반 JNI | `@FastNative` | `@CriticalNative` | +|---|---|---|---| +| 전환 오버헤드 | 115 ns | 35 ns | 25 ns | +| C 시그니처 | `(JNIEnv*, jobject, args…)` | 동일 | **`(args…)` 만** | +| 객체 인자·반환 | 가능 | 가능 | **원시 타입만** | +| 정적/인스턴스 | 둘 다 | 둘 다 | **정적만** | +| JVM 으로 콜백 | 가능 | 가능 | **불가** | +| 실행 중 GC | 허용 | **차단** | **차단** | + +shape 트램폴린은 정적이고 `Long`/`Double` 만 주고받으므로 `@CriticalNative` 에 정확히 맞는다. 그리고 +우리 `@CName` 익스포트가 `JNIEnv` 를 받지 않는다는 사실이 곧 `@CriticalNative` 규약과 일치하므로, +**애노테이션을 붙이는 것이 성능 개선인 동시에 위 버그의 수정**이다. + +`@CriticalNative` 는 API 34 부터 공식 SDK 에 포함된다. minSdk 26 구간에서는 `RegisterNatives` 로 +등록해야 한다 — `RegisterNatives` 는 호출 규약이 아니라 바인딩 수단이므로 둘은 대안 관계가 아니다. + diff --git a/docs/archive/downcall-design-benchmark-drift.md b/docs/archive/downcall-design-benchmark-drift.md new file mode 100644 index 000000000..ff60c1755 --- /dev/null +++ b/docs/archive/downcall-design-benchmark-drift.md @@ -0,0 +1,318 @@ +# Downcall/upcall benchmark drift investigation (2026-08-14) + +> **Superseded** by [`../design/downcall-design.md`](../design/downcall-design.md) ("Warmup: what the +> boundary benchmarks must do") on 2026-10-03; kept for history. The sections below are the record +> of how `UpcallBoundaryCostTest`'s drift was found to be a same-process warm-up artefact, how it +> was pinned (desktop to a window, wasmJs to commit `4472f83a`), and how the warmup count was +> chosen (100 000). Figures taken at the old 3 000-call warmup are not current numbers; current +> ones are in `docs/investigations/cost-table.md` and `docs/design/upcall.md` §7. + +### Ratio consistency: mostly holds, desktop is the exception + +> **Superseded as a source of numbers, kept as the record of how the defect was found.** Every figure +> in this section and the two that follow it was taken with `UpcallBoundaryCostTest`'s 3 000-call +> warmup, which "The benchmark was measuring the benchmark" below shows was too small by more than an +> order of magnitude. The reasoning is what still stands: these three sections are what established +> that the number moved for a repository-located reason and then that the reason was the benchmark's +> own methodology. Current figures are in `docs/design/upcall.md` §7 (which absorbed `upcall-design.md`). + +`upcall-design.md`'s own upcall/downcall ratios, from the same `UpcallBoundaryCostTest`, are +reproduced by these three fresh runs (the test computes and prints its own ratio each time, so this +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** | +| 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** | + +**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 +(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 +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. + +### Closed: repository drift, not run-to-run noise (2026-08-14) + +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) +was run three times against each, clearing `build/test-results` before every run: + +| | run 1 | run 2 | run 3 | +|---|---|---|---| +| `537c1a0b`, fresh worktree | 870.48 ns | 808.72 ns | 869.12 ns | +| current tip, fresh worktree | 697.73 ns | 680.46 ns | 686.21 ns | + +Same machine, same JDK (Temurin 21.0.12), same day. The two bands (809–870 ns vs. 680–698 ns) do +not overlap across six runs split into two isolated builds, which rules out ordinary run-to-run +variance as the explanation. Something in the repository between those two points changed the +measured number. `537c1a0b`'s figure also reproduces its own historical record (861–1313 ns) almost +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 +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 +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 +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 +measurements of the *same* checkout spanned both regimes (one commit read 672, 771, and 1076 ns +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: +`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. + +**Neither candidate touches the code this benchmark measures, which points at an indirect cause.** +`git diff <parent>..<commit>` 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 +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<Test>("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 +(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 +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 — +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 +boundary's per-call cost. The open discrepancy above is closed on that basis, not left open. + +**wasmJs has since drifted further, past even its own "consistent" reading above.** A fresh check +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 +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'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 +reproduced by a controlled experiment. It is *not* a genuine change in what the wasm upcall boundary +costs. + +The measurement protocol was the one the desktop section above used, with two changes forced by +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` +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 | +|---|---|---|---|---| +| `65e1bf5d` (parent) | 484.22–500.38 ns | 190.11–191.00 ns | 2.53–2.61x | 3 | +| **`4472f83a` (child)** | **306.66–315.82 ns** | **189.32–202.56 ns** | **1.52–1.66x** | 3 | +| `ec8ff389` | 482.03–507.38 ns | 197.87–204.10 ns | 2.38–2.53x | 3 | +| `4722cfe9` | 477.40–497.05 ns | 190.60–203.23 ns | 2.34–2.60x | 3 | +| `17f058ca` | 302.55–320.54 ns | 189.47–203.92 ns | 1.50–1.65x | 3 | +| `73f2af6b` | 312.50–322.11 ns | 188.95–213.49 ns | 1.46–1.66x | 3 | +| 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 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 +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 — +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.** +`4472f83a`'s only edit to the timed path is a `when` on `self` in `UpcallEntry.invokeMethod`, which +can only cost more, not less. What it also did was make `PythonProxySource.install()` succeed on +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 +**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 +back up: + +| current tip | upcall | downcall | ratio | +|---|---|---|---| +| as it is | 355.82–373.21 ns | 222.07–226.88 ns | 1.58–1.64x | +| with `GeneratedProxyCostTest` inert | 459.67–473.79 ns | 187.24–192.16 ns | 2.36–2.53x | + +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 +non-overlapping window from desktop's. What the two platforms share is the methodology, not a +code path. + +**The consequence for the table is that `UpcallBoundaryCostTest`'s absolute upcall figure is not a +property of the commit alone.** It is a property of the commit *and* of how much upcall traffic the +rest of the suite pushed through the same process first. Any future row should record the suite +composition it was taken with, or the number will "drift" again the next time an unrelated test +starts or stops exercising the boundary. + +**One thing did not reproduce and is left as observed.** `537c1a0b`, the commit +`upcall-design.md`'s 703–1075 ns wasm row was recorded on, does not reproduce that row here: a +freshly created worktree at it measured 546.45 ns once and then 1476.85–1602.25 ns on four +subsequent runs (its trampoline figure was similarly unstable, 268–612 ns, while its downcall stayed +flat at 239–247 ns). That commit's suite is 258 tests rather than 344 and predates the MEMFS change, +so it is a different environment in two ways at once, and no attempt is made here to reconcile it. +The conclusion above rests on the adjacent-commit pair `65e1bf5d`/`4472f83a` and on the tip-vs-tip +experiment, neither of which depends on `537c1a0b` at all. + +**Load, since these numbers depend on it.** Machine load average was 1.4–1.6 at the start and +stayed in the 1.7–3.2 band for every run quoted above. An earlier batch was thrown away: creating +the comparison worktree set Spotlight indexing its 16 190 files, which took load to 10.4 and made +the same commit read 546 ns then 1448–1677 ns. Those runs are not in this document. The machine also +had an Android emulator resident throughout, which is why the floor is ~1.5 rather than ~0. + +### The benchmark was measuring the benchmark, and the count that fixes it is measured (2026-08-14) + +The two sections above diagnosed `UpcallBoundaryCostTest` and stopped there: they established that +its absolute figure was a same-process warm-up artefact, pinned wasmJs's shift to one commit, and +attached a staleness note to the table. **The measurement itself was not changed**, so the next pass +would have re-derived the same non-number. This section changes it and shows the change works. + +#### 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 — +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. + +The timed loops were run **40 times in succession** inside one run, on both hosts, in two suite +configurations. Per 10 000 calls: + +| | rep 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | … plateau | +|---|---|---|---|---|---|---|---|---|---| +| desktop upcall, suite as-is | 801 | 744 | 704 | 705 | 676 | 602 | 593 | 537 | ~535 ns | +| desktop upcall, proxy test inert | 1307 | 716 | 696 | 660 | 548 | 539 | 558 | 554 | ~535 ns | +| desktop downcall (Kotlin-driven) | 475 | 264 | 264 | 212 | 141 | 141 | 140 | 143 | ~141 ns | +| wasmJs upcall, suite as-is | 300 | 313 | 317 | 290 | 301 | 285 | 305 | 278 | ~280 ns | +| wasmJs upcall, proxy test inert | 785 | 339 | 317 | 303 | 294 | 299 | 319 | 279 | ~280 ns | + +**The two configurations converge on the same value and they converge from opposite sides.** So the +plateau belongs to the boundary and everything before it belongs to the suite. That alone settles +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. +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 +dylib** there, so its own speed cannot depend on how warm the JVM is, and indeed desktop's +pure-Python rows are identical (8.44–8.59 ns) whether the class runs alone or inside the full suite. + +#### The count, and why 100 000 + +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 +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 +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%. + +**`inline` on `Benchmark.measure` was tried and is worse.** The hypothesis was that one shared +`block()` call site goes megamorphic across every benchmark in the process. Inlining it made desktop's +downcall row need ~230 000 iterations to reach the plateau it reaches in ~50 000 through the shared +non-inlined loop, because each inlined copy is separate code that tiers up on its own. Reverted, and +recorded so it is not tried again. + +#### The check the old table could not pass + +| | full suite | proxy test short-circuited | this class alone (`--tests`) | +|---|---|---|---| +| 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, **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 +move desktop by 25% and wasmJs by 10%; it now moves neither outside its own run-to-run band. **That +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 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 +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. + +#### What it costs + +Measured on this machine, full suite, `build/test-results` cleared before each run: + +| | 3 000 warmup | 100 000 warmup | +|---|---|---| +| `desktopTest`, total test time | 4.93–4.97 s | 5.10–5.20 s | +| …of which `UpcallBoundaryCostTest` | 0.036 s | 0.155–0.162 s | +| …of which `GeneratedProxyCostTest` | 0.553–0.571 s | 0.625–0.661 s | +| `wasmJsNodeTest`, wall clock | 4.31 s / 5.85 s | 4.43 s / 6.11 s | + +**About +0.2 s on desktop (+4% of test time) and +0.1 s on wasm.** The warmup is bounded by the very +cost it is warming, so eight rows of 100 000 calls at ~500 ns is under a second by construction. The +wasm rows are wall clock because that runner reports 0.0 s per class in its XML. + +Test counts are unchanged throughout: **desktop 360/0/1, wasmJs 344/0/0**, on every run quoted here. + +**Load.** 1.14 at the start, 1.6–3.8 for every run quoted, `uptime` checked before each batch. No +other agent was running and no worktree was created during this pass, so the Spotlight problem the +previous section had did not arise. Two readings taken while load was transiently 6.25 (back-to-back +Gradle invocations) were timing measurements only, and are not among the per-call figures. + diff --git a/docs/archive/investigations/android-unregistered-surface.md b/docs/archive/investigations/android-unregistered-surface.md new file mode 100644 index 000000000..de644b51f --- /dev/null +++ b/docs/archive/investigations/android-unregistered-surface.md @@ -0,0 +1,269 @@ +> **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`, +> `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 +> 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. +> +> Everything from here down is retained unedited. + +> **Earlier status: the string half of this report is closed; the counts below are stale.** +> +> 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 +> just the four functions it happens to list. What remains is the pointer-only surface, and the +> reachability reasoning for that still holds. +> +> See ROADMAP §2 for the current numbers and `docs/design/marshalling-design.md` for the per-argument +> intern/scratch rule the migration followed. + +This report analyzes the reachability of the 304 unregistered functions in the Android JNI surface (`bindings.kt`). An unregistered function relies on name-based `@CName` linking which on Android leads to arguments arriving shifted, Kotlin `String`s being passed incorrectly, and `java.lang.Long` boxing issues. Hitting any of these functions is a latent `SIGSEGV` or similar crash. + +We traced the call graph backwards from the public API wrappers in `commonMain` (specifically under `ffi/`) through `EmbedAPI.kt` to the actual Android bindings. + +## 1. Reachable and Unregistered + +Out of the unregistered functions, exactly **71 are reachable** from the current public API. + +The list below shows the reachable functions, ordered by how likely an ordinary user is to hit them. Functions that take strings are placed at the top because they are guaranteed to crash the process due to `jstring` vs Kotlin/Native `String` mismatch. Functions used by core objects (`PyObject`, `PyDict`, `PyList`, etc.) follow. + +### Critical Risk: Functions taking `String` +These functions take a string argument and are highly likely to crash immediately if reached. + +- **`PyImport_AddModule`** + - `ffi/PyTypeChecks.kt` -> `EmbedAPI.PyImport_AddModule()` -> `bindings.PyImport_AddModule()` +- **`PyObject_DelAttrString`** + - `ffi/PyObject.kt` -> `EmbedAPI.PyObject_DelAttrString()` -> `bindings.PyObject_DelAttrString()` +- **`PyObject_SetAttrString`** + - `ffi/PyObject.kt` -> `EmbedAPI.PyObject_SetAttrString()` -> `bindings.PyObject_SetAttrString()` +- **`PyErr_SetString`** + - `ffi/exceptions/PyException.kt` -> `EmbedAPI.PyErr_SetString()` -> `bindings.PyErr_SetString()` + +### High Risk: Core Object Model (Collections and Types) +These functions manipulate common Python objects (Dicts, Lists, Tuples, Sets, etc.) and are very likely to be called during normal usage. + +- **`PyDict_Clear`** + - `ffi/types/collections/PyDict.kt` -> `EmbedAPI.PyDict_Clear()` -> `bindings.PyDict_Clear()` +- **`PyDict_Contains`** + - `ffi/types/collections/PyDict.kt` -> `EmbedAPI.PyDict_Contains()` -> `bindings.PyDict_Contains()` +- **`PyDict_DelItem`** + - `ffi/types/collections/PyDict.kt` -> `EmbedAPI.PyDict_DelItem()` -> `bindings.PyDict_DelItem()` +- **`PyDict_GetItem`** + - `ffi/types/collections/PyDict.kt` -> `EmbedAPI.PyDict_GetItem()` -> `bindings.PyDict_GetItem()` +- **`PyDict_Items`** + - `ffi/types/collections/PyDict.kt` -> `EmbedAPI.PyDict_Items()` -> `bindings.PyDict_Items()` +- **`PyDict_Size`** + - `ffi/types/collections/PyDict.kt` -> `EmbedAPI.PyDict_Size()` -> `bindings.PyDict_Size()` +- **`PyDict_Values`** + - `ffi/types/collections/PyDict.kt` -> `EmbedAPI.PyDict_Values()` -> `bindings.PyDict_Values()` +- **`PyList_Append`** + - `ffi/types/collections/PyList.kt` -> `EmbedAPI.PyList_Append()` -> `bindings.PyList_Append()` +- **`PyList_AsTuple`** + - `ffi/types/collections/PyDict.kt` -> `EmbedAPI.PyList_AsTuple()` -> `bindings.PyList_AsTuple()` +- **`PyList_Insert`** + - `ffi/types/collections/PyList.kt` -> `EmbedAPI.PyList_Insert()` -> `bindings.PyList_Insert()` +- **`PyList_Reverse`** + - `ffi/types/collections/PyList.kt` -> `EmbedAPI.PyList_Reverse()` -> `bindings.PyList_Reverse()` +- **`PyList_Sort`** + - `ffi/types/collections/PyList.kt` -> `EmbedAPI.PyList_Sort()` -> `bindings.PyList_Sort()` +- **`PyTuple_GetItem`** + - `ffi/PyType.kt` -> `EmbedAPI.PyTuple_GetItem()` -> `bindings.PyTuple_GetItem()` + - `ffi/types/collections/CollectionSupport.kt` -> `EmbedAPI.PyTuple_GetItem()` -> `bindings.PyTuple_GetItem()` + - `ffi/types/collections/PyDict.kt` -> `EmbedAPI.PyTuple_GetItem()` -> `bindings.PyTuple_GetItem()` + - `ffi/types/collections/PyTuple.kt` -> `EmbedAPI.PyTuple_GetItem()` -> `bindings.PyTuple_GetItem()` +- **`PyTuple_GetSlice`** + - `ffi/types/collections/PyTuple.kt` -> `EmbedAPI.PyTuple_GetSlice()` -> `bindings.PyTuple_GetSlice()` +- **`PyTuple_Size`** + - `ffi/PyType.kt` -> `EmbedAPI.PyTuple_Size()` -> `bindings.PyTuple_Size()` + - `ffi/types/collections/CollectionSupport.kt` -> `EmbedAPI.PyTuple_Size()` -> `bindings.PyTuple_Size()` + - `ffi/types/collections/PyDict.kt` -> `EmbedAPI.PyTuple_Size()` -> `bindings.PyTuple_Size()` + - `ffi/types/collections/PyTuple.kt` -> `EmbedAPI.PyTuple_Size()` -> `bindings.PyTuple_Size()` +- **`PySequence_Contains`** + - `ffi/types/collections/PyDict.kt` -> `EmbedAPI.PySequence_Contains()` -> `bindings.PySequence_Contains()` + - `ffi/types/collections/PyList.kt` -> `EmbedAPI.PySequence_Contains()` -> `bindings.PySequence_Contains()` + - `ffi/types/collections/PyTuple.kt` -> `EmbedAPI.PySequence_Contains()` -> `bindings.PySequence_Contains()` +- **`PySequence_List`** + - `ffi/types/collections/PyList.kt` -> `EmbedAPI.PySequence_List()` -> `bindings.PySequence_List()` +- **`PySequence_Tuple`** + - `ffi/types/collections/CollectionSupport.kt` -> `EmbedAPI.PySequence_Tuple()` -> `bindings.PySequence_Tuple()` +- **`PySet_Add`** + - `ffi/types/collections/PySet.kt` -> `EmbedAPI.PySet_Add()` -> `bindings.PySet_Add()` +- **`PySet_Clear`** + - `ffi/types/collections/PySet.kt` -> `EmbedAPI.PySet_Clear()` -> `bindings.PySet_Clear()` +- **`PySet_Contains`** + - `ffi/types/collections/PyFrozenSet.kt` -> `EmbedAPI.PySet_Contains()` -> `bindings.PySet_Contains()` + - `ffi/types/collections/PySet.kt` -> `EmbedAPI.PySet_Contains()` -> `bindings.PySet_Contains()` +- **`PySet_Discard`** + - `ffi/types/collections/PySet.kt` -> `EmbedAPI.PySet_Discard()` -> `bindings.PySet_Discard()` +- **`PySet_New`** + - `ffi/types/collections/PySet.kt` -> `EmbedAPI.PySet_New()` -> `bindings.PySet_New()` +- **`PySet_Pop`** + - `ffi/types/collections/PySet.kt` -> `EmbedAPI.PySet_Pop()` -> `bindings.PySet_Pop()` +- **`PySet_Size`** + - `ffi/types/collections/PyFrozenSet.kt` -> `EmbedAPI.PySet_Size()` -> `bindings.PySet_Size()` + - `ffi/types/collections/PySet.kt` -> `EmbedAPI.PySet_Size()` -> `bindings.PySet_Size()` +- **`PyFrozenSet_New`** + - `ffi/types/collections/PyFrozenSet.kt` -> `EmbedAPI.PyFrozenSet_New()` -> `bindings.PyFrozenSet_New()` +- **`PyObject_Call`** + - `ffi/PyObject.kt` -> `EmbedAPI.PyObject_Call()` -> `bindings.PyObject_Call()` +- **`PyObject_DelItem`** + - `ffi/types/collections/PyList.kt` -> `EmbedAPI.PyObject_DelItem()` -> `bindings.PyObject_DelItem()` +- **`PyObject_GetItem`** + - `ffi/types/collections/PyList.kt` -> `EmbedAPI.PyObject_GetItem()` -> `bindings.PyObject_GetItem()` +- **`PyObject_GetIter`** + - `ffi/PyType.kt` -> `EmbedAPI.PyObject_GetIter()` -> `bindings.PyObject_GetIter()` + - `ffi/types/collections/PyTuple.kt` -> `EmbedAPI.PyObject_GetIter()` -> `bindings.PyObject_GetIter()` +- **`PyObject_IsInstance`** + - `ffi/PyType.kt` -> `EmbedAPI.PyObject_IsInstance()` -> `bindings.PyObject_IsInstance()` + - `ffi/PyTypeChecks.kt` -> `EmbedAPI.PyObject_IsInstance()` -> `bindings.PyObject_IsInstance()` +- **`PyObject_Repr`** + - `ffi/PyObject.kt` -> `EmbedAPI.PyObject_Repr()` -> `bindings.PyObject_Repr()` +- **`PyObject_RichCompare`** + - `ffi/PyObject.kt` -> `EmbedAPI.PyObject_RichCompare()` -> `bindings.PyObject_RichCompare()` +- **`PyObject_RichCompareBool`** + - `ffi/types/basic/PyFloat.kt` -> `EmbedAPI.PyObject_RichCompareBool()` -> `bindings.PyObject_RichCompareBool()` + - `ffi/types/basic/PyInt.kt` -> `EmbedAPI.PyObject_RichCompareBool()` -> `bindings.PyObject_RichCompareBool()` + - `ffi/types/collections/CollectionSupport.kt` -> `EmbedAPI.PyObject_RichCompareBool()` -> `bindings.PyObject_RichCompareBool()` +- **`PyObject_SetItem`** + - `ffi/types/collections/PyList.kt` -> `EmbedAPI.PyObject_SetItem()` -> `bindings.PyObject_SetItem()` +- **`PyObject_Type`** + - `ffi/PyObject.kt` -> `EmbedAPI.PyObject_Type()` -> `bindings.PyObject_Type()` + - `ffi/PyType.kt` -> `EmbedAPI.PyObject_Type()` -> `bindings.PyObject_Type()` + - `ffi/PyTypeChecks.kt` -> `EmbedAPI.PyObject_Type()` -> `bindings.PyObject_Type()` + - `ffi/conversion/Context.kt` -> `EmbedAPI.PyObject_Type()` -> `bindings.PyObject_Type()` + - `ffi/exceptions/PyException.kt` -> `EmbedAPI.PyObject_Type()` -> `bindings.PyObject_Type()` + - `ffi/types/collections/CollectionSupport.kt` -> `EmbedAPI.PyObject_Type()` -> `bindings.PyObject_Type()` +- **`PyModule_GetDict`** + - `ffi/types/modules/PyModule.kt` -> `EmbedAPI.PyModule_GetDict()` -> `bindings.PyModule_GetDict()` +- **`PyModule_GetFilenameObject`** + - `ffi/types/modules/PyModule.kt` -> `EmbedAPI.PyModule_GetFilenameObject()` -> `bindings.PyModule_GetFilenameObject()` +- **`PyModule_GetName`** + - `ffi/types/modules/PyModule.kt` -> `EmbedAPI.PyModule_GetName()` -> `bindings.PyModule_GetName()` +- **`PyType_GetName`** + - `ffi/PyType.kt` -> `EmbedAPI.PyType_GetName()` -> `bindings.PyType_GetName()` + - `ffi/exceptions/PyException.kt` -> `EmbedAPI.PyType_GetName()` -> `bindings.PyType_GetName()` +- **`PyType_IsSubtype`** + - `ffi/PyType.kt` -> `EmbedAPI.PyType_IsSubtype()` -> `bindings.PyType_IsSubtype()` + +### Medium Risk: Exception and Error Handling +These functions are less likely to be called during standard execution, but are critical when an error happens. A crash here masks the actual problem. + +- **`PyErr_GetRaisedException`** + - `ffi/exceptions/PyException.kt` -> `EmbedAPI.PyErr_GetRaisedException()` -> `bindings.PyErr_GetRaisedException()` +- **`PyErr_SetRaisedException`** + - `ffi/exceptions/PyException.kt` -> `EmbedAPI.PyErr_SetRaisedException()` -> `bindings.PyErr_SetRaisedException()` +- **`PyException_GetCause`** + - `ffi/exceptions/PyException.kt` -> `EmbedAPI.PyException_GetCause()` -> `bindings.PyException_GetCause()` +- **`PyException_GetContext`** + - `ffi/exceptions/PyException.kt` -> `EmbedAPI.PyException_GetContext()` -> `bindings.PyException_GetContext()` +- **`PyException_GetTraceback`** + - `ffi/exceptions/PyException.kt` -> `EmbedAPI.PyException_GetTraceback()` -> `bindings.PyException_GetTraceback()` + +### Medium Risk: Mathematics and Basic Types +- **`PyNumber_Add`** + - `ffi/types/basic/PyFloat.kt` -> `EmbedAPI.PyNumber_Add()` -> `bindings.PyNumber_Add()` + - `ffi/types/basic/PyInt.kt` -> `EmbedAPI.PyNumber_Add()` -> `bindings.PyNumber_Add()` +- **`PyNumber_And`** + - `ffi/types/collections/PyFrozenSet.kt` -> `EmbedAPI.PyNumber_And()` -> `bindings.PyNumber_And()` + - `ffi/types/collections/PySet.kt` -> `EmbedAPI.PyNumber_And()` -> `bindings.PyNumber_And()` +- **`PyNumber_Multiply`** + - `ffi/types/basic/PyFloat.kt` -> `EmbedAPI.PyNumber_Multiply()` -> `bindings.PyNumber_Multiply()` + - `ffi/types/basic/PyInt.kt` -> `EmbedAPI.PyNumber_Multiply()` -> `bindings.PyNumber_Multiply()` +- **`PyNumber_Or`** + - `ffi/types/collections/PyFrozenSet.kt` -> `EmbedAPI.PyNumber_Or()` -> `bindings.PyNumber_Or()` + - `ffi/types/collections/PySet.kt` -> `EmbedAPI.PyNumber_Or()` -> `bindings.PyNumber_Or()` +- **`PyNumber_Subtract`** + - `ffi/types/basic/PyFloat.kt` -> `EmbedAPI.PyNumber_Subtract()` -> `bindings.PyNumber_Subtract()` + - `ffi/types/basic/PyInt.kt` -> `EmbedAPI.PyNumber_Subtract()` -> `bindings.PyNumber_Subtract()` + - `ffi/types/collections/PyFrozenSet.kt` -> `EmbedAPI.PyNumber_Subtract()` -> `bindings.PyNumber_Subtract()` + - `ffi/types/collections/PySet.kt` -> `EmbedAPI.PyNumber_Subtract()` -> `bindings.PyNumber_Subtract()` +- **`PyFloat_AsDouble`** + - `ffi/types/basic/PyComplex.kt` -> `EmbedAPI.PyFloat_AsDouble()` -> `bindings.PyFloat_AsDouble()` + - `ffi/types/basic/PyFloat.kt` -> `EmbedAPI.PyFloat_AsDouble()` -> `bindings.PyFloat_AsDouble()` + - `ffi/types/collections/CollectionSupport.kt` -> `EmbedAPI.PyFloat_AsDouble()` -> `bindings.PyFloat_AsDouble()` +- **`PyFloat_FromDouble`** + - `ffi/types/basic/PyFloat.kt` -> `EmbedAPI.PyFloat_FromDouble()` -> `bindings.PyFloat_FromDouble()` +- **`PyLong_AsInt`** + - `ffi/exceptions/PyTraceback.kt` -> `EmbedAPI.PyLong_AsInt()` -> `bindings.PyLong_AsInt()` +- **`PyLong_AsLongLong`** + - `ffi/PyObject.kt` -> `EmbedAPI.PyLong_AsLongLong()` -> `bindings.PyLong_AsLongLong()` + - `ffi/types/basic/PyInt.kt` -> `EmbedAPI.PyLong_AsLongLong()` -> `bindings.PyLong_AsLongLong()` + - `ffi/types/collections/CollectionSupport.kt` -> `EmbedAPI.PyLong_AsLongLong()` -> `bindings.PyLong_AsLongLong()` +- **`PyBool_FromLong`** + - `ffi/types/basic/PyBool.kt` -> `EmbedAPI.PyBool_FromLong()` -> `bindings.PyBool_FromLong()` +- **`PyUnicode_Concat`** + - `ffi/types/basic/PyString.kt` -> `EmbedAPI.PyUnicode_Concat()` -> `bindings.PyUnicode_Concat()` +- **`PyUnicode_Contains`** + - `ffi/types/basic/PyString.kt` -> `EmbedAPI.PyUnicode_Contains()` -> `bindings.PyUnicode_Contains()` + +### Miscellaneous Utility Functions +- **`PyCallable_Check`** + - `ffi/PyObject.kt` -> `EmbedAPI.PyCallable_Check()` -> `bindings.PyCallable_Check()` +- **`PyEval_GetBuiltins`** + - `ffi/PyTypeChecks.kt` -> `EmbedAPI.PyEval_GetBuiltins()` -> `bindings.PyEval_GetBuiltins()` + - `ffi/types/basic/PyComplex.kt` -> `EmbedAPI.PyEval_GetBuiltins()` -> `bindings.PyEval_GetBuiltins()` + - `ffi/types/basic/PyNone.kt` -> `EmbedAPI.PyEval_GetBuiltins()` -> `bindings.PyEval_GetBuiltins()` +- **`PyIter_Next`** + - `ffi/types/iteration/PyIterator.kt` -> `EmbedAPI.PyIter_Next()` -> `bindings.PyIter_Next()` +- **`Py_GetBuildInfo`** + - `ffi/Python3.kt` -> `EmbedAPI.Py_GetBuildInfo()` -> `bindings.Py_GetBuildInfo()` +- **`Py_GetCompiler`** + - `ffi/Python3.kt` -> `EmbedAPI.Py_GetCompiler()` -> `bindings.Py_GetCompiler()` +- **`Py_GetCopyright`** + - `ffi/Python3.kt` -> `EmbedAPI.Py_GetCopyright()` -> `bindings.Py_GetCopyright()` +- **`Py_GetPlatform`** + - `ffi/Python3.kt` -> `EmbedAPI.Py_GetPlatform()` -> `bindings.Py_GetPlatform()` +- **`Py_RunMain`** + - `ffi/Python3.kt` -> `EmbedAPI.Py_RunMain()` -> `bindings.Py_RunMain()` +- **`ffiReadUtf8`** (Helper used for marshalling) + - `ffi/PyObject.kt` -> `EmbedAPI.PyUnicode_AsUTF8()` -> `bindings.ffiReadUtf8()` + - `ffi/PyType.kt` -> `EmbedAPI.PyUnicode_AsUTF8()` -> `bindings.ffiReadUtf8()` + - `ffi/Python3.kt` -> `EmbedAPI.Py_GetVersion()` -> `bindings.ffiReadUtf8()` + - `ffi/exceptions/PyException.kt` -> `EmbedAPI.PyUnicode_AsUTF8()` -> `bindings.ffiReadUtf8()` + - `ffi/exceptions/PyTraceback.kt` -> `EmbedAPI.PyUnicode_AsUTF8()` -> `bindings.ffiReadUtf8()` + - `ffi/types/basic/PyString.kt` -> `EmbedAPI.PyUnicode_AsUTF8()` -> `bindings.ffiReadUtf8()` + - `ffi/types/collections/CollectionSupport.kt` -> `EmbedAPI.PyUnicode_AsUTF8()` -> `bindings.ffiReadUtf8()` + +--- + +## 2. Unreachable Today + +There are **233** functions present in `bindings.kt` as unregistered `external fun`s that are not reached by any code in `commonMain` today (totalling 304 unregistered out of 375 overall, though exact counts from parsing show 71 reachable and 222 unreachable). + +While they should eventually be migrated to ensure the bindings layer is safe to use in its entirety, they **do not pose an immediate crash risk** for current users of the library. + +## 3. Cannot Determine + +Every path from the public `commonMain` API layer down to the underlying bindings layer could be definitively traced statically. There are **0** functions whose reachability could not be determined. The analysis explicitly covered every wrapper and `expect`/`actual` binding chain. + +## 4. Suggested Migration Order + +1. **String-taking reachable functions (The immediate crashers)** + - Because they process strings incorrectly across JNI natively, calling any of these guarantees a crash. + - `PyImport_AddModule`, `PyErr_SetString`, `PyObject_DelAttrString`, `PyObject_SetAttrString` + +2. **Core Object Model (Primitives & Collections)** + - Heavily used functions that could lead to crashes due to missing `JNIEnv` or boxing issues (`java.lang.Long` vs `jlong`). + - `PyDict_Clear`, `PyDict_Contains`, `PyDict_DelItem`, `PyDict_GetItem`, `PyDict_Items`, `PyDict_Size`, `PyDict_Values`, `PyList_Append`, `PyList_AsTuple`, `PyList_Insert`, `PyList_Reverse`, `PyList_Sort`, `PyModule_GetDict`, `PyModule_GetFilenameObject`, `PyObject_Call`, `PyObject_DelItem`, `PyObject_GetItem`, `PyObject_GetIter`, `PyObject_IsInstance`, `PyObject_Repr`, `PyObject_RichCompare`, `PyObject_RichCompareBool`, `PyObject_SetItem`, `PyObject_Type`, `PySequence_List`, `PySequence_Tuple`, `PyTuple_GetItem`, `PyTuple_GetSlice`, `PyTuple_Size`, `PyType_GetName`, `PyType_IsSubtype`, etc. + +3. **Exception and Error handling** + - Less likely to be hit in a happy path, but critical for clean failure handling. + - `PyErr_GetRaisedException`, `PyErr_SetRaisedException`, `PyException_GetCause`, `PyException_GetContext`, `PyException_GetTraceback`. + +4. **Remaining reachable functions** + - Other utility functions (e.g. maths and module utilities). + +5. **Unreachable functions** + - Deferred until the public surface is expanded to use them. diff --git a/docs/archive/investigations/gc-scheduling-investigation.md b/docs/archive/investigations/gc-scheduling-investigation.md new file mode 100644 index 000000000..63565e367 --- /dev/null +++ b/docs/archive/investigations/gc-scheduling-investigation.md @@ -0,0 +1,516 @@ +> **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 + `/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, + 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 + produced the earlier free-threaded conclusion. The correction was itself measured; the + hypothesis it replaced was measured and rejected rather than merely doubted. + +## 0. Summary + +Two symptoms that looked unrelated share one cause, and a third that looked like the same thing is +not. + +- **The cyclic collector never runs.** An embedder that only ever calls `PyObject_Call` and friends + never triggers a generational collection, however much it allocates. True on both builds. (§1) +- **Free-threaded, a completed `Py_DecRef` can leave the object alive.** The count is correct and + the decrement happened, but `tp_dealloc` has not run and the memory is not back. (§2) + + These two are the same thing: CPython schedules both jobs by setting a bit on the *eval breaker*, + and the only code that reads those bits is `_Py_HandlePending`, which the evaluation loop calls + and nothing else does. No bytecode frame, no checkpoint; no checkpoint, no collection and no + merge. +- **`CycleCollectionTest`'s free-threaded failure is *not* the same thing**, and an eval-loop + checkpoint provably does not fix it. It is deferred reference counting on the heap type, and what + 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 + 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 + 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) + +## 1. `_Py_ScheduleGC` only sets a bit + +Allocation-driven collection has not been a direct call since 3.12. `_Py_ScheduleGC` sets +`_PY_GC_SCHEDULED_BIT` on the eval breaker and returns: + +| build | function | file:line (v3.14.7, verified) | +|---|---|---| +| global lock | `_Py_ScheduleGC` | `Python/gc.c:1846` | +| free-threaded | `_Py_ScheduleGC` | `Python/gc_free_threading.c:2795` | + +The bit's only reader is `_Py_HandlePending` (`Python/ceval_gil.c:1357`), which calls `_Py_RunGC` +when it is set (`Python/ceval_gil.c:1397-1399`). `_Py_HandlePending` in turn has one caller family: the +`_CHECK_PERIODIC` / `_CHECK_PERIODIC_IF_NOT_YIELD_FROM` uops in `Python/bytecodes.c`, which open +every Python-level frame (`RESUME`) and close every call instruction. There is no public entry +point to it. + +So **allocation schedules a collection that only the evaluation loop will ever perform.** An +embedder that executes no Python-level frame accumulates the scheduled bit forever and its cycles +are never broken. This is not a free-threading property; it is the same on both builds, and it is +why `gc.collect()` (or the C `PyGC_Collect`) has been the only thing that has ever collected +anything in this library's tests. + +### `Py_MakePendingCalls` is not a substitute + +It is the obvious Stable ABI candidate and it does not work. It forwards to +`_PyEval_MakePendingCalls` (`Python/ceval_gil.c:1034`), which handles `handle_signals` and +`make_pending_calls` and returns. It never looks at `_PY_GC_SCHEDULED_BIT`, and on a free-threaded +build it never looks at the merge bit either. + +This one is checked here rather than taken on trust: +`EvalCheckpointTest.testDrainPendingReleasesReclaimsWhatTheCleanerGaveBack` calls +`Py_MakePendingCalls`, asserts it returned 0, and asserts the reference count did not move. + +## 2. Free-threading: biased reference counting, and the queue only the eval loop drains + +`Py_DECREF` on a free-threaded build is not one operation. It branches on whether the decrementing +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 + 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`). + +The queue is drained by `_Py_brc_merge_refcounts`, whose only caller is, again, `_Py_HandlePending` +(`Python/ceval_gil.c:1388`), behind `_PY_EVAL_EXPLICIT_MERGE_BIT`. + +**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 +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 +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 +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" — +caught both, exactly as it was written to. + +### 3a. The probe read the wrong bytes + +`ob_refcnt` is at offset 0 only on a build with the global lock. Free-threaded CPython lays the +header out differently: + +```c +/* Include/object.h, Py_GIL_DISABLED */ +struct _object { + uintptr_t ob_tid; /* +0, 8 bytes -- owning thread id, NOT a refcount */ + uint16_t ob_flags; /* +8 */ + PyMutex ob_mutex; /* +10 */ + uint8_t ob_gc_bits; /* +11 */ + uint32_t ob_ref_local; /* +12, 4 bytes */ + Py_ssize_t ob_ref_shared; /* +16, 8 bytes, shifted by _Py_REF_SHARED_SHIFT (2), + low 2 bits are state flags */ + PyTypeObject *ob_type; /* +24 */ +}; +``` + +`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 +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. + +The probe now reads the right fields on each build. + +### 3b. Correcting the correction: heap types **are** deferred-reference-counted + +This section exists because two successive diagnoses of this failure were wrong in opposite +directions, and only a direct measurement settles it. + +ROADMAP §9 originally said: + +> Fixing the offset would not save the test. Its premise is that "each live instance of a heap type +> holds one reference to that type", and free-threaded CPython gives heap types **deferred +> reference counting** [...] The invariant simply does not exist there, so `tp_dealloc`'s +> obligation to release the type reference cannot be checked this way at all. + +A later source trace proposed the opposite: that this was wrong, that `_PyGC_BITS_DEFERRED` is set +only on modules, top-level functions, descriptors and immortal objects, that nothing marks a +`PyType_FromSpec` heap type or its instances as deferred, and that the real cause was queued BRC +decrements that an eval-loop checkpoint would flush. + +**Measured, both of those are partly wrong, and the practical conclusion belongs to the first.** +Dumping the proxy type's header on the free-threaded build, before and after 100 instantiations: + +| | `ob_tid` (+0) | `ob_gc_bits` (+11) | `ob_ref_local` (+12) | `ob_ref_shared` (+16) | +|---|---|---|---|---| +| fresh type | `0x16d9b70e0` | `0x41` | 2 | `0x3ffffffffffffffd` | +| 100 instances alive | `0x16d9b70e0` | `0x41` | 2 | `0x3ffffffffffffffd` | +| after `drainPendingReleases()` | `0x16d9b70e0` | `0x41` | 2 | `0x3ffffffffffffffd` | +| after `PyGC_Collect()` | `0x16d9b70e0` | `0x41` | 2 | `0x400000000000018d` | +| after 100 `Py_DecRef` → 100 `tp_dealloc` | `0` | `0x41` | 0 | `0x4000000000000007` | + +Reading that off: + +- `ob_gc_bits == 0x41` is `_PyGC_BITS_TRACKED | _PyGC_BITS_DEFERRED`. The heap type **is** marked + deferred. +- `ob_ref_shared == 0x3ffffffffffffffd` is `(0x0fffffffffffffff << 2) | 1`: a shared count of + `2^60 - 1` = `PY_SSIZE_T_MAX / 8`, which is the deferred sentinel, with the + `_Py_REF_MAYBE_WEAKREF` flag. Two independent signals agreeing. +- **Creating 100 instances changed nothing.** `PyType_GenericAlloc` increments the type through + `_Py_INCREF_TYPE`, which is a no-op for a deferred type on its owning thread. The header is + bit-identical. +- **An eval-loop checkpoint changed nothing either.** This is the decisive one: the proposed fix + does not work, and cannot, because there is no queued decrement to merge. The increments were + never made in the first place. +- **A collection is what makes deferred references real.** `ob_ref_shared` rose by exactly + `100 << 2`. This is `gc_free_threading.c` converting deferred references into counted ones as it + 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, + 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 +correction did not touch the failing assertion. + +`CycleCollectionTest` therefore takes a `PyGC_Collect()` on either side of its instantiation loop, +free-threaded only. The assertions are unchanged: the same "rises by exactly 100" and "returns to +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 +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 +proper stays clean: `ProxyTypeFactory` writes only into memory it obtained from +`PyObject_GetTypeData`. + +## 4. What this repo does about §1 and §2 + +`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 +scheduled. + +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. + +`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` +has moved since the last one. It defaults to 32 on free-threaded builds and 0 (off) otherwise. +`reachEvalCheckpointHoldingGIL` additionally declines while `PyErr_Occurred()` is non-null, for the +same error-indicator reason. + +It is not taken from the cleaner. The queue that needs merging belongs to the thread that *owns* +the object; a cleaner owns nothing, so a checkpoint there would run Python on a cleaner thread (the +ROADMAP §1 deadlock) in order to drain an empty queue. +`EvalCheckpointTest.testCleanerActivityAloneTakesNoCheckpoint` pins the suppression. + +`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 +deferred reference. + +## 5. Measurements + +`EvalCheckpointTest.testCheckpointCostAgainstTheAlternatives`, free-threaded desktop build, macOS +arm64. The test asserts the *ordering*, not the absolute numbers: the ordering is a property of the +design and would break the moment the checkpoint started compiling or allocating. + +| | ns/op | | +|---|---:|---| +| `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 | + +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. + +`PyGC_Collect` being ~740× the checkpoint is also the cost `CycleCollectionTest` pays for the two +collections §3b requires: ~0.45 ms for the whole test, free-threaded only. + +## 6. What is still true, and what to watch + +- The scheduled-GC half of §1 affects **both** builds. Nothing in this repo currently depends on + allocation-driven collection, and `autoDrainInterval` defaults to 0 with the global lock, so the + default configuration still never runs a generational collection on its own. That is a choice, + not an oversight, and reversing it is one assignment. +- `EvalCheckpointTest` asserts that `Py_MakePendingCalls` does *not* merge. If CPython ever changes + its mind, that test fails and says so. +- The header layout in §3a is a private detail read in exactly one place, and that read is guarded + by an assertion that fails loudly if the offsets stop being right. It already has, twice. +- The deferred sentinel gives roughly `2^60` of headroom, so nothing here is at risk of the count + 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 + balanced by the collector, not by the two calls. + +## 7. GC Accumulation and Reentrancy Measurement + +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. +- **`__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 + +Everything in this section was measured here, on `desktopTest`, macOS arm64, CPython 3.14.7, both +builds. The workload is `GCSchedulingMeasurementTest.buildAndDropCycles`: 10,000 unreachable +`list` cycles, 20,000 objects, built entirely through the C API. + +### 8a. The probe was holding the evidence + +The measurement chain used to be written inline as + +```kotlin +gc.getAttr("get_objects").invoke().getAttr("__len__").invoke().toString().toInt() +``` + +which closes none of its temporaries. `gc.get_objects()` returns a list holding **a strong +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 +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 +residue essentially completely, on both builds, in every configuration tried**: 60+ repetitions, +post-collection residue between −13 and +2 objects out of ~20,000. + +So the residue is ordinary collectable cyclic garbage. What differs free-threaded is what makes the +collector *run*, not what it can take. `GCSchedulingMeasurementTest` now asserts this on both +builds (`assertResidueIsOrdinaryCollectableGarbage`), which is the opposite of what the previous +text asserted about it. + +### 8b. Rejected: "per-call `PyGILState_Ensure`/`Release` destroys the scheduled GC bit" + +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 +collection. + +**Measured and rejected.** Holding the checkpoint count fixed at 2,000 explicit +`Python3.drainPendingReleases()` calls (`autoDrainInterval = 0`, one drain every 5 rounds) and +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 | +| 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 +opposite, so it is wrong. + +The single-scope row does not rescue it either: **it is bimodal within one JVM.** Those ten +repetitions are consecutive and identical; it reclaimed on the first three and then never again. +The configuration latches, which is a property of accumulated interpreter state, not of the scope +shape the call happens to be made under. + +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 | +| 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 | + +The last row is a property of the gate, not of the collector: a single outermost scope decrements +`checkpointCountdown` once, so at interval 32 no automatic checkpoint is ever reached. An embedder +that wraps a long batch in one `withPython` therefore gets *fewer* automatic checkpoints, not more. + +### 8c. The contrast that is actually there + +The same sweep on the build with the global lock is stable and near-complete in **every** shape: + +| configuration | checkpoints | residue | +|---|---:|---| +| per C API call, `autoDrainInterval = 32` | 1,875 | 1,964–1,972 | +| per C API call, 2,000 explicit drains | 2,000 | 1,920 ×5 | +| one per 100 rounds, 2,000 explicit drains | 2,000 | 1,920 ×5 | +| whole workload in one scope, 2,000 explicit drains | 2,000 | 1,920–1,962 | +| one per round, `autoDrainInterval = 32` | 312–313 | 1,556–1,616 | +| 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. + +### 8d. Answered: the checkpoint is fine, the collector asks a second question + +The question §8c leaves open is why a checkpoint that demonstrably runs +(`CheckpointCounter.reached` climbs by an identical, deterministic amount every run) triggers no +collection free-threaded, and why the same configuration latches from "reclaims" to "never +reclaims" inside one JVM. + +CPython v3.14.7 was fetched to `/Volumes/macMini/cpython-src` (not vendored here) and read. **The +checkpoint is not where this goes wrong.** The checkpoint reads the scheduled bit exactly as §1 +describes, on both builds. What differs is that free-threaded, `_Py_RunGC` then asks a *second* +question, and it is that one that answers no. + +#### The asymmetry, in four lines of source + +| | 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`) | +| 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`) | + +`gc_should_collect` (`gc_free_threading.c:2117`) is: + +```c +if (count <= threshold || threshold == 0 || !gc_enabled) return false; // :2123 +if (gcstate->old[0].threshold == 0) return true; // :2126 +if (count < gcstate->long_lived_total / 4) return false; // :2131 +return gc_should_collect_mem_usage(gcstate); // :2136 +``` + +and `gc_should_collect_mem_usage` (`:2080`) is, in substance: + +```c +if (deferred > threshold * 40) return true; // :2089 +if ((footprint - last_mem) > Py_MAX(last_mem / 10, 128)) return true; // :2100 +young.count = 0; deferred_count += young.count; return false; // :2109-2113 +``` + +`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. +`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 +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`. + +**This is also the latch.** `last_mem` only moves when a collection runs, and it moves to the +whole process's current footprint. Early in a JVM's life the footprint is still climbing steeply, +so the gate opens and collections happen; once a collection records the JVM's settled footprint, +the bar becomes a tenth of a large number that Python-side allocation cannot move, and it is shut +one-way. Nothing about the scope shape is involved, which is consistent with §8b rejecting it. + +#### Confirmed by experiment, not by reading + +The repo has been wrong before about causes derived from source reading, so each claim above was +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` +(`: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: +the memory gate, repeatedly. + +> This probe took two attempts, and the first one was wrong in the direction that would have +> produced a confident false answer. `gc_get_count_impl` (`Modules/gcmodule.c:215`) flushes the +> per-thread allocation buffer and sets `gc->alloc_count = 0`, and `record_allocation` only +> consults `gc_should_collect` when that buffer reaches `LOCAL_ALLOC_COUNT_THRESHOLD` = 512 +> (`:69`, `:2147`). Sampling every 500 allocations therefore stopped the collector from ever being +> 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 +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 | + +**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* — +nothing else feeds `deferred_count`. + +**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 | + +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 +collector's schedule is a function of the host process's memory, which for an embedder is not +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 — +supplied the collections. The scaling prediction is untested, not confirmed. + +#### What this changes + +- The free-threaded case now has a **bound**, where §8 could only say the residue was bimodal and + refuse to assert one. `FreeThreadedGCGateTest` holds free-threading to the same + "reclaims more than half" bound as the GIL build *once the gate the source names is open*, and + asserts the matched no-op on the GIL build so that a CPython change to either gate fails the test + and says which one. +- `GCSchedulingMeasurementTest.measureCyclicGarbageWithAutoDrain` still asserts no bound + free-threaded in the default configuration, and that is now a documented consequence rather than + an unknown: at default thresholds the outcome depends on the JVM's footprint history, so it is + genuinely not boundable, and the two arms that reclaimed in §8b's single-scope row were the JVM + still warming up. +- §8b's rejection stands and is now explained: scope shape cannot matter because the gate never + looks at thread state. + +#### 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 +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 +proportional to the heap. diff --git a/docs/archive/investigations/gil-parking-investigation.md b/docs/archive/investigations/gil-parking-investigation.md new file mode 100644 index 000000000..83709d809 --- /dev/null +++ b/docs/archive/investigations/gil-parking-investigation.md @@ -0,0 +1,369 @@ +> **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 + +Research only. No source in this repo was changed to produce this document; all CPython claims +below are cited from `python/cpython` at tag `v3.14.0` on GitHub, fetched read-only. + +## 0. Summary and confidence + +**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. + +**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 +`PyThreadState`. CPython's own comment in `pystate.c` says so explicitly: "We do not unbind the +gilstate tstate here. It will still be used in `PyGILState_Ensure()`." Reusing the same, +still-registered thread state is a supported idiom (it's what `Py_BEGIN_ALLOW_THREADS` / +`Py_END_ALLOW_THREADS` do on every existing embedding), so this fact **describes the mechanism +correctly but does not, by itself, explain a segfault.** Something else that the reuse *doesn't* +restore is the more likely proximate cause, and I could not pin that down from source reading +alone. See §5 for the two candidates and their weaknesses. + +**Confidence: medium on the mechanism (thread-state identity across the park/re-enter boundary), +low on the exact reason it becomes memory-unsafe.** The minimal experiment in §6 is designed to +close that gap cheaply, without running the full suite. + +## 1. Same thread, established rather than assumed + +`PythonTestFixture.available` is a Kotlin `by lazy` property; `Python3.initialize()` runs on +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 +`"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. + +This directly determines which branch of `PyGILState_Ensure()` fires later (§3): the +"already-registered" branch, not the "first time on this OS thread" branch. + +## 2. What `PyEval_SaveThread()` does to the calling thread's state + +Source (`Python/ceval_gil.c`, v3.14.0): + +```c +PyThreadState * +PyEval_SaveThread(void) +{ + PyThreadState *tstate = _PyThreadState_GET(); + _PyThreadState_Detach(tstate); + return tstate; +} +``` + +`_PyThreadState_Detach` (`Python/pystate.c`) calls `detach_thread(tstate, _Py_THREAD_DETACHED)`: + +```c +static void +detach_thread(PyThreadState *tstate, int detached_state) +{ + assert(_Py_atomic_load_int_relaxed(&tstate->state) == _Py_THREAD_ATTACHED); + assert(tstate == current_fast_get()); + if (tstate->critical_section != 0) { + _PyCriticalSection_SuspendAll(tstate); + } + ... + tstate_deactivate(tstate); + tstate_set_detached(tstate, detached_state); + current_fast_clear(&_PyRuntime); + _PyEval_ReleaseLock(tstate->interp, tstate, 0); +} +``` + +and `tstate_deactivate`: + +```c +static inline void +tstate_deactivate(PyThreadState *tstate) +{ + ... + tstate->_status.active = 0; + + // We do not unbind the gilstate tstate here. + // It will still be used in PyGILState_Ensure(). +} +``` + +So: the `PyThreadState` object itself is **not destroyed**. It is marked inactive/detached, the +GIL is released, and the per-OS-thread "current thread state" pointer +(`current_fast_get`/`current_fast_clear`, a `thread_local` variable) is cleared to `NULL`. What is +**not** touched anywhere in this path: `tstate->gilstate_counter`, and the GILState TSS slot +(`gilstate_tss` / historically `autoTSSkey`) that `PyGILState_Ensure` consults. That slot is a +*separate* piece of per-OS-thread storage from the "current thread state" pointer, and CPython's +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 +`_PyGILState_SetTstate()`'s debug assertions (`Python/pystate.c`): + +```c +assert(runtime->gilstate.autoInterpreterState == tstate->interp); +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 +attached during startup.) + +## 3. What `PyGILState_Ensure()` then does on that same thread + +Source (`Python/pystate.c`, v3.14.0): + +```c +PyGILState_STATE +PyGILState_Ensure(void) +{ + ... + PyThreadState *tcur = gilstate_tss_get(runtime); + int has_gil; + if (tcur == NULL) { + tcur = new_threadstate(runtime->gilstate.autoInterpreterState, + _PyThreadState_WHENCE_GILSTATE); + ... + bind_tstate(tcur); + bind_gilstate_tstate(tcur); + tcur->gilstate_counter = 0; + has_gil = 0; + } + else { + has_gil = holds_gil(tcur); + } + if (!has_gil) { + PyEval_RestoreThread(tcur); + } + ++tcur->gilstate_counter; + return has_gil ? PyGILState_LOCKED : PyGILState_UNLOCKED; +} +``` + +Because the GILState TSS slot on this OS thread is still bound (§2), `tcur` is **not** `NULL`, so +the "new thread" branch (which would create a genuinely second `PyThreadState`) is **not** taken. +`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 +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 +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 +here. It does not, by itself, identify what *does* go wrong. + +## 4. Whether `PyGILState_Release` is handed an invalid state + +No. Traced above: the `PyGILState_STATE` value threaded through `withGIL`'s `tState.state` / +`PyGILState_Release` call is always the value `PyGILState_Ensure` just returned for the current, +still-registered `tcur`. There is no thread-identity mismatch and no stale/deleted state reaching +`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 +`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 +shape from today's: today's parking call is made while the thread *is* attached, immediately +after `Py_Initialize()`, so this specific failure mode does not apply.) + +## 5. What CPython 3.14 changed here, and the two remaining candidates + +The whole attach/detach vocabulary read above (`_PyThreadState_Attach`/`Detach`, +`tstate_activate`/`deactivate`, the `_status.active` / `_status.bound_gilstate` bitfield, +`current_fast_get`/`set`/`clear`) is the free-threading-era (PEP 703) rewrite of thread-state +lifecycle management, unified so the same source serves both GIL and `Py_GIL_DISABLED` builds. I +did not verify the exact CPython version this landed in (older CPython used a simpler +`PyEval_AcquireThread`/`ReleaseThread` pair over a single `interp->tstate_current` swap), but it is +recent relative to most public documentation and tutorials on `PyGILState_*`, which is worth +knowing before trusting older blog posts or Stack Overflow answers about this API. + +Two candidates for what actually breaks, neither confirmed: + +**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`: + +```c +void +_PyThreadState_Attach(PyThreadState *tstate) +{ + ... + if (_tstate->c_stack_hard_limit == 0) { + _Py_InitializeRecursionLimits(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 +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 +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 +hundred KB to MB of C stack on its own. **I could not make this candidate fit the observed crash +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 +`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 +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 +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. + +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 +of state that reuse fails to restore is unresolved, with Candidate A eliminated by the mismatch in +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 +four steps on **one thread**, printing/asserting between each so a failure localizes to a specific +step instead of reporting "0 tests run": + +```kotlin +@Test +fun parkThenReenterSameThreadIsolatesTheFailure() { + Python3.initialize(silent = true) // must NOT be parked yet at this point + val savedBeforeGuess = PyGILState_GetThisThreadState() // record identity #1, still attached + println("before park: $savedBeforeGuess") + + val parked = PyEval_SaveThread() // manual parking, mirroring the + // commented-out call in initialize() + println("PyEval_SaveThread returned: $parked") + + // Step 1: bare re-entry, no Python C API call at all inside the guard except one that + // reads state without touching bytecode. + withGIL { + val after = PyGILState_GetThisThreadState() + println("after Ensure: $after") + // EXPECT: after == savedBeforeGuess (same PyThreadState*, confirms/refutes §3 empirically + // rather than from source reading alone). A mismatch would mean the "new thread" branch + // fired -- i.e. the TSS slot was NOT still bound, contradicting the CPython source read + // here, and pointing at something specific to this embedding (e.g. the JVM's native + // thread setup interacting with TSS) rather than to CPython's generic behaviour. + } + // Step 1 crashing here would mean the attach/detach cycle itself is broken -- contradicts + // this document's source reading, and the desktop Panama bindings for PyEval_SaveThread / + // PyGILState_Ensure/Release (bindings.kt:41-50) would be the next thing to audit, since this + // project has a documented history of invoke/invokeExact and ADDRESS/JAVA_LONG mismatches. + + // Step 2: a pure-C call that cannot execute Python bytecode. + withGIL { + Py_IsInitialized() + } + // Step 2 crashing but not Step 1 would point at something broken in ordinary C API use after + // reattachment, unrelated to bytecode execution -- undermining Candidate B. + + // Step 3: the exact call that crashed today. + withGIL { + PyImport_ImportModule("sys") + } + // Step 3 crashing but not Steps 1-2 confirms it's specifically bytecode execution (not mere + // attachment, not mere C API use) that's unsafe after reattachment -- supports Candidate B + // and argues against Candidate A (see the manual-lookup step below). + + // Step 4 (only if Step 3 crashes): bypass import_ensure_initialized's Python-level path + // entirely, staying in pure C, to see whether a plain cache lookup alone is safe: + withGIL { + val modules = PySys_GetObject("modules") + PyDict_GetItemString(modules!!, "sys") + } + // If Step 4 is safe where Step 3 is not, that isolates the crash to whatever Python-level + // code import_ensure_initialized runs on a cache hit -- worth reading Modules/import.c's + // import_ensure_initialized next, specifically for anything that inspects C-stack depth, + // per-thread caches, or recursion state. +} +``` + +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. + +`PyGILState_GetThisThreadState` and `PySys_GetObject`/`PyDict_GetItemString` are already declared +in `EmbedAPI.kt` (`commonMain`); their desktop `actual`s were not verified as part of this +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 + (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()` + itself from inside the same trampoline depth used by ordinary `withGIL` calls, rather than + directly from `Python3.initialize()`'s own stack frame, so the one-time calibration isn't + systematically shallower or deeper than steady-state use. This is a real design change, not a + 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 + 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 + 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 + not exhaustive (the `find()` symbol-lookup helper and the underlying `MethodHandle` descriptors + it builds were not traced end to end). + +- **A structurally different fix, independent of which candidate is right:** if reusing the + initialising thread's `PyThreadState` across a park/re-enter boundary turns out to be + 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 + "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. + +## 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 + 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 + 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. +- **Which CPython version introduced the current attach/detach vocabulary and the C-stack-address + recursion guard.** Both read as free-threading-era (PEP 703) work; I did not verify version + numbers, so treat "this is new" as directionally right but not dated. +- **Whether the desktop Panama bindings for the four functions in play are fully correct end to + end.** `bindings.kt:41-50` looked right on inspection (consistent `invokeExact`, consistent + `JAVA_LONG` pointer convention), but the `find()` helper and `MethodHandle` descriptor + construction underneath were not traced, and this project has a specific documented history of + bugs in exactly this area. +- **iOS.** This document is about the desktop crash only. ROADMAP §1 already separately notes that + the iOS simulator's `GCLeakTest` failure is not GIL-related and is a distinct, unexamined defect; + nothing here bears on it. diff --git a/docs/archive/investigations/jni-call-convention-audit.md b/docs/archive/investigations/jni-call-convention-audit.md new file mode 100644 index 000000000..874b23750 --- /dev/null +++ b/docs/archive/investigations/jni-call-convention-audit.md @@ -0,0 +1,340 @@ +> **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 + +Android binds CPython through `RegisterNatives`. Because `@CriticalNative` and `@FastNative` block the garbage collector and `@CriticalNative` lacks a `JNIEnv`, they must never be used for functions that can re-enter the Java runtime by running arbitrary Python code. + +This document classifies all 71 registered JNI functions in `jni_onload.def`. + +> ## Correction, added after verifying against the call sites +> +> **Section 1's headline is a false alarm.** It reads the registration table as the live +> configuration and concludes that six re-entrant functions are registered `@CriticalNative` and +> must be demoted. They are not live. +> +> `jni_onload.def` deliberately registers **all three conventions for each function** -- bare name +> for `@CriticalNative`, `F` suffix for `@FastNative`, `N` suffix for ordinary JNI -- and says so +> at line 105: *"Both are registered so the Kotlin side can pick per API level."* The convention +> that runs is chosen at the **call site**, not by the presence of a registration. +> +> Every one of the six already calls the ordinary variant, with a per-function reason in the +> source: +> +> ``` +> Py_Initialize -> Py_InitializeN "Runs site.py ... never on a GC-blocking path" +> Py_Finalize -> Py_FinalizeN +> PyErr_Clear -> PyErr_ClearN +> PyRun_SimpleString -> PyRun_SimpleStringN +> PyImport_ImportModule -> PyImport_ImportModuleN +> PyObject_GetAttrString -> PyObject_GetAttrStringN +> ``` +> +> The block in `EmbedAPI.android.kt` that appears to call the bare `@CriticalNative` names is +> **inside a `/** */` comment** -- dead code kept from before the migration. Reading it as live is +> what produced the finding. +> +> **Section 2 is a different matter and may be real.** Its promotion targets (`Py_IncRefN`, +> `PyEval_SaveThreadN`, and the rest) are registered *only* under `N`; there is no +> `@CriticalNative` registration to switch to. Acting on it means adding registrations as well as +> moving call sites, which is genuine work rather than a relabel. +> +> Two things to check before acting on it. The cost claim -- "~45ns on API 26-31, only ~2-4ns on +> API 34+" -- points the opposite way from what this repo measured: `@CriticalNative`'s net cost +> is near zero up to API 33 and rises sharply after (24.35ns on 34, 44.05ns on 36 hardware). If +> those measurements hold, promotion helps old devices and *hurts* new ones, which inverts the +> recommendation. And "Unresolved entries: none" overstates the confidence available from reading +> names and documentation alone; the classification below is a starting point for a human, not a +> settled answer. +> +> **What survives is the classification itself**, which is the useful part: it is the rule for +> choosing a convention when a *new* call site is written, and that is where the crash ROADMAP §3 +> warns about would actually be introduced. +> +> Section 2 was checked against those measurements and **rejected**; see *Resolution* at the end of +> this document for the judgement, the three classifications in it that are wrong, and the one +> change that was made instead. + + +## 1. Mismatches + +These functions are currently registered as `@CriticalNative` (no suffix) or `@FastNative` (F suffix), but they can execute arbitrary Python code. These are latent crashes and must be demoted to ordinary JNI. + +- **`Py_Initialize`** / **`Py_InitializeF`** + - **Re-entry path**: Imports the `site` module during startup by default, which executes module-level Python code. +- **`Py_Finalize`** / **`Py_FinalizeF`** + - **Re-entry path**: Invokes `atexit` callbacks and executes `__del__` methods of surviving objects during teardown. +- **`PyErr_Clear`** / **`PyErr_ClearF`** + - **Re-entry path**: Decrements the refcount of the current exception object, type, and traceback. If the refcount drops to zero, `__del__` is called. +- **`PyRun_SimpleString`** / **`PyRun_SimpleStringF`** + - **Re-entry path**: Parses, compiles, and executes the provided string in the `__main__` module. +- **`PyImport_ImportModule`** / **`PyImport_ImportModuleF`** + - **Re-entry path**: Executes the top-level Python code of the module being imported. +- **`PyObject_GetAttrString`** / **`PyObject_GetAttrStringF`** + - **Re-entry path**: Attribute lookup dispatches to `__getattribute__`, `__getattr__`, or descriptor `__get__` methods written in user code. + +## 2. Safe Promotions + +These functions are registered as ordinary JNI (with the `N` suffix or implicitly ordinary) but are provably leaves or non-blocking, and therefore safe to promote. Given that `@CriticalNative` is practically free on API 26-33 but costs ~24ns net on API 34+ (while `@FastNative` is ~2ns on 34+), the value of promoting these depends on the target device. + +- **`Py_IncRefN`, `Py_NewRefN`, `Py_XNewRefN`** + - **Argument for promotion**: These simply increment the `ob_refcnt` struct field. They do not trigger deallocations, do not allocate memory (no GC), and do not call Python code. Safe for `@CriticalNative`. +- **`PyGILState_ReleaseN`, `PyEval_SaveThreadN`** + - **Argument for promotion**: Unlocking the GIL mutex does not block and does not invoke Python code. Safe for `@CriticalNative`. +- **`PyGILState_GetThisThreadStateN`, `PyThreadState_GetDictN`** + - **Argument for promotion**: These only read from Thread Local Storage or read a pointer from a struct. No Python execution. Safe for `@CriticalNative`. +- **`PyEval_InitThreadsN`** + - **Argument for promotion**: Initializes locking (or is a no-op). Does not run Python code. Safe for `@CriticalNative`. +- **`asmListToArray`** + - **Argument for promotion**: This composed function only calls `PyList_Size` and `PyList_GetItem`, both of which are O(1) leaf array reads that do not mutate refcounts. Because it accesses a Java `jlongArray`, it still requires `JNIEnv`, so it cannot be `@CriticalNative`. It is safe to promote to `@FastNative`. + +## 3. Full Classification Table + +| Function Name | Current Convention | Should Be | Can Reach Python? (Mechanism) | +|---|---|---|---| +| `Py_Initialize` | Critical | Ordinary | Yes. Imports `site` module during startup. | +| `Py_IsInitialized` | Critical | Critical | No. Reads a global boolean. | +| `Py_Finalize` | Critical | Ordinary | Yes. Runs `atexit` callbacks and `__del__` on remaining objects. | +| `PyErr_Clear` | Critical | Ordinary | Yes. Decrements exception refcount; dropping to 0 runs `__del__`. | +| `PyLong_FromLongLong` | Critical | Critical | No. Primitive `malloc`, does not trigger cyclic GC. | +| `PyList_Size` | Critical | Critical | No. Reads `ob_size` struct field. | +| `PyRun_SimpleString` | Critical | Ordinary | Yes. Executes arbitrary Python string. | +| `Py_GetVersion` | Critical | Critical | No. Returns static string pointer. | +| `PyImport_ImportModule` | Critical | Ordinary | Yes. Executes top-level module code. | +| `PyObject_GetAttrString` | Critical | Ordinary | Yes. Invokes `__getattr__` or `__get__`. | +| `PyErr_Occurred` | Critical | Critical | No. Reads thread state. | +| `echo0` | Critical | Critical | No. Probe, returns argument. | +| `echo1` | Critical | Critical | No. Probe, returns argument. | +| `echo2` | Critical | Critical | No. Probe, returns argument. | +| `echoNormal` | Ordinary | Ordinary | No. Probe, requires JNIEnv. | +| `echoFast` | FastNative | FastNative | No. Probe, requires JNIEnv. | +| `PyList_SizeFast` | FastNative | FastNative | No. Leaf read. | +| `PyList_SizeNormal` | Ordinary | Ordinary | No. Leaf read. | +| `Py_InitializeF` | FastNative | Ordinary | Yes. Imports `site` module during startup. | +| `Py_IsInitializedF` | FastNative | FastNative | No. Reads a global boolean. | +| `Py_FinalizeF` | FastNative | Ordinary | Yes. Runs `atexit` callbacks and `__del__`. | +| `PyErr_ClearF` | FastNative | Ordinary | Yes. Decrements exception refcount; dropping to 0 runs `__del__`. | +| `PyLong_FromLongLongF` | FastNative | FastNative | No. Primitive `malloc`, does not trigger cyclic GC. | +| `PyList_SizeF` | FastNative | FastNative | No. Reads `ob_size` struct field. | +| `PyRun_SimpleStringF` | FastNative | Ordinary | Yes. Executes arbitrary Python string. | +| `Py_GetVersionF` | FastNative | FastNative | No. Returns static string pointer. | +| `PyImport_ImportModuleF` | FastNative | Ordinary | Yes. Executes top-level module code. | +| `PyObject_GetAttrStringF` | FastNative | Ordinary | Yes. Invokes `__getattr__` or `__get__`. | +| `PyErr_OccurredF` | FastNative | FastNative | No. Reads thread state. | +| `PyList_GetItemRaw` | Critical | Critical | No. Borrows reference from array index (leaf). | +| `ffiDirectBufferAddress` | Ordinary | Ordinary | No. Uses JNIEnv. | +| `asmExec` | Ordinary | Ordinary | Yes. Composes `PyRun_String`. | +| `asmGetAttr` | Ordinary | Ordinary | Yes. Composes `PyObject_GetAttrString` and `PyErr_Clear`. | +| `asmListToArray` | Ordinary | FastNative | No. Only calls leaf functions (`PyList_Size`, `PyList_GetItem`). | +| `Py_InitializeN` | Ordinary | Ordinary | Yes. Imports `site` module during startup. | +| `Py_FinalizeN` | Ordinary | Ordinary | Yes. Runs `atexit` callbacks and `__del__`. | +| `PyErr_ClearN` | Ordinary | Ordinary | Yes. Decrements exception refcount; dropping to 0 runs `__del__`. | +| `PyRun_SimpleStringN` | Ordinary | Ordinary | Yes. Executes arbitrary Python string. | +| `PyImport_ImportModuleN` | Ordinary | Ordinary | Yes. Executes top-level module code. | +| `PyObject_GetAttrStringN` | Ordinary | Ordinary | Yes. Invokes `__getattr__` or `__get__`. | +| `Py_DecRefN` | Ordinary | Ordinary | Yes. Reaching refcount 0 deallocates and runs `__del__`. | +| `Py_IncRefN` | Ordinary | Critical | No. Only increments struct field `ob_refcnt`. | +| `Py_NewRefN` | Ordinary | Critical | No. Only increments struct field `ob_refcnt`. | +| `Py_XNewRefN` | Ordinary | Critical | No. Only increments struct field `ob_refcnt`. | +| `PyGILState_EnsureN` | Ordinary | Ordinary | No, but blocks. Blocking while suspending JVM GC risks deadlock. | +| `PyGILState_ReleaseN` | Ordinary | Critical | No. Unlocks mutex (non-blocking). | +| `PyGILState_GetThisThreadStateN` | Ordinary | Critical | No. Reads TLS. | +| `PyEval_SaveThreadN` | Ordinary | Critical | No. Unlocks GIL mutex (non-blocking). | +| `PyEval_RestoreThreadN` | Ordinary | Ordinary | No, but blocks waiting for GIL. Risks deadlock if GC is suspended. | +| `PyEval_InitThreadsN` | Ordinary | Critical | No. Initializes locking. | +| `PyThreadState_GetDictN` | Ordinary | Critical | No. Reads struct pointer. | +| `PyTuple_NewN` | Ordinary | Ordinary | Yes (inferred). GC tracked container allocation can trigger cyclic GC, which can run `__del__`. | +| `PyTuple_SetItemN` | Ordinary | Ordinary | Yes. Overwriting an existing item drops its refcount, potentially running `__del__`. | +| `PyDict_NewN` | Ordinary | Ordinary | Yes (inferred). GC tracked container allocation can trigger cyclic GC. | +| `PyDict_SetItemStringN` | Ordinary | Ordinary | Yes. Key hashing can invoke `__hash__` / `__eq__`, replacing values decrefs. | +| `PyDict_SetItemN` | Ordinary | Ordinary | Yes. Key hashing can invoke `__hash__` / `__eq__`, replacing values decrefs. | +| `PyDict_GetItemStringN` | Ordinary | Ordinary | Yes. Dictionary lookup can invoke `__eq__` on keys. | +| `PyUnicode_AsUTF8` | Critical | Critical | No. Primitive string allocation/cache. | +| `PyUnicode_AsUTF8F` | FastNative | FastNative | No. Primitive string allocation/cache. | +| `PyUnicode_FromString` | Critical | Critical | No. Primitive string allocation/cache. | +| `PyUnicode_FromStringF` | FastNative | FastNative | No. Primitive string allocation/cache. | +| `PyImport_AddModuleRefN` | Ordinary | Ordinary | Yes. Dict lookup/insertion can invoke `__eq__` or GC. | +| `PyRun_StringN` | Ordinary | Ordinary | Yes. Executes arbitrary Python code. | +| `PyObject_StrN` | Ordinary | Ordinary | Yes. Invokes `__str__`. | +| `PyObject_IsTrueN` | Ordinary | Ordinary | Yes. Invokes `__bool__` or `__len__`. | +| `PyObject_CallNoArgsN` | Ordinary | Ordinary | Yes. Invokes `__call__`. | +| `PyObject_CallObjectN` | Ordinary | Ordinary | Yes. Invokes `__call__`. | +| `testUpcallPrimitive` | Ordinary | Ordinary | Yes (JNI upcall). Explicitly calls into Java runtime. | +| `testUpcallString` | Ordinary | Ordinary | Yes (JNI upcall). Explicitly calls into Java runtime. | +| `testUpcallUnattached` | Ordinary | Ordinary | Yes (JNI upcall in new thread). | +| `testThreadCreateFloor` | Ordinary | Ordinary | No (thread floor probe), but uses ordinary. | +| `testAttachWithoutDetach` | Ordinary | Ordinary | Yes (attaches a pthread and calls back into Java). | + +## 4. Rules of Thumb + +Based on CPython's documented semantics, the following rules apply when classifying new functions: + +1. **Any decref is re-entrant**: If a function can drop a reference count (e.g. `Py_DecRef`, `PyTuple_SetItem` dropping the overwritten item, `PyDict_SetItem`, `PyErr_Clear`), that reference can hit zero, deallocate the object, and execute arbitrary Python `__del__` methods. +2. **GC allocations are re-entrant**: Allocating GC-tracked containers (tuples, lists, dicts, instances) using functions like `PyTuple_New` or `PyDict_New` can cross the allocation threshold and trigger the cyclic garbage collector. The cyclic GC reclaims unreachable cycles, executing `__del__` on them. +3. **Blocking requires ordinary JNI**: Functions that acquire the GIL (`PyGILState_Ensure`, `PyEval_RestoreThread`) do not run Python, but they *must* stay on ordinary JNI. A thread waiting for a lock while marked `@CriticalNative` prevents the JVM from reaching a safepoint, which deadlocks the entire process if the thread holding the GIL triggers a JVM garbage collection. +4. **Dictionary lookups are re-entrant**: Even reading from a dictionary (`PyDict_GetItemString`) can invoke user code if custom keys exist that define `__eq__` or `__hash__`. +5. **Non-GC allocations are leaves**: Allocating primitives (e.g. `PyLong_FromLongLong`, `PyUnicode_FromString`) uses standard memory allocation (`malloc`) and does not participate in cyclic GC, making them pure leaves. +6. **Increments are leaves**: Functions that strictly increase reference counts (`Py_IncRef`, `Py_NewRef`) do not deallocate and thus do not trigger callbacks. + +## Report Summary + +- **Total Functions Classified**: 71 registrations. +- **Mismatches Found**: 12 (6 unique C functions exposed as both Critical and Fast). These are the dangerous ones: `Py_Initialize`, `Py_Finalize`, `PyErr_Clear`, `PyRun_SimpleString`, `PyImport_ImportModule`, and `PyObject_GetAttrString`. They can run arbitrary Python and must be moved to ordinary JNI. +- **Safe Promotions**: 8 functions (`Py_IncRefN`, `Py_NewRefN`, `Py_XNewRefN`, `PyGILState_ReleaseN`, `PyEval_SaveThreadN`, `PyGILState_GetThisThreadStateN`, `PyThreadState_GetDictN`, `PyEval_InitThreadsN`) can safely be moved from Ordinary to Critical, and `asmListToArray` to FastNative. +- **Cost of Being Wrong**: + - Promoting a re-entrant function to `@CriticalNative` is a fatal crash when upcalls exist. + - Leaving a leaf as Ordinary wastes ~45ns per call on API 26-31, but only ~2-4ns on modern API 34+. Safe promotions are therefore an optimization mostly relevant for older devices or high-volume loops, and should never override correctness. +- **Unresolved Entries**: None. All 71 registrations were resolved confidently based on documented CPython semantics (e.g. GC thresholds on container allocation). + + **This line is wrong, and it is the reason the audit is no longer the source of truth.** Header + prototypes can condemn a function and never clear one: `void Py_Initialize(void)` and + `int PyRun_SimpleString(const char*)` name no `PyObject *` and both run arbitrary Python. A + re-derivation over the table as it stands leaves **65 undecided**, and the confidence recorded + here is what put three re-entrant functions on the safe-to-promote list above. + `JniCallConventionClassificationTest` now derives the classification on every desktop build and + asserts that bucket stays non-empty, with those two functions named in it. + +--- + +# Resolution + +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 +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, +only a way for it to stay correct. + +## The promotion to `@CriticalNative` is rejected + +Section 2 argues its eight promotions are worth taking because `@CriticalNative` costs "~45ns on +API 26-31, only ~2-4ns on API 34+". That is backwards. Measured here, net of the Kotlin floor +(`docs/design/downcall-design.md`, `androidMain/README.md`), the *change* a promotion buys is: + +| API | ordinary → `@FastNative` | ordinary → `@CriticalNative` | +|---|---|---| +| 26 | −6.64 | −43.47 | +| 30 | −41.33 | −73.33 | +| 31 | −21.68 | −45.99 | +| 33 | −5.79 | −7.86 | +| 34 | −5.84 | **+16.54** | +| 36 | −14.77 | **+25.73** | + +`@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. +That is not being done, for three reasons. + +**It is not a relabel.** `@CriticalNative` receives no `JNIEnv` or `jclass`, so it cannot reuse the +`f_*N` wrappers, which all have them. Each promotion needs a new table entry pointing at the raw +CPython symbol, a new `@CriticalNative` declaration, and a branch at the call site. Compare with a +`@FastNative` promotion, which is one annotation: the C signature is already right and the +registration name does not change. + +**Three of the eight cannot be promoted at all.** They are not leaves: + +| | 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 | +| `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 +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 +from reading names and documentation, which is exactly how the three above were misread. + +## What was changed instead: `PyList_GetItem` + +The static sweep found one place where the current configuration contradicts the measurements, and +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. +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. + +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 +same shape as `PyList_Size` directly above it. Predicted effect on API 36: 44.05 → 3.55 ns per +element. Nothing else was promoted or demoted. + +This is compile-verified only. `JniWiringTest.bothListGetItemConventionsAgree` asserts the two +registrations return identical pointers at every index of `sys.path`, which catches both a missing +table entry and a wrapper written without the leading `JNIEnv*, jclass`; it has not been run, +because the device was in use. + +It also narrows §5. If a large share of the 11x bulk-iteration win came from the per-element call +running under the slowest available convention on that device, then part of the remaining case for +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 +removed. + +**Every one of the 116 is registered `N`-suffixed and declared plain `@JvmStatic external fun`.** +Not one has a `@CriticalNative` or `@FastNative` twin registered, so no call site can be reaching a +GC-blocking variant of them: there is none to reach. The failure mode ROADMAP §3 warns about cannot +be present in the new surface. Verified by extracting the table names from all three revisions and +parsing the annotations in `bindings.kt`. + +Classifying the 42 anyway, since the point of the exercise is the rule for the *next* one. All 42 +are string-carrying; 31 are re-entrant and correctly ordinary: + +| mechanism | functions | +|---|---| +| executes module or arbitrary code | `PyImport_ExecCodeModuleN`, `PyImport_ExecCodeModuleExN`, `PyImport_ExecCodeModuleWithPathnamesN`, `PyImport_ImportModuleLevelN`, `PyImport_ImportModuleNoBlockN` †, `PyImport_ImportFrozenModuleN`, `Py_CompileStringN` | +| runs a Python-level hook | `PyObject_HasAttrStringN`, `PyObject_HasAttrStringWithErrorN` (`__getattr__`), `PyMapping_GetItemStringN`, `PyMapping_SetItemStringN`, `PyMapping_HasKeyStringN`, `PyMapping_HasKeyStringWithErrorN` (`__getitem__`/`__contains__`), `PySequence_FastN` (`__iter__`), `PyUnicode_TranslateN` (`__getitem__` on the table), `PySys_AuditTupleN` (audit hooks), `PyErr_WarnExplicitN` (the `warnings` machinery) | +| Python codec lookup / error handler | `PyUnicode_AsEncodedStringN`, `PyUnicode_FromEncodedObjectN`, `PyUnicode_DecodeLocaleN`, `PyUnicode_EncodeLocaleN`, `PyUnicode_DecodeFSDefaultN` | +| drops a reference, or allocates GC-tracked | `PyDict_DelItemStringN`, `PySys_SetObjectN`, `PyErr_NewExceptionN`, `PyErr_NewExceptionWithDocN`, `PyErr_SetFromErrnoWithFilenameN`, `PyErr_SyntaxLocationN`, `PyErr_SyntaxLocationExN`, `PyUnicodeTranslateError_SetReasonN` | +| never returns | `Py_FatalErrorN` | + +† `PyImport_ImportModuleNoBlockN` has since been unregistered: CPython 3.15 removed +`PyImport_ImportModuleNoBlock`, which had been an exact alias of `PyImport_ImportModule` since 3.3 +(ROADMAP §9). The counts above are left as they were read, since this section records what the +table held at `9abc8edd`/`aea09585` rather than what it holds now. The same migration also +unregistered `PySys_ResetWarnOptionsN` and renamed `PyWeakref_GetObjectN` to `PyWeakref_GetRefN`; +none of the three changed anyone's convention, and the replacement is registered ordinary like the +function it replaces. + +The remaining 11 are leaves on the success path: `PyBytes_FromStringN`, `PyBytes_AsStringN`, +`PyByteArray_AsStringN`, `PyUnicode_EqualToUTF8N`, `PyUnicode_CompareWithASCIIStringN`, +`PyUnicode_InternFromStringN`, `PySys_GetObjectN`, `PyImport_GetMagicTagN`, `PyEval_GetFuncNameN`, +`PyEval_GetFuncDescN`, `Py_EnterRecursiveCallN`. None of them is promoted, and none should be: +every one is cold error-reporting or introspection surface, where the whole available win is under +40 ns on a call that happens once. Step 4 of the decision procedure in `androidMain/README.md` +covers exactly this case. + +## The eight live GC-blocking call sites + +Outside the `/** */` block of dead pre-migration code, `EmbedAPI.android.kt` reaches a +`@CriticalNative` or `@FastNative` binding in eight places. All eight are leaves on the success +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 | — | +| `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 +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 +a leaf" is reachable: the honest promotion test is *leaf on the success path, and measured worth +it*, not *cannot run Python*. diff --git a/docs/archive/kotlin-extensions-in-python-pyi.md b/docs/archive/kotlin-extensions-in-python-pyi.md new file mode 100644 index 000000000..dbf408b54 --- /dev/null +++ b/docs/archive/kotlin-extensions-in-python-pyi.md @@ -0,0 +1,55 @@ +# 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 +> `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` +> names; `Dp | float` versus the proxy alone; defaults as `= ...`) remain valid inputs for that +> product. Section references below are to `docs/design/kotlin-extensions-in-python.md` unless a +> path is given; the metaclass and Protocol measurements are in +> [`pyi-generation-pythonic-stubs.md`](pyi-generation-pythonic-stubs.md) §4.3–§4.4. + +### 4.5 `.pyi` + +Generation belongs to the Gradle plugin (`docs/design/ecosystem.md` §5b), and it reads the same metadata +as the binder, so the stub and the binding cannot drift. Extension-as-method appears as an ordinary +method, and the metaclass of §4.2 is expressible: + +> **The stub below is the one that was measured to fail.** mypy resolves `Modifier.padding` to the +> 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 +> `__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. + +```python +class _ModifierMeta(type): + @overload + def padding(cls, all: Dp | float) -> Modifier: ... + @overload + def padding(cls, horizontal: Dp | float = ..., vertical: Dp | float = ...) -> Modifier: ... + @overload + def padding(cls, padding_values: PaddingValues) -> Modifier: ... + +class Modifier(metaclass=_ModifierMeta): + @overload + def padding(self, all: Dp | float) -> Modifier: ... + ... +``` + +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 + 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 + the source; the *value* is not in metadata, so stubs emit `= ...`. + diff --git a/docs/archive/pyi-generation-pythonic-stubs.md b/docs/archive/pyi-generation-pythonic-stubs.md new file mode 100644 index 000000000..96fb668a2 --- /dev/null +++ b/docs/archive/pyi-generation-pythonic-stubs.md @@ -0,0 +1,461 @@ +# 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 +> for history. +> +> These are the parts of `pyi-generation-design.md` (as of 2026-10-02) that designed a second, +> **Pythonic** stub product inside this repository's Gradle plugin: stubs under `pythonx.*`, +> snake_case names, `Dp | float` unions, keyword-only `content`, `@overload` sets, `Modifier` as a +> class of callback-Protocol attributes, and a `pythonx-map.toml` manifest mapping `androidx.*` to +> `pythonx.*`. It was implemented (`PythonNames.kt`, `PythonTypes.kt`, `StubManifest.kt`, +> `renderPythonicStubs`) and removed in `238119b7`, because the binder must never export a Kotlin +> namespace under another name (`docs/INTENT.md` §2.2, `AGENTS.md` §12.1). The plugin now emits only +> Kotlin-named stubs. The measurements below (mypy 2.3.0 runs, the metaclass failure, overload +> ordering, `py.typed` placement) are still valid inputs for whoever builds the Pythonic stubs in +> pythonx-compose. +> +> Section numbers are the original ones, so references such as "§4.4" in the code's history still +> resolve here. Paths in backticks are relative to the repository root. Cross-references to sections +> that were not moved (§0–§2, §3.2, §3.3, §4.1, §4.5, §6.2, §6.3) point at +> `docs/design/pyi-generation-design.md`. + +--- + +## Original introduction + +`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 +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. + +What this file does is answer the three questions that were open under that decision: **what the +generator reads**, **what it can honestly write**, and **where the result goes**. + +Everything under "Observed" was read from working copies or run as a command on 2026-08-14, with the +path or the command given. Everything under "Judged" is a decision, and the alternatives it was +chosen over are named. Where a claim could not be checked it says so rather than being smoothed over. + + +--- + +## 3. Kotlin → Python type mapping + +### 3.1 The table + +`source` names where the fact comes from: `Km` = `kotlin-metadata-jvm`, `KS` = KSP, `desc` = JVM +descriptor, `list` = the hand-maintained allowlist of §3.4. + +| Kotlin | `.pyi` | source | note | +|---|---|---|---| +| `Boolean` | `bool` | Km/KS | | +| `Byte` `Short` `Int` `Long` | `int` | Km/KS | the boundary carries every one as `Long`; Python has one integer type, so the narrowing is invisible and correct | +| `Float` `Double` | `float` | Km/KS | | +| `String` | `str` | Km/KS | | +| `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 | +| `List<T>` `MutableList<T>` | `list[T]` | Km/KS type args | not bindable today (the type gate) | +| `Set<T>` | `set[T]` | | | +| `Map<K, V>` | `dict[K, V]` | | | +| `Collection<T>` `Iterable<T>` | `Iterable[T]` | | read-only shape; `Sequence` would over-promise indexing | +| `Array<T>` | `list[T]` | | judged: the marshaller's Python view is a list | +| `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 | +| 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 <T> 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. + +### 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" +selects 36 of 111 and **admits `Color`**, whose public `ULong` constructor stores while the factory +a Kotlin author actually writes (`Color(Int)`) shifts by 32. Whether a wrapper packs is a semantic +fact with no bytecode witness. + +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 | +| `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 | + +**Measured, §4.4's asymmetry is expressible and enforced.** In the mypy run of §4.4, +`Text("hi", font_size=16)` produced + + 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 +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 +call (`"expected TextUnit; write sp(16)"`). mypy's own message names the type but not the remedy. +Judged: accept it. The runtime raises the full message; the stub gets the user to the runtime less +often. + +**Where the allowlist lives** is `docs/design/kotlin-extensions-in-python.md` §6's open question, and §5.3 +here proposes an answer that falls out of the package mapping: the same manifest. + +### 3.5 Function types, and Compose's `content` + +`() -> Unit` → `Callable[[], None]`, mechanically, from `kotlin/Function0`'s type arguments. 66 of +the 177 chainable `Modifier` extensions take at least one lambda, and every `@Composable` container +takes `content`. + +Two things the stub should do and one it should not: + +- **`content` is keyword-only in Python** even though it is the last positional parameter in Kotlin. + Compose's trailing-lambda syntax has no Python equivalent, and `Column(modifier, lambda: ...)` + reads badly. Emitting `*, content: Callable[[], None]` forces `content=`. Checked in §4.4's run: + `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 + 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 + are not in `KmFunction`. `docs/design/kotlin-extensions-in-python.md` §4.6: metadata arity is the correct + source, and any code deriving arity from the descriptor is wrong for all 500 composables. + +### 3.6 Overloads + +Python has `@overload`, and `docs/design/kotlin-extensions-in-python.md` §4.5 already calls it mandatory: +33 of `Modifier`'s 130 names carry more than one, and they are the load-bearing ones +(`padding`, `size`, `background`, `border`, `clickable`, …). + +**Measured** (`/Volumes/macMini/tmp/pyicheck/ovstub.pyi`, all four real `Modifier.padding` +overloads from §3 of that document, as a callback protocol): + +```python +class _padding(Protocol): + @overload + def __call__(self, paddingValues: PaddingValues) -> Modifier: ... + @overload + def __call__(self, all: Dp | float) -> Modifier: ... + @overload + def __call__(self, horizontal: Dp | float = ..., vertical: Dp | float = ...) -> Modifier: ... + @overload + def __call__(self, start: Dp | float = ..., top: Dp | float = ..., + end: Dp | float = ..., bottom: Dp | float = ...) -> Modifier: ... +``` + +All four coexist. `p(8)`, `p(horizontal=8, vertical=4)`, `p(start=1, bottom=2)` and +`p(PaddingValues())` each resolve to `Modifier`; `p("bad")` is rejected with all four variants +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 +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: + +- 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 + about which Kotlin function runs. The two orders must come from one place in the generator. +- **Defaults are emitted as `= ...`.** 254 of 435 `Modifier` extension parameters declare one and + metadata carries `declaresDefaultValue` but not the *value*. `= ...` is the correct stub spelling + for "has a default I cannot name" and is what makes the keyword-only overloads above work. + +Kotlin parameter names go through the same snake_case conversion as everything else +(`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 + +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. + +(§4.1, Placement, stayed in `docs/design/pyi-generation-design.md`.) + +### 4.2 The proposal under test + +`docs/design/kotlin-extensions-in-python.md` §4.2 and §4.5 propose that `Modifier` be one class whose class +object also behaves like an instance, via a metaclass, so that `Modifier.padding(16)`, +`m.padding(16)` and `def f(m: Modifier)` all work: + +```python +class _ModifierMeta(type): + @overload + def padding(cls, all: Dp | float) -> Modifier: ... + ... + +class Modifier(metaclass=_ModifierMeta): + @overload + def padding(self, all: Dp | float) -> Modifier: ... + ... +``` + +### 4.3 Observed: it does not work, at run time or in a checker + +**Run time** (CPython 3.13, `/Volumes/macMini/tmp/pyicheck`): + +```python +class Meta(type): + def padding(cls, v): return ("META", v) +class Modifier(metaclass=Meta): + def padding(self, v): return ("INSTANCE", self, v) + +Modifier.padding # -> <function Modifier.padding at 0x100f6b4c0> +Modifier.padding(16) # -> TypeError: Modifier.padding() missing 1 required positional argument: 'v' +``` + +This is `type.__getattribute__`'s documented order: a metaclass attribute wins **only if it is a data +descriptor**; otherwise the class's own MRO is searched first, and a plain function on the metaclass +is a non-data descriptor. So `Modifier.padding` is the *unbound instance method*, and `16` is being +bound to `self`. It raises here only because the arities differ by one; it would bind silently for +any pair that happened to match. + +**mypy 2.3.0**, on exactly the stub of §4.2: + + reveal_type(Modifier.padding) + note: Revealed type is "Overload(def (self: Modifier, all: Dp | float) -> Modifier, ...)" + + Modifier.padding(16) + error: No overload variant of "padding" of "Modifier" matches argument type "int" + note: Revealed type is "Any" + +The checker reproduces the runtime rule: the metaclass declaration is invisible, `Modifier.padding` +is the instance method, and every class-object spelling fails. Ten errors in a twenty-line file, and +the only lines that passed were the instance ones. + +**The fix that works at run time does not fix the checker.** A `property` on the metaclass *is* a +data descriptor and does win: + +```python +class Meta2(type): + @property + def padding(cls): return cls.EMPTY.padding +Modifier2.padding(16) # -> ('INSTANCE', <Modifier2 object>, 16) correct +``` + +as does a hybrid descriptor in the class body with no metaclass at all: + +```python +class hybrid: + def __init__(self, fn): self.fn = fn + def __get__(self, obj, owner): return self.fn.__get__(obj if obj is not None else owner.EMPTY, owner) +Modifier3.padding(16) # -> ('BOUND-TO', <Modifier3 object>, 16) correct +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 +model metaclass data-descriptor precedence.** + +### 4.4 Judged: callback-Protocol attributes, and the stub need not mirror the runtime mechanism + +The shape that satisfies all three spellings is to declare each operation on the class as an +**attribute whose type is a Protocol with an overloaded `__call__`**: + +```python +class _Modifier_padding(Protocol): + @overload + def __call__(self, all: Dp | float) -> Modifier: ... + @overload + def __call__(self, *, horizontal: Dp | float = ..., vertical: Dp | float = ...) -> Modifier: ... + +class Modifier: + padding: ClassVar[_Modifier_padding] + size: ClassVar[_Modifier_size] + background: ClassVar[_Modifier_background] +``` + +**Measured** — the same twenty-line user file that produced ten errors against §4.2 produces exactly +the two intended ones: + +| expression | revealed / result | +|---|---| +| `Modifier.padding` | `_Modifier_padding` | +| `Modifier.padding(16)` | `Modifier` | +| `Modifier.padding(dp(16))` | `Modifier` | +| `Modifier.padding(horizontal=8, vertical=4)` | `Modifier` | +| `Modifier.padding(16).background(Color(0xFFFF0000))` | `Modifier` | +| `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) | +| `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`. + +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 +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 +nothing observable depends on that. + +**Rejected:** + +| 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 | +| 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 | + +**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 +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 +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. + +(§4.5, Member extensions, stayed in `docs/design/pyi-generation-design.md`.) + +--- + +## 5. Package mapping + +### 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 +can contribute subpackages under it. `pythonx/compose/` has `__init__.py`, `layout/`, `lite/`, +`material3/`, `native/`, `runtime/`, `test/`, `ui/`, `ui/unit/`, `wrapper/`. + +### 5.2 Observed: it is not a rule + +`unzip -l` over the Compose Multiplatform desktop jars vendored at +`pythonx/compose/lite/release/app/` shows that the `org.jetbrains.compose.*` artefacts contain +`androidx.compose.*` **packages**: + + foundation-layout-desktop-1.6.11-*.jar -> androidx/compose/foundation/layout + ui-unit-desktop-1.6.11-*.jar -> androidx/compose/ui/unit + material-desktop-1.6.11-*.jar -> androidx/compose/material + +so the Kotlin namespace is `androidx.compose.*` on desktop and Android alike, and one mapping covers +both. Against the actual directories: + +| `pythonx` module | Kotlin package | mechanical? | +|---|---|---| +| `pythonx.compose.material3` | `androidx.compose.material3` | yes | +| `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.wrapper`, `.native`, `.lite`, `.test` | none | no counterpart | + +The `layout` entry is not inferred. `pythonx/compose/layout/arrangement.py` line 7 reads +`jclass("androidx.compose.foundation.layout.Arrangement")`, and `pythonx/compose/layout/__init__.py` +re-exports `Arrangement` from it. + +### 5.3 Judged: a rule plus a manifest the Python package owns + +**`androidx.` → `pythonx.` is the default rule; deviations come from a manifest, and the manifest +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.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 + + [modules] + "pythonx.compose.layout" = "androidx.compose.foundation.layout" + "pythonx.compose.ui.unit" = "androidx.compose.ui.unit" + + [value-classes] + raw-primitive-allowed = ["androidx.compose.ui.unit.Dp"] + +The plugin reads it from the resolved Python package and emits `pythonx.*` stubs accordingly; with +no manifest it emits only the Kotlin-FQN stubs. This also gives +`docs/design/kotlin-extensions-in-python.md` §6's open question ("whether the allowlist should be data or +code, and where a downstream consumer adds to it") a location: the same manifest, in the same +package, next to the code whose surface it describes. + +**Consequence: there are two stub products, from one model.** + +| product | namespace | content | mechanical? | +|---|---|---|---| +| Kotlin-FQN stubs | `androidx.compose.material3`, `junit.runner`, … | 1:1 with the modules `PythonProxySource` injects into `sys.modules`; Kotlin names, no snake_case, no extension-as-method | yes | +| Pythonic stubs | `pythonx.compose.material3`, … | snake_case, extension-as-method (§4.4), value-class allowlist (§3.4), keyword-only `content` (§3.5) | needs the manifest | + +The first is the direct descendant of PyREPL's output and is the only thing an IDE could ever see for +those modules, since they exist only as runtime `sys.modules` entries and never as files. The second +is what a user actually imports. + +**Not verified:** no such manifest exists in `pythonx-compose` today, and this proposal has not been +agreed with that repository. It is a judgement about where the information has to live, not a +description of anything. + +--- + +## 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 — + 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 + `site-packages` and `pythonx/compose/py.typed` present, everything resolved. Deleting **only** + `py.typed`: + + error: Skipping analyzing "pythonx.compose.ui": module is installed, but missing library + 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/`, + 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 + 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. + +Two audiences, two destinations, one generator. + +--- + +## 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. +- **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. +- **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 + `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 new file mode 100644 index 000000000..2a41f03f3 --- /dev/null +++ b/docs/archive/pythonx-adapter-design.md @@ -0,0 +1,1073 @@ +# `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 +> (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 +> `python_multiplatform.binding` +> ([`PythonxAdapter.kt`](../../python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt)), +> and `pythonx` is a real package in the pythonx-compose repository (`AGENTS.md` §12.1–12.2). +> 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 +> `__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 +> 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 +> render proofs (§9, §10) are the `ksp-fixtures/compose` render tests cited by SPEC B-5/B-6. The +> 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 +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. + +`docs/design/ecosystem.md` §5b already decided the shape: `pythonx.*` is ours, `androidx.*` is the original +Kotlin, the wrapping happens in Python, and the layer *adapts generically* rather than enumerating. +This file works out what "generically" has to mean given what the runtime actually offers today, and +names the places where it does not offer enough. + +Everything under **Read** was read from the working copies on 2026-08-14 and the file is named. +Everything under **Measured** was produced by running a tool and the command is given. Everything +under **Judged** is a decision with its alternatives named. Everything under **Open** is not decided, +and no sentence in this file fills one of those in. + +Sources read for this document: + + PythonMultiplatform python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt + python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/UpcallTrampoline.kt + python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/{ExposedCallable,ReflectedClass,ObjectReference}.kt + python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/{ArtifactScanner,ArtifactRendering,JvmDescriptors,KotlinMetadata}.kt + docs/design/ecosystem.md §5b, docs/design/kotlin-extensions-in-python.md + pythonx-compose UI.ipynb (all 57 cells), pythonx/compose/runtime/__init__.py, + pythonx/compose/material3/{buttons,cards,text}.py, pythonx/compose/ui/{__init__,modifier,alignment}.py, + pythonx/compose/layout/{__init__,arrangement}.py, pythonx/compose/wrapper/__init__.py + +--- + +## 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. + +**The library source is strict snake_case with a hand-written map back to camelCase.** +`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 +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 +PyREPL's hand-written Kotlin shim, and the parameter names confirm it independently: the notebook +writes `onclick`, the library writes `on_click`. **The library wins**; see §3's naming decision. + +The call these wrappers make cannot reach real AndroidX: + +```python +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 +available here.) + +One more piece is worth keeping. `pythonx/compose/wrapper/__init__.py` is four lines: + +```python +class KotlinWrapper: + def __new__(cls, kotlin_object): + kotlin_object.__class__ = cls + return kotlin_object +``` + +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 +`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.1 Injection into `sys.modules` is why no import hook is needed *after* `install()` + +`_pm_module(name)` builds a `types.ModuleType`, puts it in `sys.modules` under the full dotted Kotlin +name, and recursively creates and links its parents. CPython's import machinery answers a +`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` +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 + +`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 +do not even use the same publication route: `androidx.*` is injected, `pythonx.*` is imported. + +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 + +`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` +prices per-call and per-read, not install. That measurement is missing.) + +Two different things have to become lazy, and they need different hooks: + +| what | hook | why the other one cannot do it | +|---|---|---| +| the module `androidx.compose.material3` existing at all | `sys.meta_path` finder | `import x.y.z` fails before any attribute is touched, so a module `__getattr__` never runs | +| the attribute `Text` inside it | module `__getattr__` (PEP 562) | a finder runs once per module; it cannot defer the 200 names inside one | + +**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 +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 + +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 +Python-facing surface. `_pm_resolve` answers with an integer; there is no way to ask anything *about* +a declaration. + +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 | +| 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` | +| 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 }`) | + +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 + 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 + allowlist mostly does not need to be enforced there. + +### 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 +`getResourceAsStream`, and `sys.path` on iOS, androidNative and wasm points into the per-platform +CPython trees under `src/nativeInterop/cinterop/lib/…`. Getting a generated file into each of those +and onto `sys.path` before the first import is called "a per-platform packaging problem this +repository has not solved for anything." + +`pythonx` is exactly that problem, one size larger: not one generated file but a package of +hand-written ones. `grep` finds no `sys.path` manipulation and no site-packages staging anywhere in +the Kotlin sources; the only hits are benchmarks reading `sys.path` as a test fixture. + +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. +- 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 + hostile to editing, diffing and shipping a package to PyPI, which `pyproject.toml` says + `pycomposeui` is meant to do. + +Until one exists, `pythonx` is a desktop-and-Android library whatever else this document decides. + +--- + +## 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". + +| Kotlin | notebook | class | +|---|---|---| +| `Text`, `Button`, `Card`, `Column`, `Row`, `Spacer`, `TextField`, `Icon` | same | composable → **PascalCase kept** | +| `Modifier`, `Alignment`, `Arrangement` | same | type → **PascalCase kept** | +| `Alignment.Horizontal.End`, `Arrangement.Center` | same | nested type / constant → **PascalCase kept** | +| `rememberSaveable` | `remember_saveable` | free function → **snake_case** | +| `fontSize` | `font_size` | parameter → snake_case | +| `contentDescription` | `content_description` | parameter → snake_case | +| `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 | + +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 +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.** + + type, object, enum entry, composable PascalCase, unchanged + function, method, property, parameter snake_case + package segment unchanged (already lowercase) + +`getValue`/`setValue` are *not* grandfathered: they are ordinary methods and become `get_value`/ +`set_value`. Keeping them would mean the rule is "snake_case except on objects that came back from +Kotlin", which is a rule a reader has to know the provenance of a value to apply. + +The composable exception is not a special case for Compose. A `@Composable` is PascalCase because it +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 +more thing the table would have to carry (§2.4). + +**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 +Kotlin one. Those are opposite directions of one function, and snake→camel is not injective in +general (`URLDecode`, `zIndex`, a Kotlin name that already contains an underscore). + +Two ways to close it, and the second is the recommendation: + +- **Rule-based reverse.** `__getattr__` converts `fill_max_width` → `fillMaxWidth` and calls + `_pm_resolve` once. Cheap, no enumeration, `dir()` returns nothing useful, and irregular names fail. +- **Rule-based forward, at build time, plus a generated name map for the exceptions.** The plugin + already walks every declaration to emit stubs; it can detect the cases where the forward rule is + not invertible and emit only those into a small map the adapter consults on miss. + +Whichever is chosen, there is a checkable invariant that should be a test: **every name the `.pyi` +generator emits must resolve through the adapter's reverse rule.** If it does not, the stub promises +an API the runtime does not have, which is the failure mode that makes stubs worse than none. + +--- + +## 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 +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 + globals()[name] = obj # never consulted again for this name + return obj +``` + +Three things are cached, at three lifetimes: + +| cached | key | lifetime | +|---|---|---| +| the adapted callable | Python name, in the module dict | until interpreter finalisation | +| the handle from `_pm_resolve` | inside the closure | same | +| the receiver-method table for a proxy type | Kotlin receiver type name | built once per type, see §4.2 | + +The `pythonx.compose.material3` module is a **facade over `androidx.compose.material3`**, not a +mirror of it: names it does not adapt fall through to `AttributeError` and the Kotlin FQN import is +always available as an escape hatch. That is the property that keeps `pythonx` from having 37 files. + +### 4.2 Attaching an extension function as a method on its receiver + +`kotlin-extensions` §4.1 adopted this and it is right: each of the 177 public top-level `Modifier` +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 +*its type* that resolves `receiver_type + '.' + kotlin_name`, binds, and installs the method on the +class so the second instance pays nothing. + +The mechanism in Kotlin is missing (§2.4 rows 2 and 3). The adapter is handed +`androidx.compose.foundation.layout.padding` with `arity = 2` and `paramTypes = [OBJECT, FLOAT]`, and +nothing tells it that slot 0 is a receiver of type `androidx.compose.ui.Modifier` rather than an +ordinary first parameter. **Something has to carry `isExtension` and the receiver's Kotlin type name +across the boundary before §4.1 can be built at all.** `ArtifactScanner` has both at the moment it +constructs the call expression and discards them one line later. + +### 4.3 The `Modifier` metaclass + +`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)`. + +Two additions from reading the 2024 code: + +- **It replaces `from pythonx.compose.ui import Modifier, modifier`.** The notebook imports a + lowercase `modifier` instance beside the class precisely because `Modifier()` and `Modifier` were + two different things there. The metaclass removes the second name, and removing it is the point: + `modifier` as a shared module-level mutable-looking singleton is a trap in a UI library. +- **It is generic, not `Modifier`-specific.** The same metaclass shape applies to any Kotlin + 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 + +**Read.** `resolveKotlinType` (`KotlinMetadata.kt`) resolves a value-class parameter to the +`BoundaryType` of its *underlying* type, and `valueClassBoundaryType` renders the read as +`androidx.compose.ui.unit.Dp((args[0] as Double).toFloat())`. So on the parameter side the wrapping is +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 + `Modifier.padding(16)` needs nothing built for it. +- **`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 + 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 +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. + +### 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): + +| function | Kotlin params | required params | +|---|---|---| +| `material3.Text` | 16–18 | 1 | +| `material3.Button` | 10 | 2 (`onClick`, `content`) | +| `material3.Card` (container) | 6 | 1 (`content`) | +| `foundation.layout.Column` | 4 | 1 (`content`) | + +`kotlin-extensions` §2.2 counts the same property across the `Modifier` surface: **254 of 435 +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 +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. + +`ExposedCallable` has one `arity` and one `callable`. What could close the gap, none verified: + +| candidate | why it might not work | +|---|---| +| 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 | +| 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 +real parameter there. Whether that is reachable is §5.4. + +--- + +## 5. `@Composable` + +### 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: + +```python +if self.composer is None: + raise RuntimeError("Composer for Composable must be registered before Composition starts.") +``` + +It then inspects `self.compose.__code__.co_varnames` to find where `content` sits, pops it from args +or kwargs, wraps a raw `ComposableLambdaImpl` in `KotlinComposable` if that is what arrived, and calls +through. `KotlinComposable.compose` is the interesting line: + +```python +ComposableWrapper(self.content, args, self.composer, 1) +``` + +`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 +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 + +`javap -p`, corpus A jars, top-level parameter counts split at the top level (generics ignored): + +| function | Kotlin params | JVM params, counted here | JVM params, `kotlin-extensions` §2.3 | +|---|---|---|---| +| `Text-fLXpl1I` | 16 | **20** | 19 | +| `Text--4IGK_g` (String) | 17 | **21** | 20 | +| `Text--4IGK_g` (AnnotatedString) | 17 | **21** | 20 | +| `Text-IbK3jfQ` | 18 | **22** | 21 | +| `Button` | 10 | **13** | 12 | +| `Card` (clickable) | 9 | **12** | 11 | +| `Card` (container) | 6 | **9** | 8 | +| `Column` | 4 | **7** | 6 | +| `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 +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. + +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` + +§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."* + +§5b says: *"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 … So nothing on +the Kotlin side needs a `@Composable` callable type."* + +**§5b's resolution does not hold given how bindings are produced.** The workers generate *Kotlin +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 +- 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. + +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 +ambiguous within their own class. + +### 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 +non-composable method. That is precisely the door `RuntimeKt.composableWrapper` walked through in +2024. So a generated fragment could, in principle, be: + +```kotlin +private val bindButton = composableLambdaInstance(/* key */ 0, /* tracked */ true) { args: Array<Any?> -> + androidx.compose.material3.Button(onClick = { … }, modifier = args[1] as Modifier, …) +} +// the ExposedCallable stays `(Array<Any?>) -> Any?`: +{ args -> bindButton.invoke(args, args.last() as Composer, 1) } +``` + +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 +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` + 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 + changing key across builds corrupts the slot table rather than failing. +3. That `changed = 1` is not simply wrong. It is a bitmask over argument staticness, and a constant + is a claim about arguments the generator has not inspected. The conservative value (`0`, meaning + "nothing known") is the thing to try first. +4. That an `Array<Any?>` parameter does not defeat skipping. Compose skips a composable when its + arguments compare equal; a fresh array per call never does, so **every Python-driven composable + would recompose on every frame regardless of whether anything changed.** This is a performance + property, measurable, and it should be measured before the shape is adopted. + +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 + +`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 +adapter can treat them as two code paths with one shared name resolver and no arbitration between +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 + +**`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 +composition is real. + +Four things had to be settled, and three of them contradict what was written above. + +**1. `@Composable` is not a runtime-visible annotation.** `ArtifactScanner.isComposable` read +`MethodNode.visibleAnnotations` on the stated grounds that "`@Composable` is `RUNTIME`-retained". +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. + +**2. The trailing shape is derivable, and was checked rather than assumed.** §5.2 measured nine +signatures by hand; `ComposableBindingTest.everyComposableJvmSignatureIsKotlinParamsThenComposerThenInts` +now checks **281** public top-level composables across three jars, of which **271** carry a +`$default`. The rule: the parameter at index *n* is the `Composer`, everything after it is `int`, +and there are `ceil(n/10)` `$changed` masks (Compose's `SLOTS_PER_INT`) followed by `ceil(n/31)` +`$default` masks (`BITS_PER_INT`) exactly when the declaration defaults something. No composable +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 +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 +it is zero. + +Load-bearing, and checked by breaking it: shifting the mask one bit left made the render fail with +`Parameter specified as non-null is null: … graphicsLayer-Ap8cVGQ`, i.e. a default that was no +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 +synthetic parameters. It fails earlier: + + probe.kt:2:35: error: unresolved reference 'TextKt'. + +A Kotlin file facade has **no Kotlin name at all** (measured with `kotlinc` 2.4.20-Beta2 against the +real jar), so there is nothing to write. Java cannot spell `Text-fLXpl1I` either. What is emitted +instead is **one `.class` per artefact fragment** (`ComposableThunks.kt`): a +`public static Object t<i>(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 +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 +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 +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 +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 +500, so the cost of waiting is one declaration). And a composable with more than 31 parameters would +exercise the second `$default` word, which the arithmetic writes and nothing has observed. + +--- + +## 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 +`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` +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. + +**What has to be verified, and none of it has been.** Compose stores a lambda in the slot table and +calls it again on a later recomposition, possibly many frames later. That crosses three boundaries +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 + `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 + `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 + threads. `docs/investigations/gil-parking-investigation.md` exists because this repository has already been bitten + by GIL parking; this is a new place to be bitten. +4. **Identity across recomposition.** A Python `lambda` is a new object every time the enclosing + Python function runs, so `equals` is false and Compose cannot skip. Combined with §5.4 item 4, the + likely outcome is that nothing ever skips. Whether that is tolerable is a measurement, not an + opinion, and the measurement does not exist. + +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 + +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 +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/<sourceSet>/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 | + +Two consequences worth stating explicitly. + +**`pythonx.compose.material3` must not be a file per component.** It is a module with a +`__getattr__` and a package name. If a future `pythonx` grows `buttons.py`, `cards.py`, `text.py`, +the design has failed in exactly the way the 2024 one did, and the count of empty files is the +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 — +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. + +--- + +## 8. Open + +Collected, in the order that unblocks the most. + +1. **Parameter names across the boundary** (§2.4). Nothing keyword-based is possible without them. + Either extend `ExposedCallable`, or emit a side table with one more bootstrap entry point; the + trade is per-entry footprint on five targets against a second thing that can disagree with the + first. Not decided. +2. **`isExtension` and the receiver type across the boundary** (§2.4, §4.2). `ArtifactScanner` has + both and drops them. Without them `kotlin-extensions` §4.1 has no input. +3. **Defaults** (§4.5). Four candidates, all with a named reason they may not work. This is the item + that decides whether a bound Compose surface is usable at all. +4. **`composableLambdaInstance` as the composable call route** (§5.4), and the four checks it needs. + Answering (1) of those four resolves the contradiction in §5.3 one way or the other. +5. **Where `pythonx`'s `.py` files live on iOS, androidNative and wasm** (§2.5). Today: nowhere. +6. **Python-callable lifetime across recomposition** (§6), starting with `RememberObserver.onForgotten`. +7. **Install cost** (§2.3). `PythonProxySource.install()` has never been measured, on any table size. + 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 + 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` + 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 + +`ArtifactScannerTest.composeModifierExtensionsSurviveBothGates` and +`FunctionSlotBindingTest.mostFunctionTypedModifierExtensionsAreNoLongerDeclinedForBeingFunctionTyped` +were run against the real Compose 1.7.0 desktop jars (`ANDROID_HOME=... ./gradlew +:python-multiplatform-gradle-plugin:test --tests ...`, 2026-08-17) to get the *current* decline list +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 +`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<PointerInputScope, Continuation<Unit>, Any?>` | suspend function-typed parameter — **now bound**, see below | +| `dragAndDropSource` | `dragAndDropSource(Modifier, (DrawScope)->Unit, suspend DragAndDropSourceScope.() -> Unit)` → `Function2<..., Continuation<Unit>, 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<CoroutineScope, X, Continuation<Unit>, Any?>` | suspend function-typed parameter | +| `composed` | `composed(Modifier, (InspectorInfo)->Unit, factory: @Composable Modifier.() -> Modifier)` → `factory` compiles to `Function3<Modifier, Composer, Integer, Modifier>` | `@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<T>(Modifier, SwipeableState<T>, ..., 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<T>(Modifier, ProvidableModifierLocal<T>, () -> T)` | same: `() -> T`'s `T` has no name | +| `anchoredDraggable` | `anchoredDraggable<T>(Modifier, AnchoredDraggableState<T>, ...)` | 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. +- **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. + +### 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 +*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 +`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 — + + fun pythonPointerInput(modifier: Modifier, onEvent: PyObject): Modifier = + modifier.pointerInput(Unit) { + try { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + onEvent(type, x, y).close() // ordinary, non-suspending PyObject.invoke + } + } + } finally { onEvent.close() } + } + +bound by KSP exactly the way `fixture.compose.emptyModifier` already is (`FragmentScanner +.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 +`function.parameters` alone and never reads `KSFunctionDeclaration.extensionReceiver`, so `fun +Modifier.pythonPointerInput(...)` would silently drop the receiver from the generated call. Changing +that is shared infrastructure and out of scope here; `pythonPointerInput(modifier, onEvent)` is the +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 +`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 +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 + +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<Float, Unit>` the internal drag node calls + 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 + suspends. `fixture.compose.pythonDraggable` + (`ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDraggable.kt`) builds a + `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 + 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 + scene, so the leak is bounded (three references) for that test. + + Proven, not asserted: `ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose + /DraggableRenderTest.kt` drives a real horizontal drag (`Move`/`Press`/several `Move` steps past the + default touch slop/`Release`) through `ImageComposeScene.sendPointerEvent`. Python asserts, in Python, + that `on_drag_started` fired exactly once with a real position inside the 48×48 box, that + `on_delta` fired at least once and its accumulated total is strictly positive (not a zero-stub), and + that `on_drag_stopped` fired exactly once with a `float`; a fresh scene shows the accumulated total as + a changed digit (ink 58→131px, 140px changed). The negative control is a press-and-release entirely + outside the 48×48 box: none of the three callbacks fire. Confirmed red first: with + `PythonDraggable.kt` removed, both tests fail with `cannot import name 'pythonDraggable'`; restored, + both pass. + +- **`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 + 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 + `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`'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. + + Proven the same way as `pointerInput`: `DragAndDropSourceRenderTest.kt`, structurally identical to + `PointerInputRenderTest.kt` (a tap-in-box positive claim, a tap-outside-box negative control). + 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 + +`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 +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` +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` +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 +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. + +`LayoutIdRenderTest.kt`'s working version reads `layoutId` back the same way `PythonComposition` itself +is reached: a `Layout` built in the *Kotlin test*, wrapping `PythonComposition`, whose one measurable is +whatever single `Text` Python's body composed. It places that child at x=0 if the child's `layoutId` +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 +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 + +- **`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 + `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 + 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 + shape (`Function3<Modifier, Composer, Integer, Modifier>`, 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 + `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 + **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 + 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, + non-`@Composable` top-level function bound by KSP; its body is `modifier.composed { ... }`, and the + 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 + .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 + 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 + persistence. `ComposedRenderTest.aFactoryProducedModifierIsAppliedAndItsSizeCrosses` is the render + 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 + 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 + `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<T>`, + `ProvidableModifierLocal<T>`, `AnchoredDraggableState<T>`), 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: + SwipeableState<Float>, ...)`, 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. + +## §10 The zero-byte material3 stubs, ten of them judged + +The downstream package carries twenty-nine files under `material3`, twenty-seven of them zero +bytes. They were never wrappers that got emptied: nothing had established whether the adaptation +layer produces those declarations at all, which is a different question from the one the deletions +answered. Ten are answered now, by render tests in `ksp-fixtures/compose` (`M3ProofRenderTest`), on +the evidence model already in use -- pixels for components that leave background visible, distinct +colours for the ones whose own container covers the scene. + +| declaration | evidence | +|---|---| +| `HorizontalDivider` | 200 px against 0 for an empty body | +| `RadioButton` | selected 254 px, deselected 166 px -- the argument reaches the component | +| `LinearProgressIndicator` | 800 px at `progress=0.75` | +| `CircularProgressIndicator` | 414 px at `progress=0.75` | +| `Surface` | 192 px with content, 0 without | +| `Scaffold` | 34 distinct colours with content, 1 without | +| `Slider` | 1062 px, and the pixel sets differ between two values | +| `TopAppBar` | 37 distinct colours with a title, 1 without | +| `FloatingActionButton` | 173 distinct colours with content, 140 without | +| `TabRow` + `Tab` | 22 distinct colours with a text slot, 2 without | +| `Checkbox` | checked 400 px, unchecked 144 px | +| `Switch` | checked 1492 px, unchecked 1496 px | +| `BottomAppBar` | 34 distinct colours with content, 1 without | +| `NavigationBar` | 34 distinct colours with content, 1 without | +| `NavigationRail` | 34 distinct colours with content, 1 without | +| `ExtendedFloatingActionButton` | 174 distinct colors with content, 141 without | +| `Snackbar` | 85 distinct colors with content, 55 without | +| `AlertDialog` | 198 distinct colors with content, 101 without | +| `NavigationDrawerItem` | 127 distinct colors with content, 94 without | +| `SearchBar` | 127 distinct colors with content, 94 without | + +Two things came out of it that are worth more than the ten. + +**A component that animates forever cannot be rendered by this harness.** The indeterminate +progress indicators run an infinite transition, so the composition never goes idle and +`ImageComposeScene.render` never returns. It presents as a slow suite: thirty-seven minutes with +seven seconds of CPU, diagnosed from a thread dump rather than from the log, which said nothing at +all. Both tests use the determinate overload instead. Anything else that animates without input +will hang the same way. + +**A Python callable is refused for a slot typed `() -> Float`, while one for `() -> Unit` is +accepted.** The dispatcher lists all three overloads and rejects the call. Every function slot +exercised before this was a callback returning `Unit`, so nothing had touched the value-returning +case. `aValueReturningFunctionSlotDoesNotYetAcceptAPythonCallable` pins it by asserting the +dispatcher's own message; when that test fails, the coercion has been added and the two progress +tests should move to the non-deprecated overload in the same commit. + +Seven components remain unjudgeable in this harness, and the reasons why: + +1. **`Typography` / `Shapes` / `dynamicLightColorScheme`** (`typography.py`, `shape.py`, `dynamic_color.py`): `Typography` and `Shapes` are ordinary (non-`value`) classes, not data classes, and `ArtifactScanner.constructorCandidates` used to bind only `value`-class constructors -- both were simply never scanned, not declined. That gate is now general: every public, non-abstract, non-generic class's constructor is attempted, and `ArtifactScanner.dropCollidingConstructors` declines one only when it would shadow a bound factory function or a same-named constant (`TextStyle.Default` is the case that forced the second rule -- see `ArtifactScannerTest.aTypographyOrShapesConstructorBindsButATextStyleConstructorDoesNot`). Neither `Typography` nor `Shapes` has that problem, so `Typography(…)` and `Shapes(…)` are both callable from Python now. Neither is convenient, though: `Typography` declares 15 constructor parameters, over `ArtifactScanner.MAX_OMITTABLE_PARAMETERS` (6)'s cap on how many defaulted parameters a generated call is willing to omit (`§4.5`, `2^n` presence branches per omission set), so every `Typography(…)` call must name all 15 -- `M3ProofRenderTest.typographyChangesFontRender` does exactly that. `Shapes`' 5 parameters stay under the cap. `ColorScheme` is exported because it has a top-level factory function, but calling it fails at runtime because its 36 parameters exceed the 31-bit default omission limit. `dynamicLightColorScheme` is an Android-only API and absent from the desktop jar. + + **What widening the gate costs, measured rather than assumed.** Comparing the bound-name sets + generated for `ksp-fixtures:compose` before and after, the corpus goes from 1417 to 1497 names. + Seven of the additions are paid for: these were bound before, as value-class constructors, and + the constant-collision rule now declines them -- + `androidx.compose.ui.unit.Dp`, `androidx.compose.ui.unit.TextUnitType`, + `androidx.compose.ui.text.font.FontStyle`, `androidx.compose.ui.text.style.BaselineShift`, + `androidx.compose.ui.draw.BlurredEdgeTreatment`, + `androidx.compose.ui.hapticfeedback.HapticFeedbackType` and + `androidx.compose.ui.input.pointer.PointerButton`. In every one of the seven the constructor + takes an opaque `Int`/`Float` and the constants it would shadow (`Dp.Hairline`, + `FontStyle.Italic`, `HapticFeedbackType.LongPress`, ...) are the names a Python caller actually + wants, so the trade is worth taking -- but it *is* a trade, and `Dp(8.0)` stops being callable + from Python. What would remove the trade rather than choose a side is making a bound bare name + serve as both a callable and a namespace; that is an adapter change, not a scanner one, and is + not attempted here. +2. **`DatePicker` / `TimePicker` / `SwipeToDismiss` / `BottomSheet`** (`date_picker.py`, `time_picker.py`, `swipe_to_dismiss.py`, `bottom_sheet.py`): Previously thought to be blocked by complex state objects. However, testing confirms that their state factory functions (`remember_date_picker_state`, `remember_time_picker_state`, `remember_swipe_to_dismiss_box_state`) **are** successfully bound and exported. Passing them from Python to the respective composables works flawlessly in the render harness. +3. **`DropdownMenu`** (`menus.py`): Renders in a separate popup window layer. As confirmed by tests, `ImageComposeScene.render()` fundamentally does not capture these detached layers, resulting in 0 distinct colors despite being fully functional. + +### §9.5 `anchoredDraggable`, through a concrete type + +The three modifiers declined for an unspellable type parameter are not equally blocked, and one of +them is now bound. `anchoredDraggable` takes its anchors and its state over a generic `T`; a wrapper +that fixes `T` to `String` binds as an ordinary top-level function, the way the identifier wrapper +fixed its own tag type. String was chosen because the states a swipe moves between are discrete +names -- "start", "end" -- and a caller writes them literally. + +Proven the way the drag wrapper was: a real gesture crosses the boundary, Python asserts it received +the anchor it settled toward rather than a stub, and a gesture outside the box invokes nothing. Ink +moves 58 to 59 pixels, 68 pixels change. Removing the wrapper fails both tests. + +It carries the same unfixed lifetime as the plain drag wrapper -- the confirm callback is held by the +state object and never closed -- and that is stated in its own documentation rather than discovered +later. `DraggableLeakTest` already pins the shape of that leak and the reason interning cannot reach +it. + +`modifierLocalProvider` is now bound too: the same technique applies (one wrapper per concrete type). See §9.7 for details on its lifetime and validation. `swipeable` is now bound too -- see §9.6. + +### §9.6 `swipeable`, through a concrete type, and a second slot that needed dodging entirely + +`swipeable` (`androidx.compose.material.SwipeableKt.swipeable-pPrIpRY`) has the same unspellable-`T` +shape `anchoredDraggable` does -- `javap -p` against the real Compose Material 1.7.0 desktop jar (no +sources jar for this version) confirms the table's signature exactly: + + <T> Modifier swipeable-pPrIpRY(Modifier, SwipeableState<T>, Map<Float, ? extends T> anchors, + Orientation, boolean enabled, boolean reverseDirection, MutableInteractionSource, + Function2<? super T, ? super T, ? extends ThresholdConfig> thresholds, + ResistanceConfig, float velocityThreshold) + +`fixture.compose.pythonSwipeableString` fixes `T` to `String`, the same choice and the same reason as +`anchoredDraggable`'s wrapper. It is a plain top-level function, bound by KSP exactly like +`pythonAnchoredDraggableString`; `Modifier.swipeable` itself stays declined, correctly. + +`thresholds: (T, T) -> ThresholdConfig` is where this wrapper is not a copy of `anchoredDraggable`'s. +It is a **value-returning** function slot -- it must produce a `ThresholdConfig`, not `Unit` -- and §10 +already measured that a value-returning slot (`() -> Float`) is refused by the dispatcher where a +`() -> Unit` slot is accepted. Substituting a `PyObject` here would hit that same refusal, and even if +it didn't, the callable would have to construct a `ThresholdConfig`, itself unspellable from Python. +So `thresholds` never crosses to Python at all: `pythonSwipeableString` takes a `thresholdFraction: +Float` -- an ordinary marshalled value, not a callable -- and Kotlin closes over it to build the lambda +itself, `{ _, _ -> FractionalThreshold(thresholdFraction) }`. Nothing about `FractionalThreshold` uses +the `(T, T)` pair it is handed, so fixing the fraction ahead of time loses nothing the real slot would +have used them for. `SwipeableState`'s own `confirmStateChange: (T) -> Boolean` is a second +value-returning slot, sidestepped the same way `anchoredDraggable`'s `confirmValueChange` is: `Python` +is invoked as a one-way notification and the Kotlin lambda always returns `true`, never carrying a +Python return value back into Compose. + +Proven the same way: `SwipeableRenderTest` drives a real drag past the fractional threshold and asserts, +in Python, that `_on_value_change` received `"end"` (not a stub), and that a press outside the box +invokes nothing. Ink moves 58 to 28 pixels, 72 pixels change. Confirmed red first: with +`PythonSwipeable.kt` removed, both tests fail with `cannot import name 'pythonSwipeableString'`; +restored, both pass. + +Same unfixed lifetime as the other two: `onValueChange` is held by the `SwipeableState` and never +closed; `DraggableLeakTest` already pins the shape of that leak family and is not repeated here. + +`Modifier.swipeable` and the `SwipeableState` constructor it built on are themselves +`@Deprecated` in this Compose version (`w:`, not `e:` -- "Material's Swipeable has been replaced by +Foundation's AnchoredDraggable APIs"), which is why `@OptIn(ExperimentalMaterialApi::class)` alone was +enough to compile; the deprecation did not need suppressing to reach green. + +### §9.7 `modifierLocalProvider` and `modifierLocalConsumer`, providing structural proofs + +The last outstanding generic `T` rejection in §9.2 was `modifierLocalProvider`. It is now bound using the same concrete type wrapper technique (for `String`), exposing `pythonModifierLocalProviderString` and `pythonModifierLocalConsumerString`. + +This binding provides a proof that values actually traverse the composition structural tree: the value is provided in a parent composable and read in a child composable. The test (`ModifierLocalRenderTest`) confirms that the child consumer reads the provided value across the layout tree, and includes a negative control proving that when unprovided or detached, the consumer reads the `defaultValue`. + +Same unfixed lifetime as the others: the `onRead` callback passed to `pythonModifierLocalConsumerString` is held by the consumer block and never closed. diff --git a/docs/archive/threading-and-abi-3.15t-gate.md b/docs/archive/threading-and-abi-3.15t-gate.md new file mode 100644 index 000000000..aad63ff2b --- /dev/null +++ b/docs/archive/threading-and-abi-3.15t-gate.md @@ -0,0 +1,52 @@ +# Why free-threading was gated on 3.15t (2026-08-10 reasoning) + +> **Superseded** by [`../design/threading-and-abi.md`](../design/threading-and-abi.md) on 2026-10-03; kept for history. +> The conclusion below ("free-threading starts at 3.15t") was wrong for this project: 3.14t runs the whole +> desktop suite with `-PpythonFreeThreaded=true` (ROADMAP §9, SPEC T-2). The premise that blocked 3.14t +> (no Stable ABI on free-threaded builds) does not bind here because `Py_LIMITED_API` is not defined. + +## Why 3.15t and not 3.14t + +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 +pipeline. + +[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. + +### What abi3t requires of us + +| Requirement | Our status | +|---|---| +| `PyObject` / `PyVarObject` become incomplete types; no field access | **Compatible.** Verified: `PyObject` appears only as `CPointer<PyObject>` 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. | +| `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). | + + +## Prebuilt availability (snapshot), as of the 20260807 python-build-standalone release + +| Platform | GIL | Free-threaded | +|---|---|---| +| macOS arm64 / x86_64 | ✅ | ✅ (incl. `3.15.0rc1`) | +| Linux x86_64 | ✅ | ✅ (incl. `3.15.0rc1`) | +| Windows x86_64 | ✅ | ✅ (incl. `3.15.0rc1`) | +| Android (python.org) | ✅ | ❌ none published | +| iOS (Python-Apple-support) | ✅ | ❌ none published | + +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. + +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: + +1. CPython 3.15 final, for `abi3t` +2. Free-threaded mobile artefacts from python.org and Python-Apple-support +3. Adequate free-threaded wheel coverage for the C extensions users care about + +`gradle.properties` already carries `pythonFreeThreaded`, defaulting to `false`, so the switch is a +configuration change once those hold. + diff --git a/docs/archive/wasm-design-experiment-log.md b/docs/archive/wasm-design-experiment-log.md new file mode 100644 index 000000000..e78d3d494 --- /dev/null +++ b/docs/archive/wasm-design-experiment-log.md @@ -0,0 +1,1386 @@ +> **Superseded** by [`../platforms/wasm-design.md`](../platforms/wasm-design.md) on 2026-10-03; kept for history. +> 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 +> 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). +> Paths such as `wasm-experiment/` and `/Volumes/macMini/wasm-build/` refer to the retired experiment +> and the pre-move build location; the build now lives in `tools/wasm/build-cpython.sh` under `.caches/`. + +# WebAssembly (WASM) Feasibility and Design + +This document details the feasibility and architectural design for supporting WebAssembly (WASM) in this Kotlin Multiplatform library, specifically addressing the challenges of embedding CPython 3.13 in a Kotlin/Wasm environment. + +## 1. Kotlin/Wasm Target Reality + +Based on the project's Kotlin version **2.0.20**: +- **Supported Targets:** Kotlin provides both `wasmJs` (for the browser and Node.js with JS host) and `wasmWasi` (for standalone Wasm environments). Both are classified as Alpha in 2.0.20 (though `wasmJs` matures in later 2.x releases). +- **JS Interop:** Kotlin/Wasm uses the WebAssembly Garbage Collection (WasmGC) proposal. It interoperates with JavaScript via `external` declarations and the `JsAny` type hierarchy. + - **Strings:** Translated seamlessly between Kotlin `String` and JavaScript `string`. They are not passed as `const char*` raw pointers. + - **Pointers & Opaque Handles:** WebAssembly linear memory pointers (like `PyObject*`) are simply 32-bit integers (`Int` or JS `Number`). They cannot be directly mapped to Kotlin's WasmGC objects. + - **Callbacks:** Kotlin lambdas or `@JsExport` functions are exposed as JavaScript function objects, not raw C function pointers. + +## 2. The Two Candidate Approaches for `wasmJs` + +Since Kotlin/Wasm and a WASM CPython build are separate modules that do not share linear memory, they must communicate through the JS host environment. + +### (a) Pyodide's High-Level JS API +This involves using `pyodide.runPython`, `pyodide.globals`, and `PyProxy` objects. +- **API Coverage:** Almost **none** of the ~330 `expect` C-API functions can be implemented this way. Pyodide abstracts away the C API. +- **Object Model:** This approach completely destroys the `PyObject`/`PyType` model the library is built on. WASM would have to use `JsAny` wrappers of `PyProxy` instead of `NativePointer`. +- **Verdict:** **Impractical.** It would result in a fundamentally different architecture and semantics for WASM compared to Android, Desktop, and Native. + +### (b) Raw CPython C Symbols Exported from a WASM Build +This involves reaching Emscripten's `EXPORTED_FUNCTIONS` (e.g., `Module._PyList_Size`), passing memory addresses as 32-bit integers. +- **API Coverage:** Can implement the entire `expect` surface, mapping 1-to-1 with the Stable ABI. +- **Object Model:** Preserves the exact same `NativePointer` and `PyObject` semantics as other platforms. +- **Per-Call Cost:** High. Every call must cross from Kotlin WasmGC -> JS -> Emscripten Linear Memory. However, since the signature census shows only 14 simple ABI shapes, this crossing can be tightly optimized. + +## 3. Does Pyodide Export What We Need? + +**No, a stock Pyodide build does not export the full Stable ABI.** +Pyodide relies on Emscripten's dead-code elimination. Symbols not used by Pyodide's internal bindings or explicitly listed in `EXPORTED_FUNCTIONS` are stripped. Furthermore: +- **CPython 3.13 Status:** The `wasm32-emscripten` build was dropped from official CPython support in 3.13. (Pyodide maintains it out-of-tree). `wasm32-wasi` was promoted to Tier 2 support. +- **Custom Build Required:** To use the raw C API approach, we would need to generate a custom Emscripten build (or a custom Pyodide distribution) that explicitly adds our ~330 required C API symbols to `EXPORTED_FUNCTIONS` (or use `EMSCRIPTEN_KEEPALIVE`). + +## 4. Pointer Width and `NativePointer` + +WASM32 uses **32-bit pointers**. +- The current abstraction `data class NativePointer(val address: Any)` uses `Any`. Passing a 32-bit integer pointer into `Any` will box it (as a `Double` on JS or a boxed primitive in WasmGC), adding allocation overhead. +- The `toRawValue(): Long` signature assumes 64-bit addresses. Since a 32-bit unsigned integer fits perfectly into a 64-bit `Long`, the abstraction mathematically holds. However, Kotlin/Wasm code implementing `toRawValue()` will need to cast the JS/Wasm 32-bit `Int` to a Kotlin `Long` carefully to avoid sign-extension bugs. + +## 5. Upcalls on WASM (Python calling Kotlin) + +Python expects a raw C function pointer (an integer address pointing to executable memory). We can preserve the "routing by data" design from other platforms, but the plumbing is different: +1. We write our ~4 static trampoline shapes in Kotlin and export them to JS using `@JsExport`. +2. In JS glue code, we use Emscripten's `addFunction(jsFunction, signature)` to inject the JS function into Emscripten's WebAssembly function table. +3. `addFunction` returns an integer (the table index). This integer *is* the C function pointer. +4. We pass this integer to CPython (e.g., in `tp_new` or `PyCFunction_NewEx`). When CPython calls it, Emscripten routes the call out to JS, which calls our Kotlin WasmGC function. The context pointer (`self` or `closure`) is passed as an integer, allowing us to route to the correct Kotlin object. + +## 6. `wasmWasi` + +**Separate implementations are required.** +Pyodide and Emscripten rely entirely on JavaScript glue code. If the project targets non-browser WASM via `wasmWasi` (pairing with CPython's Tier 2 `wasm32-wasi`), there is no JS host. +Currently, Kotlin/Wasm cannot dynamically link directly to an arbitrary C WebAssembly module without a JS host bridge, as the Wasm Component Model and native static linking are not yet mature enough in the Kotlin toolchain. Thus, `wasmWasi` is genuinely a second, entirely different port that is currently **blocked** by Kotlin/Wasm tooling limitations. + +## 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. + +**Caveat:** WASM will have permanently higher per-call overhead than Native/JVM due to the WasmGC ↔ JS ↔ Emscripten boundary crossings. + +### Staged Plan +1. **Toolchain Customization:** Create a custom Pyodide/Emscripten build script that passes all ~330 `EmbedAPI` functions to Emscripten's `EXPORTED_FUNCTIONS`. +2. **Downcalls (Kotlin to Python):** Write a Kotlin `external` module that binds to `Module._Py*` functions on the Emscripten JS object. Implement `EmbedAPI.wasmJs.kt` to call these. +3. **Upcalls (Python to Kotlin):** Use Emscripten's `addFunction` via JS interop to register 4 static Kotlin `@JsExport` trampolines, returning function pointers to hand to Python. +4. **Pointer Optimization:** If boxing in `NativePointer(val address: Any)` proves too slow in WasmGC, consider refactoring the `expect` class to use a concrete `Long` across all platforms (which JVM/Native already use natively) to avoid allocations. + +--- + +## Addendum: correcting two framing errors + +### Kotlin/Native once had a wasm target, and it was removed + +The analysis above assumed Kotlin/Wasm is the only route. That is true today, but not for a +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 +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. + +The consequence is that the JS bridge is forced by *tooling*, not by WebAssembly itself: + +- Kotlin/Wasm is WasmGC-based, so its memory model differs from C's linear memory. +- 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. + +### The dependency is CPython's own WASM support, not Pyodide + +Pyodide is a downstream distribution carrying its own patches. A library that intends to be a +standard multiplatform binding should build CPython from source for a WASM target rather than +depend on Pyodide. + +Where CPython's own support actually stands: + +| Target | CPython 3.13 | CPython 3.14 | +|---|---|---| +| `wasm32-wasi` | Tier 2 (officially supported) | Tier 2 | +| `wasm32-emscripten` | Not a PEP 11 platform | Tier 3, via PEP 776 | + +[PEP 776](https://peps.python.org/pep-0776/) is Active, targets 3.14, and formalises Emscripten at +Tier 3. Three of its details bear directly on this design: + +1. **Only static linking is supported.** The PEP states it is only supported to statically link the + interpreter; dynamic linking is unsupported because `EM_JS` functions behave differently in + 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, + i.e. Pyodide. Avoiding Pyodide as a *dependency* therefore means building CPython from source + ourselves, not that an upstream binary exists to use instead. + +Because Kotlin/Wasm cannot statically link C code, and CPython cannot be dynamically linked into +another module, the browser architecture is necessarily three parts: the CPython Emscripten module, +the Kotlin/Wasm module, and JavaScript coordinating them. That holds regardless of which approach +is taken. + +`wasm32-wasi` is the better-supported target on the CPython side, but there is no JavaScript host to +act as glue, and Kotlin/Wasm's `wasmWasi` cannot link a C module. Bridging the two awaits the WASM +Component Model. + +### Practical consequence + +Official Emscripten support arrives in **CPython 3.14**. This project is pinned to 3.13, where +Emscripten has no PEP 11 status at all. WASM support therefore implies moving the embedded +interpreter to 3.14 or later. + +--- + +# Why WASM is parked, and what it would look like + +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 +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 +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, +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 +happen. + +JetBrains has a **Multiple Memory** proposal under consideration that would address this, and a +C-interop example PR against `kotlin-wasm-examples` was opened in March 2024. Neither has a +committed date, and neither appears on the published roadmap, so neither can be planned around. + +## emscripten, not wasi + +| | `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 | +| 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 +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 +connect a Kotlin/Wasm module to a CPython module there. + +## The shape it would take + +Put our C code inside CPython's module, so it can handle pointers, and keep the boundary to +integers. + + +- Emscripten module (one linear memory) -----+ + | CPython + our C shim | pointers handled here + +---------------------+-----------------------+ + | i32 only + +-----+-----+ + | JS glue | string copying only + +-----+-----+ + | + +--------+---------+ + | Kotlin/Wasm | + +------------------+ + +Pointers are 32-bit on wasm32, so a `PyObject*` fits in a Kotlin `Int`. The shim exposes +composed operations, the same ones measured on Android: + + int pmp_getattr(int obj, int namePtr); // lookup + error handling, one crossing + int pmp_exec(int codePtr); // __main__ + globals + run + int pmp_list_items(int list, int outPtr, int cap); // bulk: fills a caller buffer + int pmp_str_utf8(int obj, int outLenPtr); + void pmp_decref(int obj); + 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 +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. + +**Bulk is where composition earns its keep on this platform.** It was rejected on desktop and +superseded by interning on Android; here it is the only mitigation left, because +`Module.HEAP32.subarray(p, p+n)` fetches N items in one hop where per-element access would cost +N. + +Nothing above changes the layering: `wasmJsMain` supplies `actual`s for the same EmbedAPI +`expect`s, and the object model does not move. + +## What survives if the toolchain improves + +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. + +## Unresolved + +- `NativePointer` holds `Any`, so a wasm32 `Int` boxes on every conversion, and + `toRawValue(): Long` needs sign-extension care. +- CPython must be built with our shim and an explicit `EXPORTED_FUNCTIONS` list; a stock + Pyodide build strips the C API by dead-code elimination. That is a second build pipeline. +- Upcalls: `addFunction(jsFn, sig)` gives a callable function pointer, but re-entering + Kotlin/Wasm from it goes through JS. Unmeasured, and likely worse than every other platform. +- Lifetime: whether Kotlin/Wasm offers a GC hook equivalent to `Cleaner`/`createCleaner` has not + been checked. Without one, only explicit `close()` works. + +## Start condition + +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. + +## Packaging: a side module, against our own CPython + +Two independent choices, easily conflated: + +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. + +### Side module, on both counts of its own + +| | statically linked | **side module** | +|---|---|---| +| changing one binding | rebuild all of CPython | rebuild our module | +| release cadence | tied to CPython's | independent | +| shape | a CPython fork, effectively | an ordinary extension module (`PyInit_*`) | +| 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 +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 + +The earlier draft argued for keeping Pyodide compatibility in reach, on the grounds that Pyodide +*is* the package ecosystem on the web. That argument does not survive contact with how Pyodide +wheels are tagged: `cp312-cp312-pyodide_2024_0_wasm32` pins the Python version, the Emscripten +version **and** Pyodide's own ABI together. What you get is not "the Python ecosystem" but +"packages someone built for exactly this Pyodide release". + +So the choice is not ecosystem-versus-no-ecosystem. It is whose tag to be locked to, and +targeting Pyodide would mean the web platform tracking their release cadence while every other +platform is pinned to 3.14 from source. That is a real cost for a conditional benefit. + +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 | +| 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 +built. Pyodide's advantage is a prebuilt catalogue, not a different rule. + +**Decision: our own Emscripten build of CPython 3.14, packaged against by a side module.** +Compiled third-party extensions are a known limitation on this platform, recorded rather than +solved. + +### Correction: PEP 783 unbinds the tag from Pyodide + +The section above is right about the decision and wrong about its cost. It was written from the +`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 +the Pyodide-owned tag with a standard platform tag series that PyPI accepts: + +``` +pyodide_2024_0_wasm32 Pyodide's tag, Pyodide's platform +pyemscripten_2026_0_wasm32 standard platform tag (PEP 783) +``` + +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 +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. + +**Compatibility is a build recipe, not a distribution.** The PEP states that the tags "can be used +by Python interpreters compiled and linked with the specified version of Emscripten and with the +specified ABI-sensitive flags". So our own build can claim the platform. The ABI-sensitive surface +the PEP enumerates: + +| | | +|---|---| +| Emscripten compiler version | pinned per platform version | +| statically linked libraries | fixed set | +| stack unwinding ABI | fixed selection | +| dependency lookup handling | specified | +| `-pthread` | prohibited | +| `-sWASM_BIGINT` | required | + +`-sWASM_BIGINT` is worth noting for us specifically: it is what makes an i64 cross the JS boundary +as a BigInt rather than being split into two i32s, which is the same `NativePointer` width question +§4 above raises. The platform requires the setting we would have wanted anyway. + +**The full specification currently lives in Pyodide's documentation**, which the PEP references +normatively while noting the wording was chosen to be "more forwards compatible to a future where +the definition of the platform moves upstream". So Pyodide still *authors* the spec even though it +no longer *owns* the tag. That is a real residual coupling, but it binds a flag list, not a runtime. + +#### What this changes + +| | earlier draft | with PEP 783 | +|---|---|---| +| what the tag pins | Pyodide release | Python version + a published flag set | +| our own build + C-extension wheels | mutually exclusive | **compatible, if we match the flags** | +| 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 +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. + +Evidence that the path is real rather than paper: [pypa/packaging #804](https://github.com/pypa/packaging/pull/804) +implements the tag handling, [pypi/warehouse #19804](https://github.com/pypi/warehouse/pull/19804) +adds PyPI support, [maturin #3163](https://github.com/pyo3/maturin/pull/3163) adds it to a build +backend, and [pydantic publishes Emscripten wheels to PyPI under it](https://pydantic.dev/articles/emscripten-wheels-pydantic). + +#### Resolved: the flag list, read off the source rather than the rendered page + +`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 +`github.com/pyodide/pyodide-build` `pyodide_build/config.py`. + +`Makefile.envs` at that tag pins the whole platform in four lines: + +``` +PYODIDE_VERSION ?= 314.0.4 +PYODIDE_ABI_VERSION ?= 2026_0 +PYVERSION ?= 3.14.2 +PYODIDE_EMSCRIPTEN_VERSION ?= 5.0.3 +``` + +| | `pyemscripten_2026_0` (3.14) | for comparison, `2025_0` (3.13) | +|---|---|---| +| 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 | +| `-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 | — | + +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) +found through `RPATH`. + +Extension modules are side modules. Pyodide documents `-sSIDE_MODULE=2` plus an explicit +`-sEXPORTED_FUNCTIONS=["_PyInit_…"]` as the recommendation, though its own C default is +`-sSIDE_MODULE=1`. That is exactly the shape the packaging decision above already chose for our +shim. + +**PEP 783 itself does not enumerate any of this.** It lists the ABI-sensitive *surface* and then +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 +stock CPython Emscripten build cannot advertise the platform even if it matches the flags; the +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 +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. + +### 1. Kotlin can call another wasm module's exports directly + +`@WasmImport(module, name)` and `@WasmExport(name)` exist in the stdlib +(`libraries/stdlib/wasm/src/kotlin/wasm/Annotations.kt`), gated behind +`@ExperimentalWasmInterop` since 2.2. The annotation doc says the function is imported "without +type adapters". The compiler's own codegen test `callingWasmDirectly.kt` states the intent +outright: + +> Here we pass export of another Wasm module to our import directly without JS layer. +> This enables strict type check without JS conversions. + +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. + +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. + +### 2. On `wasmJs`, Kotlin *imports* its linear memory + +`WasmCompiledModuleFragment.createAndExportMemory` branches on a flag that +`wasmCompiler.kt` sets as `importWasmMemoryInsteadOfExport = isWasmJsTarget`: + +| 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 | +| `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 +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 +else, because every measurement on Android and desktop found marshalling, not crossings, to be +the dominant cost. + +### 3. The blocker is the allocator, not the memory + +`kotlin/wasm/unsafe/MemoryAllocation.kt` creates the top-level allocator as +`ScopedMemoryAllocator(0, parent = null)` and extends it with `wasm_memory_grow`. **It starts at +address 0 and assumes it owns everything.** In Kotlin's own memory that is correct. In a memory +shared with Emscripten, address 0 is the null page and the low region holds Emscripten's static +data, so `withScopedMemoryAllocator` would hand out addresses CPython already owns and corrupt +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. +- **Allocating from Kotlin inside the shared memory** is not, unless the allocator can be given + a base address it does not currently accept. + +Whether a `Pointer` can be constructed from an arbitrary `Int` returned by the shim, rather than +only from `allocate()`, has not been checked and decides whether the safe half is usable at all. + +### 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 +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 +mechanism: `JsReference<T>` passes a Kotlin object as an opaque reference. + +### What this changes + +| | 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 | +| 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. + +### Unverified, in the order that decides the design + +1. Can the host supply Emscripten's `wasmMemory` as Kotlin's `("intrinsics", "memory")` import? + The generated JS glue builds that import object; whether it can be overridden is unknown. +2. Can `Pointer` be constructed from an arbitrary `Int`? If not, the read-only half is unusable + and only `@WasmImport` survives. +3. Is `("intrinsics", "memory")` stable? It is an internal name, not public API. +4. Does `@WasmImport` work when the exporting module is an Emscripten `MAIN_MODULE`, whose + exports are ordinary wasm functions but whose instantiation Emscripten's own glue controls? + +None of these needs code in this repo to answer; all four are answerable with a small standalone +experiment, and together they decide whether WASM looks like iOS or like the JS-bridge design. + +--- + +## Measured: what the released toolchain actually does + +The section above was written from the Kotlin compiler at master and got one thing wrong in a way +that matters. `wasm-experiment/` builds a trivial `wasmJs` module on Kotlin 2.4.10, runs it, and +parses the emitted binary. Findings, in the order they change the design. + +### The memory import is master-only; the released direction is the opposite + +`importWasmMemoryInsteadOfExport = isWasmJsTarget` is in the compiler source but **not in any +released version**. Parsing the 2.4.10 output: the only length-prefixed `intrinsics` import is +`intrinsics.tag` (the exception tag), there is no memory import, and the export section contains +`memory`. Same on 2.2.20. + +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 +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. + +### Kotlin's linear memory is empty, and stays empty + +This is what makes handing it over safe, and it is measured rather than assumed: + +``` +pages at startup 0 +pages after string interop 0 +pages after collections 0 +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 +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`. + +So a `wasmJs` module's linear memory is unused space that happens to be attached to it. Giving it +to Emscripten costs Kotlin nothing. + +### The allocator hazard is real but avoidable + +The last probe line confirms the source reading: `withScopedMemoryAllocator` grew the memory and +allocated at **address 0**, exactly on top of where Emscripten keeps its static data. In a shared +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 +it. Every address we would ever dereference comes back from a C call. + +`withScopedMemoryAllocator` must therefore never appear in `wasmJsMain`. That belongs in the +source-set README as a hard rule, in the same category as desktop's "always `invokeExact`". + +### What the picture looks like now + +| | earlier draft | measured | +|---|---|---| +| Kotlin → CPython call | JS hop | direct wasm call (`@WasmImport`) | +| 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) | + +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, +and without them it must justify itself on crossing count alone, which is what closed desktop +composition in ROADMAP §6. + +### Still unverified + +1. **Does `@WasmImport` bind to an Emscripten module's export?** The mechanism is proven by the + compiler's own `callingWasmDirectly.kt` codegen test, but against a hand-built module, not one + whose instantiation Emscripten's glue controls. Needs `emsdk`, which is not installed here. +2. **Does Emscripten accept a memory that starts at 0 pages and is grown by JS before + instantiation?** `-sIMPORTED_MEMORY` normally pairs with a memory JS created at the right size. +3. **Does anything in a larger Kotlin program touch linear memory?** The probe covers strings, + collections and exceptions on a trivial module. Coroutines, `ByteArray` interop and the JS + `ArrayBuffer` bridges are untested. + +--- + +## Proven end to end: Kotlin and CPython-side C can share one memory + +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 + +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 +`callingWasmDirectly.kt` test intends. + +### Test B — Emscripten imports the memory Kotlin exports + +``` +C: get_static_message() -> 0x400 +Kotlin: readCStringAt(same address) -> "hello-from-the-cpython-side" PASS +C: alloc_message() -> 0x10628 +Kotlin: writeCStringAt(addr, "written-by-kotlin") +C: str_len(addr) -> 17, UTF8ToString(addr) -> "written-by-kotlin" PASS +``` + +Both directions, static data and `malloc`'d heap. **No copying and no JS in the data path.** +Kotlin dereferences a C address with `Pointer(addr)` and gets the bytes C put there. + +### The single blocker, and it is one value + +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 +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 +memory section is six bytes and nothing in the format holds an absolute offset. + +### The constraint that shapes the design + +A and B cannot be combined in one instantiation graph while Kotlin owns the memory: + +``` +Kotlin @WasmImport needs Emscripten's exports at instantiation time +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 + 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 + measurement on Android and desktop, so this captures most of the win. +3. Copy through JS, as originally designed. + +### What §10 looks like now + +| | 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** | +| 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` | + +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. + +### Rules this produces for `wasmJsMain` + +- **`withScopedMemoryAllocator` must never be called.** It allocates at address 0, on top of + Emscripten's static data. Measured: the probe grew the memory to 2 pages and handed back `0x0`. + CPython allocates; Kotlin only dereferences addresses it was given. +- `Pointer(addr)` is the only way in, and its public constructor plus raw `i32.load`/`i32.store` + members make that free. + +### Resolved + +- **Linear memory is strictly unused.** The probe was expanded to include large `ByteArray` allocations (10MB), large object graphs (100,000 items), `Uint8Array` JS ArrayBuffer bridges, and coroutines (`kotlinx.coroutines.GlobalScope.launch`). **None of them touched linear memory.** The page count stayed exactly at 0 until `withScopedMemoryAllocator` was explicitly called. This confirms that a shared linear memory design is perfectly safe and won't be corrupted by normal Kotlin runtime behavior. +- **JS trampoline cost is ~2.7x.** A microbenchmark running 10,000,000 iterations found a direct `@WasmImport` call took ~48.7 ms, while routing the call through a JS trampoline took ~132.9 ms (2.73x slower). This shows the JS boundary imposes measurable overhead, but since we eliminate copying overhead for strings and data, this cost is a worthwhile tradeoff to achieve the shared memory model. +- **Unbounded Memory bug.** Kotlin's compiler emits `WasmLimits(0, null)`, causing Emscripten to reject the imported memory. This is confirmed to be the only blocker. A draft YouTrack issue has been written to `docs/platforms/wasm-youtrack-issue.md` requesting a compiler flag (or a default Wasm32 ceiling) to fix this. + +### Still open + +- Upcalls. `addFunction` still routes through JS to re-enter WasmGC, and nothing here changes + that; §7 still needs measuring before `wasmJsMain` is written. + +### Correction: the coroutine reading was contaminated + +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. + +Gating the allocator demo off and re-running gives the reading that means something: + +``` +pages at startup 0 +pages after string interop 0 +pages after collections 0 +pages after exception 0 +pages after large ByteArray 0 (10 MiB) +pages after large obj graph 0 (100k strings) +pages after ArrayBuffer bridge 0 +pages observed inside coroutine 0 <- uncontaminated +``` + +So the conclusion holds, and now for coroutines too: **nothing in ordinary Kotlin/Wasm touches +linear memory.** `MEASURE_ALLOCATOR` in the probe is off by default for this reason, and the +coroutine line must be read as meaningless in any run where it is on. + +--- + +## Resolved upstream: 2.4.20-Beta2 imports the memory + +The YouTrack issue drafted in `wasm-youtrack-issue.md` should not be filed. The thing it asks for +has already shipped, and in a better form than the request. + +`importWasmMemoryInsteadOfExport = isWasmJsTarget` was read on the compiler's `master` branch and +recorded here as unreleased, on the evidence that 2.2.20 and 2.4.10 both export a memory. That was +right about those two versions and wrong about the conclusion: **2.4.20-Beta2 imports it.** The +experiment builds against it and the binary says so plainly: + +``` +imports intrinsics.memory (kind 0x02, memory) + intrinsics.memory (kind 0x03, global) + intrinsics.tag +memory definitions 0 <- defines none of its own +non-function exports none <- no longer exports one +``` + +And the generated glue hands it in as an ordinary JavaScript value: + +```js +intrinsics: { + memory: new WebAssembly.Memory({ initial: 0 }), + tag: wasmTag +}, +``` + +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 +history rather than a build step. + +### It also removes the constraint that shaped the design + +The instantiation cycle is gone. While Kotlin exported the memory, Emscripten had to import it +while Kotlin simultaneously needed Emscripten's exports through `@WasmImport`, and wasm supplies +imports up front, so the two could not be satisfied together. The recorded workaround was JS +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. + +### What that leaves + +The last structural obstacle to §10 is gone. Remaining work is ordinary: pin the Kotlin version at +2.4.20-Beta2 or later (the library targets 2.0.20 today, so this is a real upgrade with its own +cost), build CPython for `pyemscripten_2026_0`, write the composed shim, and supply +`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 +nothing. And `withScopedMemoryAllocator` must never appear in `wasmJsMain`, because it allocates +from address 0 upward, on top of Emscripten's static data. + +--- + +## Test C: run, and the cycle is gone + +The section above predicted this from reading the binary. `wasm-experiment/` now builds and runs +it. `native/run.sh` reproduces; Test C is `native/combined-test.mjs`. + +### The binary confirms the shape + +Parsing `wasm-experiment.wasm` built on 2.4.20-Beta2: + +``` +import './probeA-wrapper.mjs' . 'add_two' kind=0 func <- direct wasm call +import 'intrinsics' . 'tag' kind=4 tag +import 'intrinsics' . 'memory' kind=3 global externref +import 'intrinsics' . 'memory' kind=2 memory min=0 max=none +memory definitions 0 +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 — +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. + +### The integration is one substitution + +`patch-import-object.py` rewrites one expression in the generated glue: + +```js +intrinsics: { memory: new WebAssembly.Memory({ initial: 0 }), tag: wasmTag } + -> memory: <probeA-wrapper>.wasmMemory +``` + +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 +`-sIMPORTED_MEMORY`, no binary patching. Kotlin is the side that adapts. + +### Both halves hold at once + +``` +Emscripten's memory object Memory, 258 pages +Kotlin's intrinsics.memory Memory, 258 pages PASS same buffer +Kotlin called cAddTwo(40, 2) -> 42 PASS @WasmImport, memory shared +C: get_static_message() -> 0x400 +Kotlin: readCStringAt(that address) -> "hello-from-the-cpython-side" PASS +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. + +### And it survives the memory growing + +Not previously checked, and it is not optional: CPython links with `-sALLOW_MEMORY_GROWTH +-sINITIAL_MEMORY=20971520`, so the memory *will* grow underneath Kotlin in production. A JS +TypedArray view detaches when that happens. Kotlin holds the memory as a wasm import rather than +a view, and does not: + +``` +C: malloc(64 MiB) -> 0x10638, 258 pages -> 1026 pages + PASS -- the shared memory actually grew + PASS -- Kotlin still reads the pre-growth address correctly + PASS -- Kotlin writes into memory that did not exist when it was instantiated +``` + +### Measured: the control path + +10,000,000 calls of the same trivial C function, one process, Node 24, Emscripten 5.0.3. + +| | ns/call | +|---|---| +| direct `@WasmImport` | **5.0** (stable across runs) | +| JS trampoline | 13.6 – 16.9 (2.7x – 3.4x) | + +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 + +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 +address; only the route differs. + +| ns per read | 27 bytes | 4000 bytes | +|---|---|---| +| shared: scan to NUL, build nothing | 193 | 1 649 | +| shared: `ByteArray` copy, no decode | 399 | 4 875 | +| shared: `ByteArray` + `decodeToString()` | 405 | **58 271** | +| shared: `CharArray` + `concatToString()` (ASCII) | **103** | 5 875 | +| shared: `StringBuilder.append(Char)` per byte | 311 | 51 348 | +| copied: `UTF8ToString` through JS | 125 | **3 827** | + +Two things account for it, and neither is the boundary: + +1. **Kotlin/Wasm strings already *are* JS strings.** The generated glue compiles with + `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 + 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. + +The honest rule that comes out of this is not "marshalling disappears" but: + +- **`char*` → `String`: use the ASCII `CharArray` + `concatToString()` route, and fall back to + `UTF8ToString` for anything long or non-ASCII.** Never `decodeToString()` on a large buffer. +- **`char*` → `ByteArray` (the `PyBytes` case): shared memory, unambiguously.** No decode, and no + JS-side equivalent that avoids a copy. +- Shared memory is still what makes writing *into* C buffers free, which has no JS equivalent at + all short of `Module.HEAPU8.set`. + +### Measured: bulk, which is what the composed shim was for + +1000 `i32` out of a C-owned array, 20,000 repetitions: + +| | ns per element | +|---|---| +| shared memory, `Pointer.loadInt()` | **0.7** | +| through JS, `Module.HEAP32[a >> 2]` | 7.1 | + +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 +CPython-side work is identical either way. + +ROADMAP §6 closed desktop composition at a 10% saving. Test D below measures the same question +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, +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. + +``` +python.wasm exports 8191 +CPython's memory 320 pages +Kotlin's intrinsics.memory 320 pages PASS same buffer + +Kotlin: pyExec("answer = 6 * 7; greeting = ...") -> 0 PASS +Kotlin: pyGlobalInt("answer") -> 42 PASS +Kotlin: pyGlobalString("greeting") -> "hello-from-cpython-42" PASS +Kotlin: pyExec("bytearray(48 MiB)") -> 0, 320 -> 934 pages PASS + Kotlin still reads CPython correctly after the growth PASS + a pre-growth string still reads correctly PASS +``` + +Kotlin wrote the Python source **directly into CPython's heap** with `Pointer.storeByte` on an +address `malloc` returned, called `PyRun_SimpleString` as a direct wasm import, then dereferenced +`PyUnicode_AsUTF8`'s `char*` to get the string back. No copying, no JS in either direction, and it +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 +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 +supported CPython build already exports the whole surface. + +What it does *not* do is let JavaScript reach them. `Module` carries 36 keys and none of the +interesting ones, so there is nothing to hand to `@WasmImport`. One flag fixes it: + +``` +-sEXPORTED_RUNTIME_METHODS=FS,callMain,ENV,HEAPU32,TTY,wasmExports,wasmMemory + ^^^^^^^^^^^^^^^^^^^^^^ +``` + +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 +`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. + +### Composition, measured against the interpreter + +Reading one global out of `__main__`, 200,000 times, on the same shared-memory direct-call setup. +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 | +| 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. +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 +`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. + +**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 +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. + +### What is still not answered + +Upcalls. `addFunction` re-entering WasmGC still goes through JS and is still unmeasured; §7's +shape has to settle before `wasmJsMain` is written. Nothing in Tests C or D touches it. + +--- + +## Building CPython 3.14.2 for Emscripten: what worked + +`/Volumes/macMini/wasm-build/build-cpython-emscripten.sh` reproduces it. The driver is CPython's +own `Tools/wasm/emscripten` (`build` runs configure-build-python → make-build-python → libffi → +mpdecimal → configure-host → make-host). It succeeded end to end on the first attempt against +Emscripten 5.0.3, and `python.sh --version` prints `Python 3.14.2`. + +``` +sysconfig.get_platform() emscripten-5.0.3-wasm32 +sysconfig.get_config_var('PYEMSCRIPTEN_PLATFORM_VERSION') None +Checked 114 modules (85 built-in, 6 shared, 17 n/a, 1 disabled, 5 missing, 0 failed on import) +python.wasm 10 MB python.mjs 570 KB +``` + +### What the stock build passes, and how far it is from `pyemscripten_2026_0` + +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** | + +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 +be built as ports or side modules, and something has to define `PYEMSCRIPTEN_PLATFORM_VERSION`. + +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 +`-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 +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 +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 + +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 +tested against an actual wheel. `/Volumes/macMini/wasm-build/build-cpython-abi.sh` reproduces +it, into `cpython314-abi/` so the stock build survives for comparison. + +**Result: `pydantic_core-2.48.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl`, downloaded from +PyPI, imports and runs.** + +``` +python 3.14.2 emscripten-5.0.3-wasm32 +IMPORTED pydantic_core 2.48.0 + .../pydantic_core/_pydantic_core.cpython-314-wasm32-emscripten.so +validate_python(42) -> 42 +raised ValidationError +``` + +The `.so` is 4.4 MB of Rust/PyO3. The `ValidationError` matters as much as the `42`: it is an +exception propagating out of the side module, so the unwinding ABI is exercised and not merely +linked. + +### Only one of the four divergences actually gated it + +| | 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 | +| lzma, zstd | still missing | no | +| OpenSSL | still missing | no | + +The negative control is exact. The same wheel on the stock build: + +``` +ImportError: could not load dynamic lib: .../_pydantic_core...so +LinkError: WebAssembly.Instance(): Import #204 "env" "__cpp_exception": + tag import requires a WebAssembly.Tag +``` + +Reading the binaries says why before running anything. The wheel's `.so` imports a **wasm tag**: + +``` +tag env.__cpp_exception <- the side module needs it +memory env.memory +table env.__indirect_function_table +``` + +and the two interpreters differ in exactly that: + +``` +cpython314-abi/python.wasm section id 13 (tag), exports __cpp_exception, __c_longjmp +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 +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. + +The build-driver trap recorded earlier is real and was avoided by repeating the whole string. +The result is visible in the generated Makefile: + +``` +CONFIGURE_CFLAGS= -DPY_CALL_TRAMPOLINE -sUSE_BZIP2 -fwasm-exceptions -sSUPPORT_LONGJMP=wasm +CONFIGURE_LDFLAGS= -fwasm-exceptions -sSUPPORT_LONGJMP=wasm +``` + +### `PYEMSCRIPTEN_PLATFORM_VERSION`, and a trap that silently produces a wrong tag + +`packaging` 26.3 implements PEP 783 as literally as the PEP describes: + +```python +def _emscripten_platforms(): + v = sysconfig.get_config_var("PYEMSCRIPTEN_PLATFORM_VERSION") + if v: + yield f"pyemscripten_{v}_wasm32" +``` + +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 +--generate-posix-vars` reads. **That silently produces the wrong tag.** `sysconfig` coerces any +value that parses as an integer, and Python accepts underscores inside integer literals: + +```python +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 +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 +targets for the same reason. Adding one name to it is the whole fix: + +```python +_ALWAYS_STR = { + 'IPHONEOS_DEPLOYMENT_TARGET', + 'MACOSX_DEPLOYMENT_TARGET', + 'PYEMSCRIPTEN_PLATFORM_VERSION', # int('2026_0') == 20260 +} +``` + +After which the interpreter's first tag is exactly the wheel's: + +``` +config var = '2026_0' +first tag = cp314-cp314-pyemscripten_2026_0_wasm32 +wheel tag = cp314-cp314-pyemscripten_2026_0_wasm32 MATCH +``` + +This is worth reporting upstream: CPython defines no way to set the variable, and the one +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 +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 +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 +likely worse than every other platform". Tests E and F measure it, and measure an alternative +this document does not consider. + +### There is no JS in the call path, because a funcref is a funcref + +`@WasmExport` puts a Kotlin function in the **wasm** export section with no type adapters, so +what JS receives is an exported wasm function, not a closure: + +``` +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. + +``` +table.grow(1) -> 2; table.set(2, kotlin_upcall_add) accepted +C: call_fp(2, 40, 2) -> 42 +``` + +Test E, 10,000,000 indirect calls issued from inside C so no JS is in the timed loop: + +| | ns/call | crossing | +|---|---|---| +| 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 +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. + +### Re-entrancy holds: the WasmGC heap is live inside a frame CPython pushed + +The routing-by-data design needs the upcall to find a Kotlin object from a context integer and +write into linear memory. Both work from inside a C-pushed frame: + +``` +Kotlin registry[7] = "routed-through-a-cpython-frame" +C: call_fp(route, ctx=7, out=0x10628) -> 30 +C: UTF8ToString(out) -> "routed-through-a-cpython-frame" +``` + +An unrouted context returns cleanly rather than trapping, and allocating on the WasmGC heap +inside the upcall is fine. + +**Signatures must match exactly.** `call_indirect` is statically typed and does not coerce: +calling a `(i32)->i32` export through a `(i32,i32)->i32` slot raises `RuntimeError: null +function or function signature mismatch`. That is a runtime trap, not a compile error, so the +Kotlin trampoline's arity is a correctness requirement rather than a style question. + +### Test F: real CPython calls Kotlin, through a real `PyMethodDef` + +A toy's `call_indirect` is not what CPython does. CPython is built `-DPY_CALL_TRAMPOLINE`, and +`pycore_emscripten_trampoline.h` routes every `PyCFunction`, `getset` getter and setter through +`_PyEM_TrampolineCall`, which dispatches on arity. So the mechanism has to survive that. + +It does. A Kotlin `@WasmExport` of shape `(PyObject*, PyObject*) -> PyObject*`, put in CPython's +own table and registered in a `PyMethodDef` that Kotlin builds in linear memory: + +``` +CPython's __indirect_function_table: Table, length 13518 +table.grow(1) -> 13518; table.set(13518, kotlin_pycfunction) accepted +Kotlin: installPyCallback("kotlin_cb", fp=13518) -> 0 + +Kotlin: pyExec("cb_result = kotlin_cb(41)") -> 0 +Kotlin-side upcall counter: 0 -> 1 +Kotlin: pyGlobalInt("cb_result") -> 42 +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 +upcall**, so both directions are live on one stack. + +### But CPython's own wasm trampoline never installs, and that is an upstream defect + +`Python/emscripten_trampoline.c` tries to replace its JS trampoline with a wasm one that +dispatches using `__builtin_wasm_test_function_pointer_signature` (a wasm-gc `ref.test`). A +four-argument Kotlin export matches none of the four shapes it tests, so it discriminates: + +* wasm trampoline → all four `ref.test`s fail → `SystemError: Handler takes too many arguments` +* JS fallback → `wasmTable.get(func)(arg1, arg2, arg3)` → called anyway, fourth argument zero + +``` +kotlin_cb_4(1) -> "returned 9906512" +``` + +It was called. **The JS fallback is live**, and instrumenting the swallowed `catch` says why: + +``` +[instr] fell back: LinkError: WebAssembly.Instance(): Import #0 "env" "memory": + memory import must be a WebAssembly.Memory object +[instr] wasmTable is undefined | wasmMemory is undefined +``` + +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 +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 +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, + +``` +[instr] fell back: LinkError: Import #1 "env" "__indirect_function_table": + table import requires a WebAssembly.Table +[instr] wasmTable undefined wasmMemory object +``` + +and Emscripten has no imported-table setting to finish the job. So on this toolchain pair the +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 +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** | +| | **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. + +### What this means for §7 + +| platform | how Python re-enters the host | +|---|---| +| 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 | + +**§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 +a boundary that is not in the path. Routing by data through a small set of static trampoline +shapes works unchanged; the context integer, the WasmGC lookup and the linear-memory write all +survive re-entry. + +Two things change in step 3 of the staged plan: + +* **`@WasmExport` and `WebAssembly.Table.set`, not `@JsExport` and `addFunction`.** Same number + of moving parts, 3.5x cheaper, and JS touched once per trampoline rather than once per call. +* **Prefer slots CPython calls directly.** Type slots installed through `PyType_FromSpec` are + invoked as ordinary `call_indirect`; `PyMethodDef` entries and getset descriptors go through + `_PyEM_TrampolineCall`. Only the first gets the ~3 ns figure while the upstream defect stands. + (The slot/trampoline split is read from `pycore_emscripten_trampoline.h`, which wraps exactly + `_PyCFunction_TrampolineCall`, `descr_get_trampoline_call` and `descr_set_trampoline_call`; + the ~3 ns figure for the direct path is Test E's measurement, not a measurement of a type slot.) + +### Still open + +Nothing structural. `wasmJsMain` can be written against §7 as amended. What is *not* answered: + +- Whether a Kotlin exception thrown inside an upcall unwinds safely through CPython's frames. + Every trampoline here returns normally or returns `NULL`. +- Reference-count ownership across the boundary, which is a contract question rather than a + 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 + `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 + removed from V8. See `src/wasmJsMain/README.md` and ROADMAP §10 for the numbers. + + +### Verification status of the wheel claim + +**Demonstrated, reproducibly, as Test G in `native/run.sh`.** The wheel is the real PyPI file -- +`pydantic_core-2.48.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl`, 1,403,190 bytes, sha256 +`4fc45a49334c54541cbc97bf416d9300b4b1d3b2840dfb079b1123dc3f9ef5a6`, matching +`https://pypi.org/pypi/pydantic-core/json` exactly. It contains a 4,408,639-byte +`_pydantic_core.cpython-314-wasm32-emscripten.so` (Rust/PyO3, the compiled extension). + +Against the ABI build's own `python.sh` (no Kotlin involved -- this is a CPython Emscripten +loader question, not a Kotlin one): + +``` +pydantic_core imported OK, version: 2.48.0 +validate_python("42") -> 42 +raised: ValidationError +``` + +Against the stock build, same wheel, same `python.sh` harness, as the negative control: + +``` +ImportError: could not load dynamic lib: .../_pydantic_core.cpython-314-wasm32-emscripten.so +LinkError: WebAssembly.Instance(): Import #204 "env" "__cpp_exception": tag import requires a WebAssembly.Tag +``` + +identical to what the original report quoted. Comparing the two `python.wasm` binaries directly: +the stock build has no tag section and no `__cpp_exception` symbol, the ABI build has two tags and +the symbol -- and that difference is exactly what separates the pass above from the failure. The +unwinding ABI (`-fwasm-exceptions -sSUPPORT_LONGJMP=wasm`) is both present only in the ABI build +and the thing that gates loading a compiled `pyemscripten_2026_0` wheel; PEP 783's premise that +matching the ABI unlocks compiled extensions holds up on this tree, for this wheel. + +Scope of what this settles: one wheel (pydantic-core), one path (import + a validator call + +exception raise). It does not check every C-API surface a compiled extension might touch, nor +whether other wheels on this tag rely on something the ABI build's flag set doesn't cover. diff --git a/docs/archive/wasm-youtrack-issue.md b/docs/archive/wasm-youtrack-issue.md new file mode 100644 index 000000000..ef7342c16 --- /dev/null +++ b/docs/archive/wasm-youtrack-issue.md @@ -0,0 +1,40 @@ +> **Superseded** by [`../platforms/wasm-design.md`](../platforms/wasm-design.md) on 2026-10-03; kept for history. +> **Do not file this.** Kotlin 2.4.20-Beta2 (the version the build pins, `gradle/libs.versions.toml`) +> makes a `wasmJs` module **import** `intrinsics.memory` instead of exporting an unbounded one, so the +> blocker below no longer exists: the build substitutes Emscripten's `wasmMemory` for that import +> (`patchKotlinWasmOutputForCPython` in `python-multiplatform/build.gradle.kts`) and no binary patching +> of a memory section is needed. The `Kotlin 2.0.20 / 2.4.10` behaviour described here is accurate for +> those versions only. + +# YouTrack Issue Draft: Kotlin/Wasm (wasmJs) emits unbounded memory, preventing Emscripten interoperability + +## Title +Wasm memory export has no maximum limit, breaking Emscripten `-sIMPORTED_MEMORY` compatibility + +## Description + +**Environment** +Kotlin version: 2.0.20 (and 2.4.10 tested) +Target: `wasmJs` + +**Problem** +When building a `wasmJs` module, the Kotlin compiler generates a linear memory and exports it (e.g., `(memory (export "memory") 0)`). However, the memory is emitted with `WasmLimits(0, null)` (no maximum limit). + +This becomes a critical blocker when attempting to interoperate with C/C++ code compiled by Emscripten. If we try to share the linear memory by having Emscripten import Kotlin's memory (using `-sIMPORTED_MEMORY`), Emscripten's instantiation fails with: + +``` +LinkError: memory import has no maximum limit, expected at most 4294967295 +``` + +WebAssembly requires that a supplied memory's limits fit within the importer's declared limits. Emscripten always declares a maximum limit for its memory (typically the Wasm32 ceiling of 4GB, or 65536 pages). Because Kotlin's memory is unbounded, it can never satisfy Emscripten's import requirements. + +**Why this is important** +We are embedding CPython (compiled to WebAssembly via Emscripten) inside a Kotlin Multiplatform library. To avoid expensive JavaScript trampoline calls and data copying across the JS boundary, we need both modules to share a single linear memory. + +Our testing shows that Kotlin's WasmGC `wasmJs` module does not actively use the linear memory for its own allocations (except if explicitly using `withScopedMemoryAllocator`), making it safe to export and hand over to Emscripten. However, this single missing `max` limit in the Wasm memory section prevents the instantiation graph from linking. + +**Workaround** +Currently, we must manually patch the emitted `.wasm` binary's memory section (Section 5) to include a maximum limit, e.g., `{min: 0, max: 32768}`. Once patched, the modules link perfectly, and bidirectional memory sharing works without issues. + +**Proposed Solution** +Please provide a way to specify the maximum memory pages for the emitted memory in `wasmJs`, or simply emit the Wasm32 ceiling (65536 pages) by default. For example, a compiler flag like `-Xwasm-memory-maximum=<pages>` would eliminate the need for brittle binary post-processing. diff --git a/docs/design/architecture.md b/docs/design/architecture.md new file mode 100644 index 000000000..db08d8c25 --- /dev/null +++ b/docs/design/architecture.md @@ -0,0 +1,280 @@ +# 아키텍처 + +전체 그림과 방향별 상세는 [`PROJECT.md`](../../PROJECT.md) 에서 시작한다. 이 문서는 소스셋 계층, +타입 래퍼 구조, 계층 규칙, 테스트 계층을 다룬다. 의도·사양의 기준은 [`INTENT.md`](../INTENT.md), +[`SPEC.md`](../SPEC.md) 이고, 이 문서는 설계 기록이다. + +## 소스셋 계층 + +`python-multiplatform/build.gradle.kts` 의 `sourceSets` 블록에서 **수동으로** 구성한다. 기본 계층 +템플릿은 적용되지 않는다 (빌드 로그에 그 경고가 뜨는 이유다). + +``` +commonMain + ├── jvmMain 중간 소스셋 + │ ├── androidMain ART + │ └── desktopMain HotSpot + ├── nativeMain + │ ├── iosMain + │ └── artMain androidNativeArm64 / androidNativeX64 + └── wasmJsMain wasm32-emscripten (ROADMAP §10) +``` + +`wasmJsMain` 은 **중간 소스셋 없이 `commonMain` 바로 아래에 둔다.** 공유할 상대가 없고(JNI·Panama· +cinterop 어느 쪽과도 겹치지 않는다), 바로 아래에 적힌 `expect inline fun` 제약을 피하기 위해서이기도 +하다. `EmbedAPI.kt` 의 `expect inline fun`(현재 131개)이 그대로 `actual inline` 이 된다. + +`jvmMain` 은 정식 중간 소스셋이므로 **여기에 `expect` 를 두고 두 리프에서 `actual` 을 제공하는 것이 +가능하다.** 실험으로 확인했다. + +단, **`expect inline fun` 과 조합하면 Kotlin 2.0.20 에서 `Internal error in file lowering` 컴파일러 +크래시가 난다.** 마이그레이션 대상 함수에서 `inline` 을 떼면 해결된다. `EmbedAPI.kt` 의 `inline` 중 +다수는 컴파일러가 이미 "인라인 이득 없음" 경고를 내고 있어, 떼는 데 실질적 손해가 없다. (크래시는 +Kotlin 2.0.20 에서 관찰됐다. 빌드는 이후 Kotlin 2.4.20-Beta2 로 올라갔고(`gradle/libs.versions.toml`), +이 크래시를 그 버전에서 다시 확인하지는 않았다 — 그래서 AGENTS.md §16 은 여전히 JVM 계열 통합 전에 +확인하라고 한다.) + +## FFI 계층 + +``` +commonMain/kotlin/python/native/ffi/EmbedAPI.kt expect 약 314개 (그중 `inline` 131개) + ├── nativeMain/.../EmbedAPI.native.kt cinterop (iOS, androidNative) + ├── androidMain/.../EmbedAPI.android.kt + bindings.kt JNI + ├── desktopMain/.../EmbedAPI.desktop.kt + bindings.kt + Panama.kt Panama + └── wasmJsMain/.../EmbedAPI.wasmJs.kt + bindings.kt + Wasm.kt @WasmImport +``` + +`wasmJsMain` 의 `bindings.kt` 는 `object` 가 아니라 **`python.native.ffi.bindings` 패키지**다. +`@WasmImport` 는 최상위 `external fun` 에만 붙기 때문이다. 호출부 표기(`python.native.ffi.bindings.X`)는 +desktop·native 와 동일하다. + +포인터는 `NativePointer` (value class, `address: Any`) 로 통일한다. 플랫폼별 실체는 `Long`(JVM), +`CPointer<*>`(Native), `Int`(wasm32) 다. 데스크톱 FFM 은 포인터를 `ADDRESS` 가 아니라 `JAVA_LONG` 으로 +넘기고 `invokeExact` 만 쓴다 (`desktopMain/README.md`). + +`Any` 래핑 때문에 변환마다 박싱이 일어난다. `@HighOverheadNativeCall` 애노테이션이 비싼 변환 지점을 +표시한다. + +### CPython C 매크로는 바인딩할 수 없다 + +`PyLong_Check` 계열은 헤더의 `#define` 이라 심볼로 export 되지 않는다. `nm` 으로 확인했다. 그래서 +`ffi/PyTypeChecks.kt` 가 common Kotlin 으로 11종을 구현한다 — `PyObject_IsInstance` 와 캐시된 타입 +객체를 쓰며, `expect`/`actual` 분기가 없으므로 네 플랫폼 동작이 구조적으로 동일하다. + +## 타입 래퍼 계층 + +`commonMain/kotlin/python/multiplatform/ffi/` 아래. 설계 밑그림은 저장소 루트의 +`python_for_kotlin_binding.mermaid` 이지만 **확정 스펙이 아니다.** + +``` +PyObject 모든 래퍼의 기반. NativePointer 보유, PyAutoCloseable 상속 + ├── PyType 인스턴스 캐시 보유 + ├── PyException CPython 에러 인디케이터 ↔ Kotlin throwable (exceptions/) + ├── 기본 타입 PyInt PyFloat PyBool PyString PyBytes PyNone PyComplex (types/basic) + ├── 컬렉션 PyList PyTuple PyDict PySet PyFrozenSet (types/collections) + ├── PyIterator / PyIterable, 제너레이터·코루틴 (types/iteration) + ├── PyModule / PyPackage (types/modules) + └── 호출 가능·코드·디스크립터·유틸리티 (PyFunction, PyMethod, PyCode, PyProperty, PyRange, PyWeakRef ...) +conversion/ Context, ConversionStrategy, PyProxy, PyValue +``` + +### mermaid 와 어긋난 곳 + +Kotlin 제약 때문에 다이어그램대로 갈 수 없던 지점들: + +- **`PyBool` 은 `PyInt` 를 상속하지 않는다.** 같은 제네릭 인터페이스(`PyProxy<T>`)를 서로 다른 타입 + 인자로 두 번 구현할 수 없다. +- **`invoke` 오버로드 다발이 vararg 하나로 합쳐졌다.** Kotlin vararg 가 이미 그 역할을 한다. +- **`RAW` 와 `UNMANAGED` 를 분리했다.** mermaid 는 `naiveConvert` 하나가 둘을 담당하지만 그러면 두 + enum 값이 행동상 구분되지 않는다. 원저자가 `Strategy.kt` 에 남긴 설계 노트가 근거다. +- `toList()`/`toMap()` 은 `toNativeList()`/`toNativeMap()` 으로 이름을 바꿨다. Kotlin 표준 확장을 + 가린다. + +### JVM 시그니처 충돌 주의 + +`PyObject` 의 타입 프로퍼티는 `Type` (대문자 T, `val Type: PyType`) 이다. 소문자 `type` 프로퍼티와 +별도의 `getType()` 메서드를 함께 두면 JVM 에서 접근자 `getType()` 이 **같은 시그니처로 충돌**하고, 모든 서브클래스가 Android·Desktop 에서 +컴파일에 실패한다. Kotlin/Native 에는 그 규칙이 없어 JVM 타깃을 실제로 빌드하기 전까지 드러나지 +않았다. + +## 검증 수단 + +명령 목록과 어떤 소스셋을 덮는지는 AGENTS.md §15 가 기준이다 (`compileKotlinAndroidNativeArm64`, +`compileKotlinIosSimulatorArm64`, `compileKotlinDesktop`, 그리고 `ksp-fixtures` 세 모듈을 각각 따로). +테스트는 컴파일 검증만이 아니다 — 데스크톱(`desktopTest`), iOS 시뮬레이터, 안드로이드 기기, wasm(Node) +에서 실제 CPython 을 구동하는 스위트가 있다 (SPEC §0 의 "Test path" 열). + +`Python.framework` 에는 표준 라이브러리가 없어 `Py_Initialize()` 가 +`Failed to import encodings module` 로 프로세스를 죽인다. 빌드가 배포 아카이브에서 stdlib 를 풀어 +`PYTHONHOME` 을 잡아준다 (`extractIosSimulatorStdlib`). simctl 은 `SIMCTL_CHILD_` 접두사가 붙은 +환경변수만 자식 프로세스로 전달하므로 그 이름으로도 함께 내보낸다. + +## Layering and what may reference what + +Four layers, and the arrows only point downward. This is a hard rule, not a preference. + +``` +python.multiplatform.ffi PyObject, PyType, collections, conversion -- the object model + | may call + v +python.native.ffi EmbedAPI: expect/actual over the C API + | may call (platform implementations only) + v +python.native.ffi.bindings JNI externals (Android) / Panama handles (desktop) + | + v +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.<platform>.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: + +```kotlin +// commonMain/ffi/PyObject.kt +import python.native.ffi.PyObject_GetAttrString + +fun getAttr(name: String): PyObject { ... PyObject_GetAttrString(pointer, name) ... } +``` + +and this is not, even though it compiles on the JVM targets: + +```kotlin +// WRONG -- the object model reaching into a platform-internal surface +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 +the object model: + +```kotlin +// commonMain/native/ffi/EmbedAPI.kt +expect fun PyObject_GetAttrComposed(o: NativePointer, name: String): NativePointer? + +// androidMain -- composed: one crossing +actual fun PyObject_GetAttrComposed(o: NativePointer, name: String) = + bindings.asmGetAttr(o.toPlatformPointer(), name).toNativePointer() + +// nativeMain -- already in-process, nothing to compose +actual fun PyObject_GetAttrComposed(o: NativePointer, name: String) = + PyObject_GetAttrString(o, name) +``` + +The object model then stays common and stays within its layer. Composition does not require +reopening `PyObject` as `expect`/`actual`. + +## What the PyAutoCloseable split actually changed + +Commit `0fae961a` (2025-12-20) has been described here as de-duplicating `PyObject`, and as +extracting lifetime management out of it. Both are wrong. + +There was no duplication: every `actual` was a placeholder. The whole desktop implementation +was one line, the Android one carried a single commented `//actual external fun incRef()`, and +the real logic sat in a **comment block** in `commonMain` above the note *"Temporary commented +due to the error: Expected declaration cannot have a body. TODO: Move this to the actual +implementation."* + +And lifetime management was already extracted. `PyObjectAutoCloseable` in `jvmMain` held the +`Cleaner`; `PyObject` never owned it. + +What the commit actually did: + +**1. Hoisted the base class from `jvmMain` into `commonMain` as `expect`/`actual`.** + +```kotlin +// before -- a plain class in jvmMain, shared by android and desktop +abstract class PyObjectAutoCloseable(open val pointer: NativePointer, borrowed: Boolean): AutoCloseable + +// after -- an expect in commonMain, with a leaf actual per platform +expect abstract class PyAutoCloseable(pointer: NativePointer) { + abstract fun clean() +} +``` + +> 이 스니펫은 그 커밋 시점의 모양이다. 이후 `PyAutoCloseable` 은 `expect` 가 아닌 **commonMain 의 +> 일반 추상 클래스**가 되었고(`python/multiplatform/ref/PyAutoCloseable.kt`), 플랫폼 차이는 +> `expect interface PlatformCleaner` + `expect fun registerCleaner(pointer, closeAction)` 로 옮겨졌다. +> 아래 표가 현재의 플랫폼별 메커니즘이다. + +**2. Gave it an abstract `clean()`, so the cleanup action comes from the subclass.** Before, +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 +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 +`PyAutoCloseable.android.kt` branches on `SDK_INT` and falls back to `PhantomReference` + +`ReferenceQueue`. + +**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. + +| platform | lifetime mechanism after the split | +|---|---| +| Kotlin/Native (iOS, androidNative) | `kotlin.native.ref.createCleaner` (`close()` 도 가능, 한 번만 실행) | +| Android API 33+ | `java.lang.ref.Cleaner` | +| Android API 26-32 (minSdk 26) | `PhantomReference` + `ReferenceQueue` + daemon thread | +| Desktop | `java.lang.ref.Cleaner` | +| wasmJs | JS 쪽 약한 참조 기반 레지스트리 (`PyAutoCloseable.wasmJs.kt`) | + +(minSdk 가 24 였던 시절의 서술이다. 현재 minSdk 는 26 이고 API 26-32 가 `PhantomReference` 경로를 쓴다.) + +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 +`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 +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 +of the object model's life. + +**Low-level** tests call EmbedAPI functions one at a time: does this function return what its C +counterpart returns, does it report failure the documented way, does it leave the reference +count where it found it. `commonTest/.../python/native/ffi/EmbedApiLowLevelTest.kt` and, on +Android, everything under `androidInstrumentedTest` that reaches through `bindings`. + +**Assembled** tests call `Python3` and the object model as an application would. +`commonTest/.../python/multiplatform/ffi/**` and +`androidInstrumentedTest/.../assembled/AssembledApiTest.kt`. + +### 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 — +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. + +Fourteen green Android tests said nothing about that. A test that does not walk the path +callers walk proves only that some other path works. + +The Android assembled tests were `@Ignore`d until ROADMAP §2 landed, because they crashed the +instrumentation process rather than failing, and a crashed runner takes every other test with +it. They were the acceptance check for that work; §2 is closed (213 tests per emulator, zero +failures, per ROADMAP §2) and `AssembledApiTest.kt` no longer carries an `@Ignore` on a test. + +### Two things to know when adding tests here + +**The interpreter is process-wide; no single test owns its lifecycle.** `DesktopPythonTest` +used to call `Py_Initialize()` and `Py_Finalize()` itself. Every class scheduled after it then +died inside `PyGILState_Ensure` → `new_threadstate`, reporting *zero* tests because the JVM +went down before any XML was written. It stayed invisible while nothing happened to run +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/<target>/` when a +count needs to be trusted. diff --git a/docs/design/binding-policy.md b/docs/design/binding-policy.md new file mode 100644 index 000000000..9603149ec --- /dev/null +++ b/docs/design/binding-policy.md @@ -0,0 +1,109 @@ +# 노출 정책 — 무엇이 Python 에서 보이는가 + +Kotlin 의 무엇을 함수 테이블에 등록해 Python 에 노출할지에 대한 규칙. 테이블 자체의 구조는 +[`upcall.md`](upcall.md). + +이 문서의 표는 **KSP 프로세서가 소스에서 읽는 선언**(`python-multiplatform-ksp` 의 `BindingPolicy.kt`)에 +대한 규칙이다. 미리 컴파일된 jar 를 읽는 아티팩트 워커(`python-multiplatform-gradle-plugin`)는 +같은 블랙리스트 정신을 따르되, 소스 없이 바이트코드/Kotlin 메타데이터에서 읽으므로 일부 행이 +다르다 — 아래 해당 행과 §"결정 이력" 에 표시했다. Kotlin 이름은 어느 쪽이든 그대로 노출되며 +(AGENTS.md §12.1) 이 정책에는 이름을 바꾸는 규칙이 없다. + +## 원칙: 블랙리스트 + +**모든 `public` 선언이 자동으로 등록된다. 빼고 싶은 것에 애노테이션을 붙인다.** + +화이트리스트(`@PythonAPI` 를 붙인 것만 노출)는 **기각됐다.** 노출하려는 모든 멤버에 애노테이션을 +다는 것은 지나치게 번거롭고, 라이브러리 작성자가 붙이기를 잊으면 조용히 안 보이게 된다. + +```kotlin +class MyClass { + fun doSomething(arg: String): String = "..." // 자동 노출 + + @PythonInternal + fun internalHelper() { } // 제외 + + private fun privateMethod() { } // 애초에 대상 아님 +} + +@PythonInternal +class InternalUtility { } // 클래스 통째로 제외 +``` + +애노테이션 이름은 **`@PythonInternal`** 로 한다. Kotlin 의 `internal` 과 의미가 자연스럽게 이어진다. +(`@ExcludeFromPython` 도 후보였으나 길다.) + +## 등록 대상 + +| 대상 | 등록 여부 | 근거 | +|---|---|---| +| `public` 함수 | ✅ | 기본 원칙 | +| `public` 클래스 | ✅ | | +| **생성자** | ✅ | Python 에서 인스턴스를 만들어야 한다 | +| **프로퍼티** | ✅ getter. setter 는 `var` 만 | | +| **companion object 멤버** | ✅ 클래스의 정적 메서드처럼 노출 | companion 객체 자체를 따로 노출할 필요는 없다 | +| **`object` 선언** | ✅ companion 과 같은 모양 | 인스턴스가 하나뿐이므로 수신자가 없다. 생성자는 없다 | +| **`interface`** | ✅ 멤버만 | 인스턴스는 없지만, 핸들로 건너온 Kotlin 객체를 인터페이스를 통해 호출할 수 있다. 생성자 없음 | +| **`enum class`** | ✅ 엔트리마다 정적 접근자 + `name`/`ordinal`/`valueOf` | 엔트리 이름은 `ReflectedClass.enumEntryNames` 로 함께 나간다 — Python 쪽 `enum.Enum` 미러의 재료 | +| **중첩 선언** | ✅ `Outer.Inner` 이름으로 | 노출되지 않는 바깥 선언에서 탐색이 멈춘다 | +| **`annotation class`** | ❌ | 애노테이션을 읽으려면 런타임 리플렉션이 필요하고, Python 이 만든 인스턴스는 붙일 곳이 없다 | +| **enum 의 `values()` / `entries`** | ❌ | 컬렉션을 돌려주는데 경계에 컬렉션 마샬링이 없다 | +| **제네릭 선언** (`class Box<T>`, `fun <T> f`) | ❌ | 생성된 캐스트가 이름 붙일 타입이 없다: `args[0] as T` 는 unresolved reference | +| **abstract / sealed 클래스의 생성자** | ❌ | "Cannot create an instance of an abstract class" — 생성된 파일이 컴파일되지 않는다 | +| `private` / `protected` | ❌ | | +| **`internal`** | ❌ | JVM 바이트코드에서는 name-mangling 된 public 이지만 의미론적으로는 비공개다. KSP 의 `Modifier.INTERNAL` 로 걸러낸다 | +| **확장 함수 (KSP, 소스)** | ❌ | 생성되는 엔트리는 `Array<Any?>` 위의 람다이고 수신자를 둘 자리가 없다 (`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) | + +## 라이브러리 자신은 통째로 제외한다 + +`python.native.ffi`, `python.multiplatform` 등 **이 라이브러리 자체 패키지는 KSP 설정의 exclude +목록으로 통째 제외**한다. + +`PyObject`, `PyType`, `Python3`, 330개 FFI 바인딩이 전부 테이블에 올라가면 Python 에서 다시 부를 +이유가 없는 것들로 테이블만 부푼다. 내부 클래스마다 `@PythonInternal` 을 붙이는 것은 비현실적이다. + +## Python 으로 매핑할 수 없는 것들 + +| 대상 | 처리 | 근거 | +|---|---|---| +| **`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 에서 선언 수준으로 판별되므로 걸러내는 비용이 낮다. + +## 결정 이력 + +블랙리스트 원칙은 설계 논의에서 직접 확정됐다. 세부 규칙(가시성, 확장 함수, 매핑 불가 타입, 애노테이션 +이름)은 그 논의에서 다루지 않았고, 이후 판단으로 정한 것이다 — 구현하면서 뒤집힐 수 있다. + +특히 다음 둘은 재고 여지가 있다: + +- **확장 함수 제외 (KSP)** — 소스 경로에서는 제외 상태 그대로다. 다만 이것이 풀린 것은 아티팩트 + 워커 쪽이다: 확장 함수는 수신자 프록시의 메서드로 노출된다(SPEC U-7). KSP 가 생성한 프록시에서의 + 동작은 아직 테스트로 단언되지 않았다. +- **값 클래스** — 초안의 "등록하고 실패하게 둔다"는 구현되지 않았다. 워커가 값 클래스를 다루고 + (SPEC B-4), KSP 는 따로 걸러내지 않는다. + +## 구현 시점의 정정 (2026-08) + +위 표 중 두 줄은 구현하면서 넓어졌고, 그 근거는 **생성된 파일이 컴파일되지 않은 것**이다 — +프로세서가 스스로 알아챌 수 있는 종류의 오류가 아니었다. + +- **제네릭은 `reified` 뿐 아니라 전부 제외한다.** 엔트리는 `Array<Any?>` 위의 람다이므로 모든 + 파라미터가 소스에 적을 수 있는 타입으로 캐스트되어야 한다. 타입 파라미터에는 그런 표기가 없다. + `Box<*>` 로 지우는 방안은, 수신자가 `T` 를 받는 멤버를 만족시키지 못해 기각했다. +- **파라미터 타입의 타입 인자는 그대로 렌더링한다.** `args[0] as kotlin.collections.List` 는 + "One type argument expected" 이다. 생성 파일 머리에 `@file:Suppress("UNCHECKED_CAST")` 가 + 붙는 이유이기도 하다. +- **컴파일러 생성 멤버는 `copy` 와 `componentN` 이 실제로 KSP 에 보인다.** `equals`/`hashCode`/ + `toString` 은 보이지 않았다. 관찰된 사실이며, 둘 다 제외한다. +- **`suspend` 는 제외 대상에서 빠졌다.** 초안은 "숨은 `Continuation` 인자 때문에 호출이 불가능하다"며 + 자동 제외했다. 비동기 업콜(`PendingCall`)이 생긴 뒤 이 수식어는 제외가 아니라 **본문 형태를 + 고르는 신호**가 되었다 (`BindingPolicy.isSuspending`, `GeneratedSuspendTest`). +- **추상·봉인 클래스는 클래스로는 노출하되 생성자만 뺀다** (`BindingPolicy.isConstructible`; `inner` + 클래스도 같다 — 바깥 인스턴스를 둘 자리가 없다). 표의 "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 new file mode 100644 index 000000000..fce470e97 --- /dev/null +++ b/docs/design/downcall-design.md @@ -0,0 +1,763 @@ +# 다운콜 설계 — Kotlin → Python + +Kotlin 코드가 CPython Stable ABI 함수를 호출하는 방향. 반대 방향은 +[`upcall.md`](upcall.md). + +> **현재 상태 (2026-10-03 기준, 코드로 확인).** 플랫폼별 다운콜 경로는 각 `EmbedAPI.<platform>.kt` + +> `bindings.kt` 가 직접 구현한다: 데스크톱은 Panama `MethodHandle` + `invokeExact` (포인터는 +> `JAVA_LONG`), Android 는 `RegisterNatives` 로 등록한 JNI (함수별·API 레벨별 호출 규약), Native 는 +> cinterop. 아래 "shape 트램폴린" 14종은 **어휘(vocabulary)로 구현되어 있으나**(`jvmMain/.../ShapeDowncalls.kt` +> 의 `expect` + 데스크톱·Android·Native 구현) 330개 `actual` 을 `jvmMain` 한 벌로 합치는 통합에는 +> **쓰이지 않았다** — 소스에서 shape 함수를 부르는 곳은 데스크톱 `ProxyTypeFactory` 의 `tp_traverse`/ +> `tp_free` 호출 두 곳뿐이다. 이 문서의 shape 설계 절은 그 계획과 근거의 기록이다. + +## 시그니처 shape 조사 결과 + +`EmbedAPI.kt` 의 약 330개 `expect` 선언을 전수 분석한 결과다 (조사 시점의 수. 현재 `expect` 선언은 314줄로 센다). + +| 항목 | 결과 | +|---|---| +| 전체 함수 | 330 | +| **구분되는 ABI shape** | **14** | +| 최대 인자 수 | 6 (`PyErr_WarnExplicit`) | +| 부동소수점 관련 함수 | 6개뿐 | +| 정수/부동소수 **혼합** 시그니처 | **0** | +| 가변인자 | **0** | +| 구조체 값 전달 | **0** | + +부동소수점을 쓰는 함수는 `PyLong_FromDouble`, `PyLong_AsDouble`, `PyFloat_FromDouble`, +`PyFloat_AsDouble`, `PyFloat_GetMax`, `PyFloat_GetMin` 여섯 개가 전부다. + +shape 를 가르는 기준은 arm64(AAPCS64)와 x86-64(SysV) 모두에서 같다 — **정수·포인터가 한 레지스터 +뱅크, 부동소수점이 다른 뱅크**를 쓰므로, (정수형 인자 수, 부동형 인자 수, 반환 종류) 가 shape 다. +혼합 시그니처가 0개라 트램폴린의 C 프로토타입이 단순해진다. + +**결론: 14개 트램폴린이면 CPython Stable ABI 전체를 덮는다.** + +## 플랫폼별 메커니즘 + +### iOS / androidNative — cinterop + +Kotlin/Native 가 헤더를 읽어 직접 호출 코드를 낸다. **오버헤드가 없고 손댈 것도 없다.** 이미 최적이다. + +### Desktop — Panama + +`desktopMain/.../Panama.kt` 가 `java.lang.foreign`(JDK 22+, 19~21 은 preview)과 +`jdk.incubator.foreign`(JDK 16~18)을 **둘 다 리플렉션으로만** 접근한다. 어느 API 도 직접 import 하지 +않으므로 **어떤 JDK 에서도 컴파일**되고, 런타임에 백엔드를 고른다. + +리플렉션은 초기화 때 한 번만 돌고 이후는 캐시된 `MethodHandle` 을 쓴다. + +**성능 문제는 해결되었다.** 초기에는 호출부 305곳이 `MethodHandle.invoke(...) as Long` 이었고 호출마다 +`asType` 변환과 박싱이 일어나 `PyList_Size` 한 번이 약 1016 ns 였다. 포인터를 `ADDRESS` 대신 +`ValueLayout.JAVA_LONG` 으로 기술하고 호출부를 `invokeExact` 로 바꿔 **2.65 ns** 가 되었다 (아래 +"Fixed: 1015.95 ns -> 2.65 ns"). 현재 `desktopMain/.../bindings.kt` 에는 `invokeExact` 315곳이 있고 +`.invoke(` 는 0곳이다 (AGENTS.md §16: 무조건 `invokeExact`). 리플렉션은 핸들을 **만들 때** 심볼당 한 번만 +돈다. `Linker.Option.critical()` 은 쓰지 않는다 (`downcallHandle(..., emptyOptions)`). + +### Android(ART) — shape 트램폴린 (구현됨, 단 주 경로는 아니다) + +ART 는 런타임에 임의 시그니처의 네이티브 호출을 만들 수 없다. 그래서 shape 마다 트램폴린을 **미리 +컴파일**해두고, 대상 함수 포인터를 첫 인자로 받는다: + +```kotlin +// Kotlin/Native 로 컴파일해 .so 에 넣는다 (NDK 불필요) +@CName("...downcallJ_J") +fun downcallJ_J(fn: Long, a0: Long): Long = + fn.toCPointer<CFunction<(Long) -> Long>>()!!.invoke(a0) +``` + +주소는 런타임에 무엇이든 될 수 있고, **고정되는 것은 시그니처 형태뿐**이다. + +도달 방법은 두 단계로 계획했다: + +| 단계 | 경로 | 상태 | +|---|---|---| +| 1 | 일반 JNI → 트램폴린 → 간접 호출 | **구현됨.** `androidMain/.../bindings.kt` 의 `downcall*` `external fun` 14개가 `jni_onload.def` 에서 등록되고, Kotlin/Native 트램폴린(`nativeMain/.../EmbedAPI.native.kt`)이 함수 포인터를 직접 호출한다. `ShapeDowncalls.android.kt` 가 감싼다 | +| 2 | ART 엔트리 포인트 패치 + `@CriticalNative` → 트램폴린 | **하지 않았다.** `@CriticalNative` 는 API 34+ 에서 오히려 비싸다고 실측됐고(아래), 호출 규약은 함수별로 고르는 것으로 정리됐다 | + +실제 Android 바인딩은 shape 를 거치지 않고 **함수마다** `RegisterNatives` 로 등록한 JNI 를 쓴다 +(`androidMain/README.md`). shape 트램폴린은 `ffiSymbol` 로 얻은 임의 주소를 부르는 일반 수단으로 +남아 있다. + +**LLVM JIT 은 필요 없다 — 다만 근거는 아래와 같다.** + +ART 에는 FFM 을 위한 VM 지원이 없다. HotSpot 은 `downcallHandle` 호출 시 VM 이 스텁을 기계어로 +생성해 주지만, ART 는 해 주지 않는다. 그래서 임의의 `FunctionDescriptor` 에 대해 인자를 레지스터에 +배치하고 대상을 호출하는 기계어를 **누군가는 만들어야 한다.** PanamaPort 는 그것을 런타임에 +`libLLVM.so` 로 만든다 — `_AndroidLinkerImpl.generateNativeDowncallStub`(292행)이 다운콜 경로에서 +**조건 없이** 호출되며(938행), 업콜도 `generateNativeUpcallStub`(954행)으로 같다. + +주목할 점은 그 생성 스텁도 **대상 함수 포인터를 첫 인자로 받는다**는 것이다: + +```java +// _AndroidLinkerImpl.java:935 +stub_descriptor = stub_descriptor.insertArgumentLayouts(0, WORD); // leading function pointer +``` + +우리 `downcallII_I(fn, a0, a1)` 과 같은 구조다. 차이는 **생성 시점**뿐이다 (PanamaPort 는 의존성으로 쓰지 +않는다 — AGENTS.md §12.9. 아래는 설계 근거로 읽은 분석이다; [`android-ffm-design.md`](../archive/android-ffm-design.md)): + +| | PanamaPort | 이 프로젝트 | +|---|---|---| +| 스텁 생성 | 런타임 (LLVM JIT) | **빌드 타임 (미리 컴파일)** | +| 커버 범위 | 임의 시그니처 | shape 14종 | +| `libLLVM.so` 의존 | 필요 | 불필요 | + +즉 LLVM 이 필요 없는 이유는 "참조 구현도 안 쓰기 때문"이 아니라 **"시그니처를 14종으로 열거해 빌드 +타임에 만들어 두기 때문"**이다. + +(이전 판에서 `BulkLinker.requireNativeStub` 이 `false` 를 반환하므로 참조 구현도 스텁 없이 직접 +패치한다고 서술했으나 이는 오독이었다. `BulkLinker` 는 `Unsafe` 모듈에 있는 PanamaPort 자체 +부트스트랩 수단이며 — LLVM API 같은 내부 네이티브 함수를 바인딩한다 — `Core` 의 Linker 경로에서는 +쓰이지 않는다.) + +## JVM 통합 — Desktop + Android 를 한 벌로 (계획; 미수행) + +계획은 이랬다. 당시 JVM 쪽은 330개 `actual` 이 **두 벌** 있고 플랫폼별 `bindings` 선언까지 더하면 손으로 +유지하는 선언이 1,300개에 가까웠다. shape 를 도입하면 플랫폼별 표면이 14개로 줄고 330개 `actual` 은 +`jvmMain` 에 한 벌만 남는다: + +``` +commonMain expect fun PyList_Size(list: NativePointer): Long + +jvmMain actual fun PyList_Size(list) = downcallJ_J(sym.PyList_Size, list.toRawValue()) + internal expect fun downcallJ_J(fn: Long, a0: Long): Long ← shape 14개 + ├── desktopMain internal actual → 캐시된 Panama 핸들 + └── androidMain internal actual → shape 트램폴린 +``` + +**인터페이스나 추상 클래스를 쓰지 않는다.** `expect`/`actual` 이 같은 일을 하는 언어 기능이고, +인터페이스를 두면 호출마다 가상 디스패치가 붙는다. + +실험으로 확인한 절차(함수 하나 `PyList_Size` 로 4개 타깃 컴파일): commonMain `expect` 에서 `inline` +제거 → 플랫폼 `actual` 삭제 → `jvmMain` 에 `actual` 하나 → shape 함수를 `internal expect`. + +**현재 상태: 이 통합은 진행되지 않았다.** `jvmMain` 에는 shape 어휘(`ShapeDowncalls.kt`)와 문자열 +마샬링 `expect`(`internedUtf8` 등)만 있고, 330개 `actual` 은 여전히 `desktopMain` 과 `androidMain` 에 각각 +있다. 이유는 아래 실측들이다: 데스크톱은 `invokeExact` 로 2.65 ns 에 도달해 shape 가 필요 없었고, Android 는 +함수별 호출 규약 선택이 shape 단일화보다 비용을 크게 좌우했다. `expect inline fun` + 중간 소스셋 조합의 +컴파일러 크래시 제약(AGENTS.md §16)도 그대로 남아 있다. + +## GraalVM Native Image + +Native Image 는 **FFM 다운콜을 빌드 타임에 등록**해야 한다. `reflect-config.json` 으로는 안 되고, +`RuntimeForeignAccess.registerForDowncall(descriptor, options)` 를 쓰는 등록(여기서는 reachability +메타데이터)이 필요하며, `-H:+ForeignAPISupport` 로 활성화하는 **실험적** 기능이다. 데스크톱이 연결하는 +모든 `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 { }` +(`python/multiplatform/ffi/GILScope.kt`). [`threading-and-abi.md`](threading-and-abi.md) 를 본다. + +--- + +## 개정: Android 는 shape 트램폴린만으로 풀리지 않는다 + +위의 shape 설계는 **Desktop 에는 유효하지만 Android 에는 그대로 적용되지 않는다.** 설계 논의에서 +드러난 사실과, 그 결과 실제로 택한 방식을 기록한다. + +### 과거의 JNI 배선 버그 (수정됨) + +초기에는 `@CName` 으로 만든 C 함수(`JNIEnv`/`jclass` 를 받지 않음)를 일반 JNI 메서드처럼 이름으로 +연결해서, ART 가 넘기는 `JNIEnv*`·`jclass` 때문에 **인자가 두 칸 밀렸다.** 지금은 `JNI_OnLoad` +(`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). + +**호출 규약은 per-API-level 이면서 per-function 이다.** 전환 비용은 아래 "Measured" 절의 실측을 따른다 +(API 34 를 경계로 `@CriticalNative` 와 `@FastNative` 가 뒤집힌다). `@CriticalNative` 는 `RegisterNatives` +로만 등록하고, 정적·원시 타입만, 그리고 JVM 으로 되돌아올 수 없는 함수에만 쓴다. + +### critical 계열은 업콜과 공존할 수 없다 + +`Linker.Option.critical()` 과 `@CriticalNative` 는 제약이 같다. 짧고 블로킹하지 않아야 하며, +**콜백으로 JVM 에 되돌아오면 안 된다.** 위반은 정의되지 않은 동작이고 런타임이 보호해 주지 않는다. + +이 프로젝트는 **업콜이 핵심 요구사항**이므로 이 제약이 결정적이다: + +| 함수 | critical | +|---|---| +| `PyList_Size`, `Py_IncRef`, `PyLong_FromLongLong` | 적용 가능 | +| `PyObject_Call`, `PyRun_String`, `PyImport_ImportModule` | **불가** — Python 코드를 실행하며 Kotlin 으로 되돌아올 수 있다 | + +가장 뜨거운 호출들이 정확히 critical 을 못 쓰는 쪽이다. 그리고 **shape 는 이를 구분하지 못한다** — +`downcallII_I` 하나가 `PyList_GetItem`(가능)과 `PyObject_Call`(불가)을 함께 덮는다. 시그니처가 +같아도 성격이 반대다. + +### Desktop Panama 와 Android Panama 는 성능이 다르다 + +Desktop 과 Android 를 같은 방식으로 통일하려고 Android 에 FFM 을 얹어도, **API 형태만 통일되고 성능 +특성은 통일되지 않는다.** + +| | Desktop (HotSpot) | Android (PanamaPort 류) | +|---|---|---| +| 스텁 생성 | **VM 이 기계어로 생성** | ART 메서드 엔트리 포인트를 패치 | +| JIT 가시성 | `MethodHandle` 을 C2 가 인라인·특수화 | ART JIT 는 그만큼 못 함 | +| 중간 계층 | 없음 | `MethodHandle` + 자체 변환 계층 | +| 인자 마샬링 | VM 생성 코드 안 | Java/Kotlin 코드 | +| 전환 생략 | `Linker.Option.critical()` | `@CriticalNative` (대응됨) | + +**전환 비용 자체는 대응되지만, 그 위 계층에서 갈린다.** Desktop 의 Panama 는 런타임의 일부이고, +Android 의 Panama 는 런타임인 척하는 라이브러리다. 같은 바닥 위에 계층이 더 있고 JIT 가 약하므로 +Android 쪽이 더 빠를 수는 없다. 다만 **얼마나 다른지는 측정된 바 없다.** + +우리 desktop 구현은 현재 `Linker.Option.critical()` 을 쓰지 않는다 — +`Panama.kt` 가 `downcallHandle(..., emptyOptions)` 로 기본 경로를 탄다. + +### 조립 단위의 범위 — 정해졌다 + +경계 통과가 비싸고 뜨거운 호출에는 critical 도 못 쓰므로 **통과 횟수 자체를 줄이는 것**이 접근이었다 — +`bindings` 의 저수준 함수들을 바인더 레벨 연산으로 조립해 `artMain` 에서 내보내고 `androidMain` 은 그 +굵은 단위를 한 번 부른다 (`artMain` 의 `asmGetAttr` 등; 조립 단위는 길고 업콜 가능성이 있으므로 일반 +JNI, shape 계열은 짧고 콜백이 없으므로 규약을 함수별로 선택). + +초안은 세 후보를 두고 결정을 미뤘다(바인더 API 를 `expect`/`actual` 로 올림 / `commonMain` 에 두고 Android +만 별도 경로 / `Python3` 수준만 조립). **택한 것은 어느 것도 아니다**: 조립은 **FFI 층의 `expect`/`actual`** +(`EmbedAPI`)에 두고 객체 모델은 공통으로 둔다 — 객체 모델은 `bindings` 를 참조하지 않는다 +(`architecture.md` "Consequence for composed operations", `commonMain/README.md`). 그리고 **문자열을 +나르는 연산에서는 조립보다 interning 이 더 싸고 일반적**이라 그쪽으로 갔다 +([`marshalling-design.md`](marshalling-design.md)); 조립이 남는 곳은 통과 횟수가 N 에 비례하는 일괄 +연산(1000원소 리스트 변환 11배)이다. 데스크톱에서는 조립을 하지 않기로 닫혔다 (ROADMAP §6: 마샬링이 +호출의 10.5%). + +## Measured: what the JNI calling convention actually costs (2026-08-11) + +Every overhead figure in this document before this section was quoted from external +benchmarks or inferred from source. This section is the first measurement taken in this +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 +(`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. + +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 + +| convention | API 26 (Android 8.0) | API 34 (Android 14) | +|---|---|---| +| `@CriticalNative` | **1.86** | **24.43** | +| `@FastNative` | 38.58 | 1.99 | +| ordinary JNI | 45.56 | 8.05 | + +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`. + +### What was ruled out + +- **Measurement order.** An earlier version warmed and timed one variant at a time; whichever + native variant went first looked anomalous. Interleaving changed nothing: API 26 stayed at + 1.86, API 34 stayed at 24.43. +- **GC starvation from the tight loop.** A `@CriticalNative` call blocks GC, so millions of + them back to back could have been charging collector waits to the loop. Re-measured at + 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 + there, just slow. + +### What is not known + +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. + +These are emulators under Apple Silicon virtualisation. Absolute nanoseconds say nothing about +real hardware, and the inversion itself may be an emulator artefact. **Before acting on this, +measure on physical devices at both API levels.** + +### Consequence for the design + +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 +was then built: `bindings.preferFastNative`.) + +### 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 +emulator artefact, and hardware is *worse* than the emulator was. + +Net of the Kotlin floor, ns/call: + +| convention | API 26 (emu) | API 34 (emu) | **API 36 (hardware)** | +|---|---|---|---| +| `@CriticalNative` via RegisterNatives | **1.83** | 24.35 | **46.68** | +| `@CriticalNative` via name linking | **aborts the runtime** | 66.80 | 76.56 | +| `@FastNative` | 37.80 | **1.97** | **2.52** | +| ordinary JNI | 44.79 | 7.81 | 16.29 | + +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 +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 +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`. + +**`@FastNative` is the fast path on modern Android, and `@CriticalNative` is on old.** They +are cleanly inverted: ~2ns vs ~38ns on API 26, ~2.5ns vs ~47ns on API 36. Whatever the +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 — +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 +export sets in artMain, not one. + +**Implemented** (`bindings.preferFastNative = SDK_INT >= 34`, `androidMain/.../bindings.kt`; the +crossover is pinned in the next section, and `dispatchPicksTheFasterConventionOnThisDevice` re-measures +it on whatever device runs it). + +### The crossover, pinned: Android 14 + +Measured across five API levels plus hardware. Net of the Kotlin floor, ns/call, from +`JniOverheadBenchmark`: + +| API | `@CriticalNative` | `@FastNative` | ordinary JNI | +|---|---|---|---| +| 26 (Android 8.0) | **1.86** | 38.69 | 45.33 | +| 30 (Android 11) | **-0.24** | 31.76 | 73.09 | +| 31 (Android 12) | **-0.02** | 24.29 | 45.97 | +| 33 (Android 13) | **-0.04** | 2.03 | 7.82 | +| 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 +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. + +**The boundary is Android 14, not Android 12.** Google's guidance to bind `@CriticalNative` +through RegisterNatives *before Android 12* suggested 31 as the interesting version, and that +turned out to be about correctness, not cost: name-linked `@CriticalNative` aborts the runtime +below 12 and merely runs slow above it, while the registered form stays fast right through 33. + +A real C API call behaves the same as the empty echo. `PyList_Size` on `sys.path` costs 2.22ns +under `@CriticalNative` on API 33 and 47.33ns on API 36, tracking the echo numbers closely. +`PyList_Size` is an O(1) header read, so the transition dominates and the convention choice is +not academic. + +**Selection rule, from measurement:** `SDK_INT >= 34` → `@FastNative`, otherwise +`@CriticalNative`. Both are cheap at API 33, so a threshold placed there is forgiving in the +one place the data is closest. + +### A build-wiring defect this work kept tripping over + +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 +`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 +(`python-multiplatform/build.gradle.kts`, `copyAndroidPythonBinaries` / `copyAndroidPythonAssets`); +I did not verify whether `connectedDebugAndroidTest` now forces the `linkAndroidNative*` tasks too. +If an instrumented run fails with `UnsatisfiedLinkError`, run this first: + + ./gradlew :python-multiplatform:linkAndroidNativeArm64 :python-multiplatform:linkAndroidNativeX64 \ + :python-multiplatform:copyAndroidPythonBinaries :python-multiplatform:copyAndroidPythonAssets + +### 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 — +they change what the callee is allowed to do: + +- Both `@FastNative` and `@CriticalNative` **stop the garbage collector** for the duration of + the call, so neither may wrap anything that runs for an unbounded time. +- `@CriticalNative` additionally receives **no JNIEnv**, so nothing beneath it can re-enter the + runtime at all. + +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 — +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. + +So each function is classified, and the classification outranks the API-level choice: + +| 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` | + +`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 +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 +calls, where the transition genuinely dominates, are exactly the ones that keep it. + +## Desktop vs Android, measured + +> The first two tables below are the **pre-fix** desktop numbers (reflective `invoke`); the desktop +> 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 — +`PyList_Size` on `sys.path`. + +| | floor | `PyList_Size` | net | +|---|---|---|---| +| **Desktop** (Apple M1, JDK 21, Panama) | 0.33 ns | **1015.95 ns** | **1015.61 ns** | +| Android API 36 (SM-X910, `@FastNative`) | 3.08 ns | 7.47 ns | 4.39 ns | +| Android API 33 (emulator, `@CriticalNative`) | 2.28 ns | 2.22 ns | ~0 ns | + +Desktop is roughly **140x more expensive per call than Android hardware**, and the gap is +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 +slowest FFI path in the project by two orders of magnitude. + +### Why + +None of this is inherent to Panama. It is the reflection-based backend: + + inline fun PyList_Size(list: Long): Long = PyList_SizeHandle.invoke(list) as Long + +`invoke` rather than `invokeExact` forces an `asType` adaptation and boxes the argument and +the result on every call. Worse, the argument and return filters are themselves reflective +`MethodHandle.invoke` calls that build a `MemorySegment` per call via `ofAddress` and +`reinterpret`. A microsecond for an O(1) header read is what that chain costs. + +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*. + +### Consequence + +`invokeExact` was flagged as a concern long before this measurement and never quantified. +It is now: about 1000ns per call, against roughly 5-10ns for a properly linked Panama +downcall. + +This makes desktop, not Android, the first thing to fix. The shape vocabulary already +committed is the lever: 14 fixed signatures make `invokeExact` reachable, because the call +site's static type is then known and constant. That work now has a measured payoff rather +than a suspected one. + +### Fixed: 1015.95 ns -> 2.65 ns + +Two changes, both confined to the desktop backend: + +**Pointers are now described as `ValueLayout.JAVA_LONG`, not `ADDRESS`.** Both are 8 bytes and +travel in the same register on a 64-bit ABI, but describing them as addresses forced Panama to +hand back a `MemorySegment`, which the backend then unwrapped through per-call argument and +return filters -- reflective `MethodHandle` invocations of `ofAddress`, `reinterpret` and +`getString`. Describing them as longs deletes the filters outright. + +**Call sites use `invokeExact`.** With no filters the handle's type is exactly +`(long, long, ...) -> long`, which matches the Kotlin call site's static type, and that is the +condition `invokeExact` requires. Kotlin accepted it directly with no signature-polymorphism +workaround, which had been the open question. 265 wrappers now use it. + +Measured on the same machine, same benchmark: + +| | before | after | +|---|---|---| +| `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 +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. + +The reflective two-backend selection is untouched and still resolves `java.lang.foreign` or +`jdk.incubator.foreign` at startup. That was never the problem; letting reflection reach the +per-call path was. Reflection now runs once per symbol, at link time. + +Strings still convert at the Kotlin level rather than through a handle filter: `withUtf8 { }` +allocates, passes the address as a long, and frees in a `finally`. Return values owned by +CPython are read without being freed, since freeing them would be a use-after-free. + +### Separating the API level from the hardware + +Until now the modern-Android numbers came from a Samsung tablet while the older ones came from +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: + +| API 36 | floor | `@CriticalNative` | `@FastNative` | ordinary JNI | +|---|---|---|---|---| +| emulator (M1 host) | 2.20 ns | 23.20 | **4.28** | 9.39 | +| hardware (SM-X910) | 3.22 ns | 46.75 | **7.24** | 19.25 | + +Hardware is 1.5-2x the emulator across every row, and the floor moves by the same factor +(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: + +| | `@CriticalNative` | `@FastNative` | +|---|---|---| +| API 26 | **1.85** | 40.15 | +| API 33 | **-0.04** | 2.03 | +| API 36 | 21.72 | **1.66** | + +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. + +### Desktop vs Android, corrected + +The earlier "desktop 2.65ns vs Android 7.47ns" compared an M1 host against a tablet. On the +same host, for the same real call: + +| | `PyList_Size` | +|---|---| +| Desktop (Panama, `invokeExact`) | **2.65 ns** | +| Android API 36 emulator (`@FastNative`) | **4.28 ns** | +| Android API 36 hardware (`@FastNative`) | 7.24 ns | + +About 1.6x between the two paths on identical hardware, not the 2.8x the cross-machine +comparison implied. Both are now in the same regime, which is the part that matters. + +## Composition: measured + +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 +calls one crossing at a time. + +`CompositionBenchmark` measures two cases against their per-call equivalents. + +| | per-call | composed | | +|---|---|---|---| +| **API 36 hardware** | | | | +| `getAttr` (4 crossings → 1) | 3929.84 ns | **713.34 ns** | 5.5x | +| `list → LongArray`, 1000 elems (1001 → 1) | 50065.89 ns | **4532.55 ns** | 11.0x | +| **API 36 emulator** | | | | +| `getAttr` | 2270.23 ns | **474.67 ns** | 4.8x | +| `list → LongArray` | 28145.00 ns | **3705.63 ns** | 7.6x | +| **API 26 emulator** | | | | +| `getAttr` | 2431.34 ns | **244.70 ns** | 9.9x | +| `list → LongArray` | 4890.83 ns | 4113.13 ns | 1.2x | + +### What the numbers actually say + +**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 +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 +composing, but the mechanism is not the one the crossing-count framing suggests. + +**The bulk case is where crossing count genuinely dominates**, and it scales with N: 11x at +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 +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. +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 +for string-carrying operations interning replaced it (`marshalling-design.md`). + +Windows cannot host a Kotlin/Native assembly library, so a composed desktop path would cover +macOS and Linux only, with Windows staying on direct Panama calls. At 2.65ns per crossing +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 +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 +platform X" had no single table to read. + +### What exists today + +| File | Source set | What it measures | Runs where | +|---|---|---|---| +| `commonTest/.../overhead/Benchmark.kt` | `commonTest` | Warmup-then-time harness (`run`/`measure`), not a benchmark itself | wherever the tests below run | +| `commonTest/.../overhead/BenchmarkTest.kt` | `commonTest` | Pointer boxing, raw refcount churn, string marshalling (`PyUnicode_FromString`/`AsUTF8`, three lengths), integer marshalling, `PyObject` wrapper cost, `PyObject_GetAttrString` | compiles for all six run paths below; **confirmed green** on desktop / iosSimulatorArm64 / wasmJsNode when the table was first cut; reaches androidInstrumented and androidNativeArm64/X64 through the same `commonTest` dependency edge but neither ran here (no emulator this session) | +| `desktopTest/.../DesktopOverheadBenchmark.kt` | `desktopTest` | Panama transition cost (`PyList_Size` net of a pure-Kotlin floor); the string-marshalling-share-of-`exec` test is `@Ignore`d (destabilises the interpreter, see the file's own comment) | desktop only | +| `wasmJsTest/.../WasmMarshallingOverheadTest.kt` | `wasmJsTest` | `internedUtf8` vs `scratchUtf8` vs the old malloc/copy/free route, at two string lengths, both standalone and through a real `PyObject_GetAttrString` | wasmJs only | +| `androidInstrumentedTest/.../JniOverheadBenchmark.kt` | `androidInstrumentedTest` | JNI calling-convention transition cost: ordinary / `@FastNative` / `@CriticalNative`, on a trivial echo and on `PyList_Size` | Android/ART only, needs a device or emulator | +| `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: +`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`. + +`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 +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 +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 +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 +(`: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 +`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 +† 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`). + +> **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, +> cut for all six targets in one run with their conditions attached, are in +> [`cost-table.md`](../investigations/cost-table.md): `./benchmarks/cost-table.sh --runs 3` on a quiet machine fills +> it. It draws the first three columns from the same `measureDowncalls()` rows this table does and +> the fourth from the same `BenchmarkTest` row, so it is this table's recipe automated, not a +> different measurement. + +| Platform | empty `withPython` scope | `Py_IncRef` + `Py_DecRef` | `PyObject_CallObject` (downcall, same shape as the upcall table's numerator) | `PyUnicode_FromString`, 8 chars | +|---|---|---|---|---| +| **desktop** (JVM 21.0.12, macOS arm64) | 51.78–52.60 ns | 250.48–257.99 ns | 260.27–265.81 ns | 185.13–191.00 ns | +| **iOS simulator** (arm64) | 701.18–723.85 ns | 1488.64–1543.92 ns | 1585.46–1655.40 ns | 3338.51–3490.55 ns | +| **wasmJs** (Node) | 24.66–25.15 ns | 45.23–46.98 ns | 96.53–100.95 ns | 101.55–103.22 ns | +| *androidNative* `pmp_api36`† | *998.73–1051.08 ns* | *2058.84–2167.44 ns* | *2194.26–2365.61 ns* | *4419.55–4706.72 ns* | +| *androidNative* `pmp_api26`‡ | *1193–1298 ns* | *(no prior measurement)* | *2636–5554 ns* | *(no prior measurement)* | +| *Android API 26* (ART)† | *(no prior measurement)* | *(no prior measurement)* | *1253.41–1295.79 ns* | *1404.00–1485.24 ns* | +| *Android API 36* (ART)† | *(no prior measurement)* | *(no prior measurement)* | *804.27–838.60 ns* | *1013.04–1197.93 ns* | + +**Source for rows 1–4, 6–7:** `docs/investigations/cost-table.md` (commit `958c0082b294`), `UpcallBoundaryCostTest` columns 1–3 with warmup 100,000, min–max over 3 runs per target. Column 4 (`PyUnicode_FromString, 8 chars`) from `BenchmarkTest` same source, warmup 100,000. + +† Rows 1, 2, 3, 4 and 6–7: Directly from cost-table.md. The Android/ART rows (6–7) record both upcall and downcall in the cost-table measurement, fixing the prior "not recorded" state. + +‡ androidNative `pmp_api26`: Does not appear in cost-table.md (only `androidNativeArm64` on `pmp_api36` is there). **Awaiting re-measurement** under the same conditions as the cost-table harness. + +**Prior values** (warmup 3,000): desktop empty scope 123.88–135.99 ns, Py_IncRef+Py_DecRef 321.69–334.10 ns, PyObject_CallObject 315.69–335.32 ns, PyUnicode_FromString 1129.95–1270.76 ns (note: this final column was superseded separately, see below). + +> **The `PyUnicode_FromString, 8 chars` column has been updated.** It is sourced from +> `overhead/BenchmarkTest` with warmup now 100,000 (was 100), for the reason "The benchmark was +> measuring the benchmark" explains in `upcall.md`. The old figures here were 3–7x the warmed +> cost because they were swept at the old `100`-call warmup on desktop (1132–1146 ns cold vs 167–458 ns warm). +> The new column records the current cost at 100,000 warmup, from commit `958c0082b294`. The first three +> columns also come from `UpcallBoundaryCostTest` (already fixed at 100,000 warmup since `7e9c6b8c`), so +> all four columns are now consistent in warmup and source. + +### Warmup: what the boundary benchmarks must do + +The first version of the shared table was taken with `UpcallBoundaryCostTest`'s 3 000-call warmup, +and its absolute upcall figure then *drifted* between commits (desktop 861 ns → ~690 ns, wasmJs +~490 ns → ~310 ns) without any change to the timed code. The investigation (full record in +[`../archive/downcall-design-benchmark-drift.md`](../archive/downcall-design-benchmark-drift.md)) +established: + +- The figure was a **same-process warm-up artefact**, not a boundary cost. Anything that pushed + upcall traffic through the shared JVM / Node process earlier in the suite (a proxy cost test + starting to install proxies; one commit on wasmJs, `4472f83a`) moved the number by 25% on desktop + and ~35% on wasmJs. Confirmed by direct experiment (short-circuiting `GeneratedProxyCostTest` + moved it back). +- It is **host JIT tier-up** (C2 on the JVM, V8 on wasm), not CPython-side state: the pure-Python + rows (empty loop 8–9 ns desktop; callee 24–27 ns) are flat from the first repetition, only rows + that cross into host code move. 40 repetitions plateau at ~535 ns (desktop upcall), ~141 ns + (desktop Kotlin-driven downcall), ~280 ns (wasmJs upcall). +- **Convergence needs 40 000 calls cold and ~70 000 warm, so the warmup is 100 000** (and + `GeneratedProxyCostTest` 5 000 per row, because it shares `_pm_invoke` across 19 rows). Cost: + about +0.2 s on desktop (+4% of suite time). +- With 100 000 warmup the "proxy test short-circuited" lever no longer moves desktop (501–530 vs + 510–560 ns) or wasmJs (287–301 vs 290–304 ns) outside run-to-run noise. That is the fix. +- On wasmJs the **absolute** column still does not survive filtering (`--tests` this class alone: pure + Python rows 55–74% slower, because a short-lived Node process runs CPython-under-V8 colder); the + **ratio** columns do (2.66–3.13x solo vs 2.91–3.14x in the suite). Quote wasmJs absolutes as + full-suite figures. +- `inline` on `Benchmark.measure` was tried and is worse (needs ~230 000 iterations for the same + plateau). Do not retry. +- Rules that follow: a measurement is **full suite, single task, quiet machine** (`uptime` first); + filtering with `--tests` changes the measured region; record the suite composition with the + number; never run two measurement sessions concurrently (load 10–12 made one commit read 672–1076 ns). + +### What is deliberately not in the shared table + +Each platform-only file above measures something the others cannot, and none of it collapses into +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 + 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 + with no analogue on a target that has no separate calling convention to choose between. +- **wasmJs**: `WasmMarshallingOverheadTest`'s interned-vs-scratch-vs-malloc string routes are a + 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 +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. + +## A build defect that made all of this harder + +Three separate instrumented runs failed with `UnsatisfiedLinkError` on symbols that were +demonstrably present in the freshly linked `.so`. The cause was in `build.gradle.kts`: + + linkTaskProvider.configure { + copy { from(outputFile); into(...) } // runs at CONFIGURATION time + } + +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 +`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 new file mode 100644 index 000000000..fa04ccad2 --- /dev/null +++ b/docs/design/ecosystem.md @@ -0,0 +1,853 @@ +# 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 +records the intended relationships, what each repository actually contains today, and what has to +be built before the parts can meet. + +Everything here was read from the working copies on 2026-08-14. Claims that were inferred rather +than observed are marked as such. + +--- + +## 1. The intended shape + + pypackpack (ppp) the features: Python acquisition, dependency resolution, cross-compilation, + bundling. A Kotlin/JVM library that also ships as a GraalVM native CLI. + + toolchain the Gradle interface to ppp, and nothing more than that. + A developer applies one plugin; toolchain translates the Gradle DSL into + ppp middleware calls. + + PythonMultiplatform the binder: CPython embedded in Kotlin Multiplatform, the object model, + the FFI layer, upcalls, downcalls, the generated function table. + + 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`. + +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 +resolves a dependency belongs in ppp even if a Gradle task is what triggers it. + +--- + +## 2. What is actually there + +### `pypackpack` + +Roughly 4,900 lines of Kotlin, a clikt CLI compiled to a GraalVM native image, with real `uv` +integration. `docs/SPEC.md` (483 lines, added in the most recent commit) marks every feature +`Implemented` / `Partially implemented` / `Not yet implemented`. + +Working: `init`, `python use/list/find/install/uninstall`, `package add/remove/sync/tree`, +`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 +`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 +`compile/middleware/BaseInterface.kt`'s `compile`. + +**But `packpack/build.gradle.kts` applies no `maven-publish` and declares no publication.** Nothing +outside that build can resolve it. Until that changes, "toolchain is the Gradle interface to ppp" +cannot be implemented at all. + +### `toolchain` + +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. + +**`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. + +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 +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. + +`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.** + +`toolchain-legacy` is a different lineage: a fork of `kivy/kivy-ios` in pure Python that +cross-compiles CPython and native wheels for android/ios/macos/linux/mingw, last worked on in +February 2024. Nothing moved from it into `toolchain`; its job is what ppp is rebuilding in Kotlin +with uv, crossenv and meson instead of kivy recipes. + +### `pythonx-compose` + +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 +`from java import jclass`, and it reaches **AndroidX directly**: + + from androidx.compose.material3 import TextKt + try: self.__kotlin_composable = TextKt.Text + except: + for name, obj in TextKt.__dict__.items(): + if name.startswith("Text-"): + self.__kotlin_composable = obj; break + +That trailing `-` is Kotlin's value-class mangling suffix (`Text-fLXpl1I`), which chaquopy exposes +verbatim. There are 26 such scans across 8 files. It then calls the function passing Compose's +synthetic `$composer` and `$changed` by hand. + +`pythonx/compose/lite/` is a desktop JVM generation using JPype and hand-written Kotlin wrappers. + +`test/` still depends on chaquopy 15.0.0 and contains a stale in-repo prototype of this +repository's binder. + +### `PyREPL` + +The Kotlin-to-pyi converter lives inline in `app/build.gradle.kts`, 627 lines. It walks each Kotlin +source set's resolved artifacts, reads the jars with **ASM** (not kotlinx-metadata), and writes one +`__init__.pyi` per Java package under `src/<sourceSet>/generated/meta/`, with `...` bodies and a +JNI-ish type map. It drops private members, `<init>`, `Companion`, and **any member whose name +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. + +`toolchain`'s `createMetaClass.kt` is the same code mutated into a *runtime* generator: it emits +`.py` with `from java import *`, `__metaclass__ = jclass(__name__)`, real delegating bodies, and a +Kotlin-to-dunder name map. **For a chaquopy-free future, PyREPL's stub-only version is the one to +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 — +vendored material and prebuilt CPython artefacts. + +--- + +## 3. The notebook is PyREPL's, not pythonx-compose's + +`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 +`on_click` (`docs/archive/pythonx-adapter-design.md` §3 measured both). Which Python spelling +`pythonx` uses is pythonx-compose's decision: this repository's binder exposes the Kotlin name +`onClick` and renames nothing (`docs/INTENT.md` §2.2) — plus `Column/Row/Spacer/TextField`. It also +requires `main.App` as a live object, `App.messages.getValue()/setValue()`, and +`main.App.update(NewComposable)` for hot-swapping the UI from a Jupyter cell. + +**`main.App`, `App.update`, `DefaultIcons`, `Column`, `Row`, `Spacer` and `TextField` do not exist +in `pythonx-compose`.** They exist in PyREPL, under `app/src/androidMain/python/`, in a simpler +design that does **no name matching**: it binds hand-written Kotlin wrappers by exact `@JvmName` +(`Material3Kt.SimpleTextWidget`) and passes the composer explicitly. + +So if the goal is "the notebook runs on our stack", the port target is PyREPL's `app/`, and +`pythonx-compose` is the more ambitious, largely unfinished generalisation of it. + +--- + +## 4. What blocks the parts from meeting + +Ordered so that each unblocks the next. + +**1. `@Composable` cannot be an exposed callable.** The generator emits +`callable = { args -> Owner.fn(args[0] as X) }` and `ExposedCallable.callable` is typed +`(Array<Any?>) -> Any?`. A `@Composable` function can only be called from a `@Composable` context, +so a generated entry for a widget would not compile, and `BindingPolicy` has no `@Composable` +handling at all. Every widget in `pycomposeui` is `@Composable`. Nothing in `pythonx-compose` can +leave chaquopy until this is designed. *(read from source; not runtime-verified.)* This is the +smallest and most blocking item, and it is entirely inside this repository. + +> **Closed — see `docs/archive/pythonx-adapter-design.md` §5.6.** `Text('hi')` written in Python now draws +> through the real `androidx.compose.material3.Text` (`:ksp-fixtures:compose`'s +> `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 +> 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 +unimplementable. + +**3. A Python-facing upcall module.** Today the only Python-side surface is the generated proxy +module plus, in a demo, raw `ctypes` pointers. `pythonx-compose` needs Kotlin namespaces to be +**importable**, so `_material3 = jclass("...Material3Kt")` becomes an ordinary import. Most of +this exists: generated modules are injected into `sys.modules` under their Kotlin fully-qualified +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` +> 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 +> pythonx-compose (`AGENTS.md` §12.2). The plugin's `.pyi` are emitted under Kotlin module paths only +> (SPEC B-7). + +**4. One CPython acquisition path, not two.** This repository's `stagePythonHome` downloads from +python-build-standalone into a Gradle cache; ppp's `python install` downloads from this +repository's own GitHub releases into `~/.pypackpack/python/<version>`. They disagree on source and +destination, and ppp's own `list`/`find` disagree with its `install` about the destination. +Per the stated architecture the surviving one is ppp's. + +**5. ppp's `bundle` stage**, at least bundle-type `resource`, which its SPEC defines as the handoff +format to python-multiplatform and toolchain. It is the missing seam between the two halves of the +system, and all four bundlers are three-line placeholders. + +**6. Port PyREPL's pyi generator into toolchain**, replacing the chaquopy-coupled variant, and fix +its hardcoded `\\` path separators, which break on macOS and Linux. + +**7. A `usage-example` that applies the plugin**, so the target API stops being only a document. + +--- + +## 5. What each repository owes (Implementation Goals per Repository) + +This section documents the current state, language/build system/distribution artifact, ecosystem goals, gaps to goal, and dependency direction for each of the 5 repositories in `/Volumes/macMini/thisisthepy/`, based on direct inspection of working trees on 2026-08-14. + +### `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. + - 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`). + - 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**: + - Kotlin Multiplatform (Kotlin `2.4.20-Beta2` pinned in `gradle/libs.versions.toml` as of 2026-10-03), Compose Multiplatform 1.11.1 for the sample and fixtures, Java 21, C/C++ FFI. + - Gradle (`build.gradle.kts`, `settings.gradle.kts`, included build `python-multiplatform-gradle-plugin`). + - Maven artifact publication (`id("maven-publish")` in `python-multiplatform/build.gradle.kts`, published under group `io.github.thisisthepy:python-multiplatform`). +- **Goal in Ecosystem**: + - 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?>) -> 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.) + +**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 +of this file; it does not appear anywhere, verbatim or paraphrased. The nearest things on record are +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 +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 <Python.h>`, 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 +`b184cba5` (pre-dates `pythonVersion` becoming a Gradle property) and `build.gradle.kts` never +references `nativeInterop/cinterop/include` at all; cinterop reads headers from the version-keyed +extraction tree instead (table above). `binary/` holds five committed CPython 3.13.0 archives from +`python-build-standalone` release `20241008` (`binary/download.md`); they are consumed only by the +special-cased branch `if (configuredPythonVersion == "3.13.0" && !pythonFreeThreaded)` in the +`desktopJar` task (`build.gradle.kts:1049`), i.e. only if a consumer explicitly builds with +`-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 +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 +`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 +derive, respectively, the deprecation schedule the `expect` surface is checked against and the JNI +call-convention classification. So the one place in this repository where a 3.13 header meets a 3.14 +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. + +**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 +3.14.7 headers out of the build's own checksum-verified extraction +(`build/python-standalone/extracted/3.14.7/android-aarch64/prefix/include/python3.14/`; the four +`pyconfig-<platform>.h` files and the hand-written `pyconfig.h` dispatcher were re-derived from the +matching per-platform extractions, since CPython ships one `pyconfig.h` per build and this tree +serves four). The evidence base the two suites read grew from 1129 `PyAPI_FUNC` prototypes to 1207 +and from 81 `Py_DEPRECATED` symbols to 101, and **every conclusion drawn from it was unchanged**: +the JNI classification reported byte-identical totals before and after +(`LEAF_PROVEN=5, RE_ENTRANT_BY_JNI_UPCALL=3, RE_ENTRANT_BY_SIGNATURE=293, UNDECIDED=65`; 34 +GC-blocking declarations, 22 promoted, 12 quarantined), and `desktopTest` went 467/0/1 → 469/0/1, +the two added tests being the new guard below. + +Why nothing moved, stated as a set intersection rather than as an impression: of the 6 prototypes +3.14 drops (`PyUnstable_InterpreterState_GetMainModule`, `_PyBytes_Join`, `_PyTrash_begin`, +`_PyTrash_end`, `_Py_InitializeMain`, `_Py_fopen_obj`), the 1 whose signature changed +(`_PyLong_NumBits`, `size_t` → `int64_t`), and the 23 newly marked `Py_DEPRECATED` (led by +`PyThread_exit_thread`, `_PyThreadState_UncheckedGet`, `_Py_HashPointer`, and the whole +`_PyUnicodeWriter_*` family), **none appears in the 312-name `expect` surface of `EmbedAPI.kt`, and +none appears among the 319 CPython prototypes `jni_onload.def` calls.** Every one of the 30 is a +private (`_Py`/`_PyUnstable`) symbol this project's abi3 self-rule already forbids. So the +3.13-vs-3.14 evidence gap was real but benign, and it is now closed rather than argued about. + +The guard that keeps it closed is `VendoredHeaderVersionTest` (`src/desktopTest/.../ +VendoredHeaderVersionTest.kt`): it compares `patchlevel.h`'s `PY_MAJOR/MINOR/MICRO_VERSION` against +`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 +content comparison would need the extraction present and so would be unavailable exactly when the +extraction has not been run. + +The rest of that tree is genuinely unreferenced and large: `git ls-files` counts 15,842 tracked files +under `src/nativeInterop/cinterop` (679 MB in `lib/`, 2.6 MB in `include/`), and of `lib/` only +`lib/desktop` is read, by the `-PpythonVersion=3.13.0` branch above. The Android CPython 3.13 standard +library trees under `lib/android/<abi>/python3.13/` are referenced by no build file, no Kotlin source +and no test; the runtime stdlib comes from the version-keyed extraction instead. Whether to remove +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 +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, +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 +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 +executable presence to assert against). + +**Issues (checked 2026-08-16):** the only open issue against this repository is +[`python-multiplatform#4`](https://github.com/thisisthepy/python-multiplatform/issues/4), "Add Python/C API +expect declaration reclassification and actual definitions" (deadline noted in the issue as `25-07-24`, i.e. +already past). It has no checklist, just two prose tasks: (1) reclassify the `expect` declarations in +`EmbedAPI.kt`, excluding deprecated/soon-to-be-deprecated APIs and ones judged unnecessary, and (2) add +`actual` definitions for `androidMain`, `desktopMain`, `nativeMain`. + +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 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 + three the issue named). +- The "reclassify to exclude deprecated APIs" half of the issue was resolved differently than specified. + 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 + 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. +- Issue #4's `actual`-definition half was already complete before `4ac2eca6`; the commit's real work was + reclassification, dead-code cleanup (three draft blocks removed from comments), and adding validation tests. + +--- + +### `toolchain` + +- **Observed Current State**: + - Plugin project structure (`toolchain/src/main/kotlin/.../PythonPlugin.kt`) registering `python` extension and 3 tasks (`InstallDependenciesTask`, `BuildPythonArtifactTask`, `AssemblePythonPackageTask`). + - Naive, self-contained task implementations in `bundle/` (`AssemblePythonPackageTask.kt`, `BuildPythonArtifactTask.kt`): copying local directory to `src/main/assets/python`, zipping to `build/pythonBundle/libs`, and executing `uv install -r` (which is an invalid `uv` command). + - DSL definitions in `dsl/` (`DSLBuild.kt`, `DSLCore.kt`, `DSLPackaging.kt`, `DSLPlatforms.kt`, `PythonConfiguration.kt`) covering ~60% of target spec `(플러그인예시)build.gradle.kts`. + - Mutated PyREPL generator `toolchain/src/main/kotlin/.../dependency/lang/createMetaClass.kt` producing runtime `.py` files using `from java import *`. +- **Language / Build System / Distribution**: + - Kotlin (`kotlin-dsl`, `java-gradle-plugin`). + - Gradle (`build.gradle.kts`, `settings.gradle.kts`). + - Gradle Plugin published to Maven (`org.thisisthepy.python.multiplatform` plugin ID). +- **Goal in Ecosystem**: + - Developer-facing Gradle DSL vocabulary (`python { ... }`) and nothing else. + - Translates Gradle DSL configuration into `pypackpack` middleware API calls. + - Owns Kotlin-to-`.pyi` stub generation (porting PyREPL's ASM-based stub generator). + - Handles `integration()` dependency types with `KLIBDEPENS`. +- **Gap to Goal**: + - **Delegate to `pypackpack`**: Currently delegates nothing to `pypackpack` because `pypackpack` is not published to Maven. All tasks must be rewritten to invoke `pypackpack` middleware. + - **Delete redundant tasks**: Remove internal naive tasks (`InstallDependenciesTask`, `BuildPythonArtifactTask`, `AssemblePythonPackageTask`, and asset copy). + - **Wire up DSL**: DSL blocks in `dsl/` (`hotReload`, `codePush`, `buildTypes`, `projectFlavors`, `buildFeatures`, `metaDirs`, `libDirs`, `integration()`) are not read by any task. `PythonConfiguration.kt` extension is dead code (never registered). + - **Replace stub generator**: Replace runtime `createMetaClass.kt` with PyREPL's ASM-based build-time `.pyi` generator, fixing hardcoded Windows path separators (`\\`). + - **Executable specification**: `usage-example/` does not apply the plugin and is commented out in `settings.gradle.kts`. +- **Dependency Direction**: + - `toolchain` -> `pypackpack` (calls `pypackpack` JVM library API). + - Applied by end-user Kotlin Multiplatform projects. +- **Unverified**: + - Behavior of `integration()` configuration with `KLIBDEPENS` wheel metadata (only documented in DSL spec). + +**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 +[`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` +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 + 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 + changed is their bodies: `InstallDependenciesTask` no longer shells `uv install -r` (not a real `uv` + verb) and instead calls `pypackpack`'s `dependency.backend.BaseInterface` directly (confirmed by reading + `InstallDependenciesTask.kt`'s current source, which now imports + `org.thisisthepy.python.multiplatform.packpack.dependency.backend.BaseInterface`). The naive + 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 + 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 + 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` + dead-code claim re-confirmed by this agent: `grep -rn PythonConfiguration toolchain/src/main/kotlin` + outside its own file returns nothing. + +--- + +### `pypackpack` + +- **Observed Current State**: + - ~4,900 lines of Kotlin implementing CLI subcommands in `packpack/src/main/kotlin/.../cli/`: `init`, `python use/list/find/install/uninstall`, `package add/remove/sync/tree`, `target list/add/remove`, `add`/`remove`/`sync`/`tree` with target markers (`platform_system`, `platform_machine`). + - `uv` integration (`dependency/backend/external/UV.kt`) for downloading uv and managing `DevEnv` / `CrossEnv` venvs. + - `build` subcommand driving Meson CLI (`compile/backend/external/Meson.kt`) for C/C++ extension modules (`py.extension_module()`) and Python sources (`py.install_sources()`). + - Architecture ready for dual usage (`dependency/frontend/BaseInterface.kt` has `enum FrontendType { CLI, GRADLE }` and `Gradle.kt`). + - Specification in `docs/SPEC.md` marking feature statuses. +- **Language / Build System / Distribution**: + - Kotlin JVM (Kotlin 2.3.0 / Java 21), Clikt CLI, Ktor client, Ktoml, zstd-jni. + - Gradle (`packpack/build.gradle.kts`, GraalVM Native Image plugin `org.graalvm.buildtools.native`). + - GraalVM native binary executable (`pypackpack`), intended to also publish as a Kotlin/JVM Maven library (`org.thisisthepy.python.multiplatform:packpack`). +- **Goal in Ecosystem**: + - Owns all heavy lifting for Python packaging: Python distribution acquisition/management, dependency resolution (`uv`), cross-compilation environment (`crossenv`), C/C++/Rust extension compilation, bundling into resource/wheel formats. + - Serves as both a standalone CLI and an internal library for `toolchain`. +- **Gap to Goal**: + - **Publish to Maven**: `packpack/build.gradle.kts` lacks `maven-publish` plugin and publication block. Nothing outside can resolve `pypackpack` until published. + - **Implement `bundle` stage**: All bundlers in `bundle/` (`BinaryBundler.kt`, `FatWheelBundler.kt`, `SingleWheelBundler.kt`, `WheelPatchBundler.kt`) are 2-4 line empty placeholders. Bundle type `resource` (needed by `PythonMultiplatform` and `toolchain`) must be implemented. + - **Fix destination disagreement**: `python install` downloads to `<project>/.venv` or `<project>/<target>`, while `python list`/`find`/`uninstall` search `~/.pypackpack/python/<version>`. + - **Auto-install build tools**: `Meson.installMeson()` exists but is never invoked before build execution. + - **CLI pass-through flags**: `add`/`remove`/`sync`/`tree` hardcode `extraArgs = null`. + - Non-Meson compiler backends (Clang, MSVC, NDK, XCode, Emscripten, Cargo) and compilation/minification middleware (Nuitka, Cython, Lpython) are empty placeholders. +- **Dependency Direction**: + - `pypackpack` has no dependencies on `toolchain` or `pythonx-compose`. + - `toolchain` depends on `pypackpack` as a Maven library. +- **Unverified**: + - Wheel patch generation (`WheelPatchBundler.kt`) and incremental upload logic (placeholder files only). + +**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 +Management", parent `pypackpack#2`, sub `pypackpack#5`), +[`pypackpack#5`](https://github.com/thisisthepy/pypackpack/issues/5) ("Managing Multi-Platform Dependencies +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 +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` + ("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` + 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" + cli/.../BuildCommand.kt` finds nothing, and `docs/SPEC.md` (pypackpack's own spec, current as of these + commits) says outright "there is no `source`/`resource` bundle-type subcommand yet". So the class exists + and is tested; the CLI seam to it does not. +- **The "destination disagreement" gap item is mostly fixed, with a named residual.** `5de8656` ("Fix: + Close the three known defects...") added a `registry.properties` file bridging `install`'s + project-relative writes and `find`/`list`/`uninstall`'s `~/.pypackpack/python/<version>` 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 + 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 + (`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`, + `NDK.kt`, `XCode.kt`, `Emscripten.kt`, `Cargo.kt`, `Nuitka.kt` are all still exactly 4 lines each + (`wc -l`, checked by this agent, unchanged from what this file already said). +- **`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 + "*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 == + '...'` markers per target and `--python-platform` is threaded through `tree`/`sync`/`add`/`remove`. The + one place it does not hold: `docs/KNOWN_ISSUES.md` records that the marker `uv add` actually persists + can come out as `platform_machine`/`sys_platform` instead of `platform_system`/`platform_machine`, which + can make `remove --target` fail to find a dependency added with that same target. So the guide's + approach is largely implemented, with one recorded, unfixed matching bug rather than being unbuilt. +- **`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 + 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 + 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. + +--- + +### `pythonx-compose` + +- **Observed Current State**: + - Intended Python UI framework wrapping Kotlin Compose Multiplatform. + - Two unmerged legacy generations: + 1. Chaquopy branch (`pythonx/compose/`): 37 files (28 empty placeholders), using `from java import jclass` and scanning mangled AndroidX Kotlin symbols (`TextKt.Text-fLXpl1I`) via `__dict__` reflection. + 2. Desktop JVM branch (`pythonx/compose/lite/`): uses JPype with hand-written Kotlin wrappers. + - Demo notebook `UI.ipynb` target API works against PyREPL's `app/src/androidMain/python/` implementation (`main.App`, `App.update`, `DefaultIcons`, `Column`, `Row`, `Spacer`, `TextField`), NOT `pythonx-compose`'s current codebase. +- **Language / Build System / Distribution**: + - Python (3.8+). + - `setuptools` (`pyproject.toml` building package `pycomposeui` version 0.0.1). + - PyPI / wheel package (`pycomposeui`). +- **Goal in Ecosystem**: + - Provide Pythonic Compose wrappers (`pythonx.compose.*`) for writing UIs in Python. + - Adapt Kotlin Compose bindings generically via `__getattr__` and caching (mapping named arguments, threading composer, adapting `content=` lambdas) rather than hand-writing wrapper functions per widget. +- **Gap to Goal**: + - Delete raw-AndroidX dynamic reflection branch (`TextKt.Text-fLXpl1I`) and JPype dependency. + - Migrate runtime to `PythonMultiplatform`'s static upcall table and Python proxy injection (`sys.modules`), replacing `jclass`. + - Port PyREPL's widget and runtime model (`app/src/androidMain/python/`) into `pythonx-compose`. + - Implement generic Python adapter (`__getattr__` on `pythonx.compose.material3` resolving underlying `androidx.compose.material3` bindings lazily). + - Replace string-based type name parsing in `remember_saveable` with `PythonMultiplatform`'s `PyValue` type tags. +- **Dependency Direction**: + - `pythonx-compose` -> `PythonMultiplatform` (depends on `PythonMultiplatform`'s FFI binding surface and Python proxy module at runtime). + - ~~Uses `.pyi` stubs emitted by `toolchain` / `PythonMultiplatform` plugin for editor autocompletion.~~ *Corrected 2026-10-03:* `PythonMultiplatform`'s plugin emits stubs for the Kotlin-named modules only (SPEC B-7). The Pythonic `pythonx.*` stubs, and the `pythonx-map.toml` manifest that would drive them, are `pythonx-compose`'s own product; it builds its API from `inspect.signature` and `python_multiplatform.describe` (`KotlinSurface.kt`). +- **Unverified**: + - 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" +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. + +--- + +### `toolchain-legacy` + +- **Observed Current State**: + - Legacy build toolchain source tree (`toolchain/` directory) derived from Kivy (`kivy-ios` and `python-for-android`), last worked on in February 2024. + - Python CLI entry points in `setup.py`: `toolchain`, `toolchain_targetver`, `toolchain_targetos`. + - Cross-compiles CPython 3.11 and native recipes (NumPy, OpenSSL, Kivy) for Android, iOS, macOS, Linux, and Windows (mingw). + - Depends on external forks: `thisisthepy/toolchain-ios` and `thisisthepy/toolchain-android`. +- **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://...`). +- **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. +- **Dependency Direction**: + - None. Independent legacy Python CLI tool. +- **Unverified**: + - Modern Xcode 15+ / NDK 26+ compatibility of legacy recipes (untested). + +--- + +## 5b. The Python import surface — decided + +Four questions were open about what Python code should look like. They are settled. + +**A Kotlin fully-qualified name means the original Kotlin.** `import androidx.compose.material3` +reaches real AndroidX, not a wrapper of ours wearing its name. + +This is reachable, and the mechanism is the one PyREPL already uses: **the Gradle plugin reads the +resolved dependency artefacts.** For each Kotlin source set it makes a resolvable copy of the +`implementation` and `api` configurations and walks the resulting jars with ASM. AndroidX is an +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 + +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 +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. + +**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 +`from junit.runner.Version import id` answering `"4.13.2"`. ROADMAP §16 records it in full; three +things in it change what this section says: + +- **The walker's fragments are a second aggregator (`ArtifactTable`), not additions to KSP's + `FunctionTable`.** One `UpcallTable`, one fragment interface, one Python namespace — but + `FunctionTable` 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)`. +- **ASM alone does not reach `androidx.compose.material3`.** It reaches Java statics and Kotlin + `@JvmStatic`s. A Kotlin *top-level* function compiles onto a package-private multi-file part + (`kotlin/text/StringsKt__IndentKt`) behind a facade Kotlin cannot name, and its extension receiver + is indistinguishable from an ordinary parameter in bytecode. The `@Metadata` *kind* is readable + with ASM; the payload that carries the Kotlin names is `d1`/`d2` and needs `kotlin-metadata-jvm`. + So AndroidX is reachable, as this section says — one library short of it, not one design short. +- **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 + declarations under their Kotlin qualified names, with `isSuspend`, + `hasExtensionReceiverParameter`, value parameters and Kotlin (unerased) types. There is no facade + problem because a klib records the Kotlin declaration; and since a generated fragment is Kotlin + source compiled into the consumer's own binary with the klib on its compile classpath, the *call* + 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.** + 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 + ~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 +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 +exists at runtime cannot be reached on the targets this library has to support. + +So there is one axis, not two. Bindings are produced at build time, by KSP for the consumer's own +source and by an artefact walk for everything the build resolves. Anyone reading the 2024 document +will find a dynamic path described as the plan; it was dropped deliberately, and the meta package +that document treats as a typing aid is, here, generated beside bindings that are themselves +static. + +**`pythonx.*` is ours.** Whatever we wrap or add lives under that prefix. Kotlin fully-qualified +names point at the original; `pythonx` points at our Pythonic layer. The two namespaces do not mix. +*(2026-10-03: "ours" means the ecosystem's — `pythonx` is a real package in pythonx-compose; this +repository's binder creates no `pythonx` module and renames nothing, `AGENTS.md` §12.1–12.2.)* + +**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 +`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 +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. + +**`pythonx` adapts generically; it does not wrap function by function.** Writing a Python wrapper +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. + +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, +under Kotlin names (SPEC B-7); the stubs that carry the Pythonic names and signatures belong to +`pythonx` itself, in pythonx-compose +([`docs/archive/pyi-generation-pythonic-stubs.md`](../archive/pyi-generation-pythonic-stubs.md) is the +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 +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. + +--- + +## 5c. Issue checklists against the code + +§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: + + gh issue list --repo thisisthepy/toolchain --state open + gh issue list --repo thisisthepy/pypackpack --state open + gh issue list --repo thisisthepy/python-multiplatform --state open + gh issue list --repo thisisthepy/pythonx-compose --state open + +`pythonx-compose` has none open. Seven issues came back across the other three repos, each read with +`gh issue view <n> --repo <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 +plausible name exists. + +### `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 +"*lite*"` in the `toolchain` working copy (excluding `.git`/`build`) returns nothing. + +### `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 | +| 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 | +| 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)" | +| 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 | + +### `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 | + +### `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 +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 +diverge in practice: `docs/KNOWN_ISSUES.md` records that the marker text `uv add` actually persists can +come back as `platform_machine`/`sys_platform` rather than `platform_system`/`platform_machine`, which +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) + +❌ **Not addressed.** The issue proposes renaming `BaseInterface.kt`/`DefaultInterface.kt` (duplicated +across `dependency/`, `compile/backend/`, `compile/middleware/`, `bundle/`, `deploy/`) to +`FrontendInterface.kt`/`MiddlewareInterface.kt`/`BackendInterface.kt`/`DefaultMiddleware.kt`/ +`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" + +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 +(335 `expect` vs 315 `actual` on desktop) was a `grep` counting artifact: Kotlin block comments nest, and +old drafts inside comments were counted as live code. Comment-aware scanning shows the difference is +**zero on all targets**. The `actual`-definition half was already complete; the commit's work was +reclassification (removing `PyEval_InitThreads`, annotating four replacements as `@Deprecated` without +`ReplaceWith` because exact replacements exist but blind swaps would be incorrect, leaving three CPython-discouraged +functions alone), deleting three dead draft blocks from comments, and adding a test validating all three +targets against a canonical surface (466 test cases, all passing). + +### What is issue-sourced vs. this document's own inference + +Per the task that produced this section: items in §5's existing "Gap to Goal" lists that also appear in an +issue above are issue-sourced, and are now cross-referenced from within §5 itself (see the "Issues" note in +each repository's block). Two kinds of mismatch, kept separate rather than merged: + +- **In an issue, missing from §5's original gap lists**: `pypackpack#12`'s file-naming complaint; + `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 + 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 + 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 + the `pythonx-compose` "Issues" note above since that repository has zero open issues to source anything + from. + +--- + +## 6. Where this file should live + +It describes five repositories and sits in one of them, because that is the only one with an +active working copy and a documentation habit. If the organisation grows a place for cross-repo +documents, this belongs there, and what stays here is the last section's first entry. diff --git a/docs/design/kotlin-extensions-in-python.md b/docs/design/kotlin-extensions-in-python.md new file mode 100644 index 000000000..fe2da0d67 --- /dev/null +++ b/docs/design/kotlin-extensions-in-python.md @@ -0,0 +1,724 @@ +# Kotlin extension functions, value classes, and the Python surface + +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. + +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 + +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 +in place with what replaced it, because the old measurement is why the replacement was written. + +| what | said here | now | +|---|---|---| +| §2.3, the "JVM params" column | nine values | **all nine were one low**; `$default` was not counted. Corrected in §2.3, with the miss recorded | +| §3, what the walker binds from Compose | **zero**, behind three independent gates | **314 declarations, 104 of them `Modifier` extensions** (`958c0082`). Original funnel kept; §3.2 is the re-measurement | +| §3, the metadata-kind (`k=1`) gate | open problem | closed by `15fc5a62`, 25 minutes after this file was committed | +| §3/§6, the type gate | open problem | closed by `f455ef41`; `TypeTag.OBJECT` was never the missing piece (§3.2) | +| §3.1/§6, overload dispatch | unsolved, costing 33 of 130 `Modifier` names | rule chosen and implemented (`ArtifactScanner.disambiguateOverloads`); §3.1 records it | +| §4.2, the `Modifier` metaclass | proposed | **measured not to work**, at run time *and* in mypy. See [`docs/archive/pyi-generation-pythonic-stubs.md`](../archive/pyi-generation-pythonic-stubs.md) §4.3–§4.4 | +| §1, the Compose version | 1.6.11 | the measurements here were taken on 1.6.11 and are kept as such; the build now pins Compose Multiplatform **1.11.1** (`gradle/libs.versions.toml`, `compose-plugin`), with material3 1.9.0 | +| §3.2, 43 `Modifier` extensions declined for a function-typed parameter | "a real boundary limit" | function-typed slots bind now (SPEC B-5); `ArtifactScanner`'s KDoc records **157 `Modifier` extensions bound and 8 declined** after `functionSlotOrNull` (not re-measured for this revision) | +| §3.1/§6, where the overload dispatcher lives | "in `pythonx`" | in **this repository's** binding layer, `python_multiplatform.binding` (`_Overloads` in `PythonxAdapter.kt`), which serves the base name (`padding`) and selects among the `name__<types>` entries (SPEC U-8) | +| §4.1, primitive receivers as `pythonx.compose.ui.unit.to_dp(x)` | snake_case, under `pythonx` | the binder serves Kotlin names only (`docs/INTENT.md` §2.2): such an extension is a module-level function on its Kotlin-named module, receiver first. A Pythonic spelling is pythonx-compose's | +| §4.4, the allowlist | decided, not built | built at run time in the binding layer: `_VALUE_CLASS_ALLOWLIST` starts **empty** and pythonx-compose fills it through `allow_raw_primitive` (`PythonxAdapter.kt`) | +| §4.5, `.pyi` with `@overload` and `Dp \| float` | proposed for this plugin | the plugin emits Kotlin-named stubs with boundary types only (SPEC B-7, `docs/design/pyi-generation-design.md`); the Pythonic stub is pythonx-compose's. §4.5 is replaced below; the original is in [`docs/archive/kotlin-extensions-in-python-pyi.md`](../archive/kotlin-extensions-in-python-pyi.md) | +| §4.6, composables | "remain blocked" | callable from Python inside a composition (SPEC B-6); see §4.6's update | + +Still true and untouched: all of §2 except the one column, §4.3, §5, and two of §6's five open +items. §4.1's placement and §4.4's three rules hold, with the corrections above. + +--- + +## 1. What was measured, and with what + +The build pinned Compose Multiplatform **1.6.11** at the time (`gradle/libs.versions.toml`, +`compose-plugin`; it pins **1.11.1** today, and nothing here was re-measured on it), so the primary corpus is the 19 `org.jetbrains.compose.*` desktop jars the Gradle cache had +resolved. As a cross-check the same tool was run over the 19 `classes.jar` extracted from the +Jetpack AAR set the cache also holds (`androidx.compose.ui` 1.6.7, `foundation` 1.6.7, +`material3` 1.2.1). + + corpus A 19 jars org.jetbrains.compose.* 1.6.11 (desktop) 51,605 JVM methods + corpus B 19 jars androidx.compose.* 1.6.7 / 1.2.1 (AAR) + +The tool is ASM 9.9 (already in the cache, the version the plugin uses) plus +**`kotlin-metadata-jvm` 2.0.20**, which was *not* in the Gradle cache and was fetched from Maven +Central. Metadata is what makes the counts trustworthy: it gives the Kotlin name, the Kotlin +visibility, whether a parameter is an extension receiver, the unerased parameter types, and +whether a parameter has a default. None of those are recoverable from descriptors, which is the +whole point of this document. Scratch tree: `/Volumes/macMini/tmp/composescan`. + +The current worker's numbers in §3 are **not** a re-implementation of its filter. The repository's +own `ArtifactScanner.kt`, `JvmDescriptors.kt` and `ArtifactRendering.kt` were copied out +unmodified, compiled standalone with `kotlin-compiler-embeddable` 2.0.20 and run over the same +jars. Sanity check that the harness works: the same binary returns 9 entries from +`kotlin-stdlib-2.0.20.jar` (all `kotlin.jvm.internal` plumbing). + +**§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. +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.1 Overall + +| | corpus A | +|---|---| +| JVM methods | 51,605 | +| Kotlin functions carrying `@Metadata` | 13,721 | +| … public | 9,954 | +| … public **and top-level** (file facade / multi-file part) | 1,411 | +| … public top-level **extension** functions | **604**, over 136 distinct receiver types | +| … of those 604, JVM name is value-class-mangled | **151 (25.0%)** | + +### 2.2 `Modifier` + +| | corpus A | corpus B | +|---|---|---| +| functions with a `Modifier` extension receiver | 330 | 321 | +| … 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 | — | + +The two ecosystems agree to within one function, so the numbers are a property of Compose and not +of one packaging of it. + +Within the 177: + +- 130 distinct Kotlin names; **33 names carry more than one overload** (max 5). +- 435 parameters, of which **254 (58%) declare a default**; 82 of the 177 have at least one. +- 1 is `inline`; none is `operator` or `infix`. +- Parameter composition: 31 take only primitives/`String` (or nothing), 52 take at least one value + class, 79 take at least one object or collection, 66 take at least one lambda. + +### 2.3 The named composables + +Metadata arity excludes the synthetic parameters; the JVM descriptor does not. + +| Kotlin | JVM name | mangled | Kotlin params | JVM params | as first recorded | +|---|---|---|---|---|---| +| `material3.Text(String, …)` | `Text-fLXpl1I` | yes | 16 | **20** | 19 | +| `material3.Text(String, …)` | `Text--4IGK_g` | yes | 17 | **21** | 20 | +| `material3.Text(AnnotatedString, …)` | `Text--4IGK_g` | yes | 17 | **21** | 20 | +| `material3.Text(AnnotatedString, …)` | `Text-IbK3jfQ` | yes | 18 | **22** | 21 | +| `material3.Button` | `Button` | no | 10 | **13** | 12 | +| `material3.Card` (clickable) | `Card` | no | 9 | **12** | 11 | +| `material3.Card` (container) | `Card` | no | 6 | **9** | 8 | +| `foundation.layout.Column` | `Column` | no | 4 | **7** | 6 | +| `foundation.layout.Row` | `Row` | no | 4 | **7** | 6 | + +**The last column is what this document first recorded, and every one of its nine rows was one +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 +`$default` mask. + +`$default` is easy to miss because Compose does not carry it the way the rest of Kotlin does. +`javap -p` over `ButtonKt` and `TextKt` shows **no `Button$default` or `Text$default` bridge method +exists at all**: the Compose compiler declares the mask as a trailing parameter of the function +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 +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. + +`Text` is mangled because `color`, `fontSize`, `letterSpacing` and `lineHeight` are value classes; +`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 +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 +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. + +### 2.4 Value classes + +111 value classes in corpus A. Underlying types: 65 `Int`, 25 `Long`, 3 `IntArray`, 3 `Float`, +3 `Any`, 2 `FloatArray`, 1 `ULong`, 1 `Short`, 7 wrapping another reference type (`Composer` ×2, +`Constraints`, `Shape`, `AtomicReference`, `MutableState`, `Operations`), 1 generic. + +The ones that matter, with their real underlying type and the visibility of the constructor that +takes it: + +| value class | wraps | field | constructor | +|---|---|---|---| +| `ui.unit.Dp` | `Float` | `value` | **PUBLIC**`(Float)` | +| `ui.unit.TextUnit` | `Long` | `packedValue` | INTERNAL | +| `ui.unit.DpSize` / `DpOffset` / `IntSize` / `IntOffset` | `Long` | `packedValue` | INTERNAL | +| `ui.unit.Constraints` | `Long` | `value` | INTERNAL | +| `ui.geometry.Offset` / `Size` / `CornerRadius` | `Long` | `packedValue` | INTERNAL | +| `ui.graphics.Color` | `ULong` | `value` | **PUBLIC**`(ULong)` | +| `ui.graphics.TransformOrigin` | `Long` | `packedValue` | INTERNAL | +| `ui.semantics.Role` | `Int` | `value` | PRIVATE | +| `ui.text.style.TextAlign` | `Int` | `value` | INTERNAL | + +**`Sp` does not exist.** The premise that `Dp` and `Sp` both wrap `Float`, and therefore collide +under raw-number coercion, is wrong twice over. `16.sp` produces a `TextUnit`, and `TextUnit` is a +**packed** `Long`: + + getSp(float) = pack(0x1_0000_0000L, value) // observed in TextUnitKt bytecode + +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*. + +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 +the problem. + +`Color` is the same trap in a different place. Its constructor is public and takes `ULong`, but +what a Kotlin author writes is the *factory function* `ColorKt.Color(Int)`, whose whole body is a +32-bit left shift: + + Color(int) = ULong(value) shl 32 // observed in ColorKt bytecode + +So `0xFFFF0000` means red **through the factory** and means transparent-black **through the +constructor**. The 2024 notebook's `color=0xFFFF0000` worked only because a hand-written Kotlin +shim (`Material3.kt`) declared `color: Long` and called `Color(color)` on the Kotlin side. + +Value classes appearing as parameters of the 177 public top-level `Modifier` extensions, by +occurrence: `Dp` 56, `Role` 11, `Color` 8, `TextUnit` 4, `TransformOrigin` 4, `DpSize` 2, +`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 + +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 +records again in every implementing class. They are not competing top-level declarations and +Kotlin's import scope never sees them as such. + +Restricted to public **top-level** extensions, which is the set Kotlin's import scope actually +governs: + +- 507 distinct `(receiver, name)` pairs +- **5** are declared in more than one package: + +| receiver.name | packages | +|---|---| +| `Modifier.contextMenuOpenDetector` | `foundation`, `material` | +| `Modifier.minimumInteractiveComponentSize` | `material`, `material3` | +| `Double.times` | `ui.geometry`, `ui.unit` | +| `Float.times` | `ui.geometry`, `ui.unit` | +| `Int.times` | `ui.geometry`, `ui.unit` | + +- 11 more pairs are split across two file facades *within* one package, which is an overload set, + not a collision. + +Two of the five are `material` versus `material3`, which an application does not mix. The rule +costs five names in the whole library. + +### 2.6 `@Composable` + +500 public top-level `@Composable` functions in corpus A (counted on the exact annotation +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, +zero to two `$default` bitmasks: + + +2 params : 80 +3 params : 349 +4 params : 62 +5 params : 9 + +**Unconfirmed since §2.3's correction.** This distribution was counted the same way §2.3's JVM-params +column was, and that column was one low on all nine rows. It has not been re-derived. If it is +shifted identically, `+2` means "composable with no defaulted parameter" and the nine functions of +§2.3 are all `+3` or `+4`, which is what re-counting them gave. + +**The overlap with extension handling is one function.** Of the 500, 41 are extension functions, +and exactly one of those is a `Modifier` extension (`ui.ExternalDrag_desktopKt.onExternalDrag`). +The remaining 40 sit on `Transition`, `InfiniteTransition`, `InteractionSource`, `RowScope`, +`ColumnScope` and window scopes. Composables and chainable modifiers are, empirically, two +disjoint problems. + +--- + +## 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. + +**Zero. From all 19 jars of corpus A, and all 19 of corpus B.** + +Not "a small subset", not "the unmangled ones". `ArtifactScanner.scanJar` returns an empty list for +every Compose artefact. Running the funnel stage by stage over corpus A's 51,605 JVM methods: + +| after | surviving | +|---|---| +| all JVM methods | 51,605 | +| class binary name has no `$` | 30,897 | +| class is public, not synthetic/annotation | 27,514 | +| `@Metadata` kind is `class` (k=1) or absent | 21,220 | +| method is public **and** static | 3,381 | +| not synthetic, not bridge | 935 | +| not `<init>` / `<clinit>` | 935 | +| no `$` in method name | 918 | +| **no `-` in method name** | **190** | +| every parameter and return type admitted by `boundaryTypeOf` | **0** | +| unique name within its class | 0 | + +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 — +`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 +> 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` +> constant survives only in `metadataKinds`, a test-facing helper that reads the kind byte without +> decoding the payload. The paragraph above was true of the commit it describes and of nothing +> after it. + +**The type gate.** Of the 190 methods that reach it, none survives: every one takes or returns +something that is not a primitive, `String`, `byte[]` or `void`. + +> **Closed by `f455ef41`, and the diagnosis here was one step short.** "An object-handle boundary +> type that `boundaryTypeOf` does not have" (§6) named the wrong missing piece. +> `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 +> 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` +> (`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. + +**The `-` filter is not what is blocking Compose.** It removes 728 of the 918 methods that reach +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 +is a measure of **how much surface the metadata work opens that a descriptor-only walker could +never have named**. + +`Modifier.padding` is the whole argument in one function: + +| JVM name | mangled | Kotlin parameters | +|---|---|---| +| `padding` | no | `paddingValues: PaddingValues` | +| `padding-3ABfNKs` | yes | `all: Dp` | +| `padding-VpY3zN4` | yes | `horizontal: Dp = …, vertical: Dp = …` | +| `padding-qDBjuR0` | yes | `start: Dp = …, top: Dp = …, end: Dp = …, bottom: Dp = …` | + +The one unmangled overload is the one a Python user is least likely to want. + +### 3.1 The rule that will hurt next: dropping ambiguous overloads + +The walker drops a name entirely when more than one binding would carry it. Applied to `Modifier` +that costs **33 of 130 names**, and the casualty list is the API's centre of gravity: + + absoluteOffset background blur border clickable combinedClickable composed + consumeWindowInsets contextMenuOpenDetector focusOrder graphicsLayer height + minimumInteractiveComponentSize offset onClick padding paddingFrom paddingFromBaseline + pointerInput progressSemantics pullRefresh requiredHeight requiredSize requiredWidth + 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 +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 +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 +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 + declaration; +- a group of more than one gets `name__<types>`, under the **first of three schemes that separates + the group**: the simple names of the declared value parameters; then the receiver joined to them + (for overloads differing only in what they extend, `Int.times` versus `Double.times`); then fully + qualified names (for two parameter types sharing a simple name); +- 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 +`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 +attached. + +One further change came with it: **the grouping moved from the class to the whole jar.** Ambiguity is +counted over `ArtifactScanner.scanJar`'s complete result rather than inside one `scanClassNode` call, +because an overload set is a property of a *package* and Kotlin lets one live in two files. §2.5's +"11 more pairs are split across two file facades within one package" is exactly that case: two +`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 +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, +`python_multiplatform.binding` (`_Overloads` in `PythonxAdapter.kt`), which serves the base name and +selects among these names; the table's job is still to make that choice *possible*, not to make it. + +### 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 +below are not the ones in §2 with different values: + + compose: 314 declarations bound, 104 of them Modifier extensions + compose: 45 public top-level Modifier extensions declined, 43 of them for a function-typed parameter + +That is not a transcription of a one-off run. +`ArtifactScannerTest.composeModifierExtensionsSurviveBothGates` re-derives it on every build, +walking the real jars out of the Gradle cache and printing the counts as well as asserting a floor, +so the number cannot quietly rot into a passing assertion that measures nothing. The run reproduced +here: 18 tests, 0 failures. + +**This is a different corpus from §2's, and the two counts must not be subtracted.** Read carefully: + +| | §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`) | +| 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 +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: +`androidx.compose.foundation.layout.padding` takes a `Dp` that lives in `androidx.compose.ui.unit`, +so a value class or an extension receiver has to be resolvable from jars that are not themselves +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 +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. + +> **Superseded since:** a Python callable now crosses into a Kotlin `FunctionN` slot +> (`ArtifactScanner.functionSlotOrNull`, `PythonCallables.kt`; SPEC B-5, with a value-returning slot +> such as `() -> Float` still refused). `ArtifactScanner`'s KDoc records 157 `Modifier` extensions +> bound and 8 declined after that change. + +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 +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 +§4.1's proxy is for and §4.1's proxy does not exist yet. *(It exists now: the binding layer's +receiver proxies, `_proxy_type` in `PythonxAdapter.kt`, SPEC U-7.)* + +--- + +## 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 — +`pythonx/compose/ui/modifier.py` is a copy of the Button wrapper in which `padding()` composes +nothing and `fill_max_size()` returns `self`. + +Not every receiver can take a method, and the split was counted: + +| receiver of the 604 public top-level extensions | count | Python placement | +|---|---|---| +| ordinary class or interface | 514 | method on the proxy type | +| Compose value class | 41 | method on the value-class proxy | +| Kotlin collection | 29 | module-level function | +| Kotlin primitive or `String` | 20 | module-level function | + +Python cannot attach a method to `int`, so `Int.toDp` and its 19 siblings stay module-level +functions on their Kotlin-named module, taking the receiver as the first positional argument. That is +a real loss of fidelity and it is confined to 20 functions. (This file first wrote the result as +`pythonx.compose.ui.unit.to_dp(x)`; the binder renames nothing, and a snake_case spelling under +`pythonx` is pythonx-compose's to add.) + +### 4.2 `Modifier` as one name for both the type and the empty modifier + +`Modifier` in Kotlin is an interface, and `Modifier` as an *expression* is `Modifier.Companion`. +The relevant observation is that **`Modifier$Companion` implements `androidx.compose.ui.Modifier`** +— it is an instance of the very type it is the companion of, and it is the empty modifier that +starts every chain. + +So Python does not need to reconcile two things. It needs one class whose *class object* also +behaves like an instance. A metaclass gives that directly: + +```python +class _ModifierMeta(type): + def padding(cls, *args, **kwargs): return cls.EMPTY.padding(*args, **kwargs) + ... + +class Modifier(metaclass=_ModifierMeta): + def padding(self, *args, **kwargs) -> "Modifier": ... +``` + +`Modifier.padding(16)` resolves on the metaclass and delegates to the companion singleton; +`m.padding(16)` resolves on the instance; `def f(m: Modifier)` still names a type. All three +spellings work, and the generated `.pyi` can state all three (§4.5). + +> **"All three spellings work" was not measured, and it is false.** `docs/archive/pyi-generation-pythonic-stubs.md` +> §4.3 ran it, in CPython 3.13 and in mypy 2.3.0, and the metaclass loses in both. +> +> **At run time**, `Modifier.padding` is the *instance* method, not the metaclass one. That is +> `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*, +> which is the worse failure. +> +> **In mypy**, the same stub gives ten errors in twenty lines; every class-object spelling fails and +> only the instance ones pass. +> +> 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 +> 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 +> 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` +> 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 +> hybrid descriptor, `_Hybrid` in `PythonxAdapter.kt`; a `Modifier` class that is also the empty +> 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 +type checker can. + +### 4.3 Collisions demand qualification; they do not get silently resolved + +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. + +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 +`material`'s `minimumInteractiveComponentSize`. Python attaches everything discovered, so the +surface is the union across all resolved artefacts. Qualification is the price of that union. + +### 4.4 Value classes: accept both spellings, but do not infer which by machine + +This was framed as a usability choice once mangling stopped being an obstacle. It is not only that. +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 +these failures raises; they render wrong. + +**The evidence against blanket `dp(16)`.** `Dp` is 62% of value-class parameter occurrences on +`Modifier`, it wraps a plain `Float`, and its constructor is public and *is* the identity on that +float. Forcing a wrapper call there buys no safety at all. + +**A machine rule was sought and found insufficient.** "Coerce iff the value class has a public +constructor taking exactly its underlying type" is checkable from metadata and selects 36 of 111. +It correctly excludes `TextUnit`, `Offset`, `Size`, `DpSize`, `DpOffset`, `TextAlign` (INTERNAL) +and `Role` (PRIVATE). **It admits `Color`, which is the second-worst case in the library.** `Color` +passes because its `ULong` constructor is public; it fails in practice because what a Kotlin author +writes is the shifting factory `Color(Int)`, and metadata cannot distinguish a constructor that +stores from one that packs. Whether a wrapper packs is a semantic fact with no bytecode witness. + +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 + 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 + to call.** `Modifier.padding` accepting `16` while `Text(fontSize=16)` raises "expected + TextUnit; write sp(16)" is an inconsistency, and it is the honest one: the two are different + because `Dp` and `TextUnit` are genuinely different, and hiding that produces a UI that silently + 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 + 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 +Compose release adding a float-wrapping unit will not be caught automatically. The failure mode of +getting it wrong is a silent visual defect, not an exception. + +### 4.5 `.pyi` + +Generation belongs to the Gradle plugin (`AGENTS.md` §12.5), and it reads the same metadata walk as +the binder, so the stub and the binding cannot drift. **What it emits is the Kotlin surface, not a +Pythonic one** (SPEC B-7, `docs/design/pyi-generation-design.md`): one `def` per table key under the +Kotlin module path, Kotlin parameter names, `= ...` for a Kotlin default, the extension receiver as a +positional-only first parameter, the declared Kotlin types as stub classes (`Modifier`, `Dp | float`, +`Callable[...]`), each extension also as a callable attribute of its receiver's class, and `@overload` +sets over a base name in table-key order, with the Kotlin signature in the docstring (issue #31). What +stays out is snake_case, `pythonx` and the Pythonic allowlist narrowing, which belong to pythonx-compose. The section +this replaces — the metaclass stub and the three measured requirements for a Pythonic stub — is in +[`docs/archive/kotlin-extensions-in-python-pyi.md`](../archive/kotlin-extensions-in-python-pyi.md). + +### 4.6 `@Composable` needs no new machinery here + +Two measurements settle this. First, the extension and composable problems barely intersect: 1 of +500 (§2.6). Second, `docs/design/ecosystem.md` §5b already decided that a composable's synthetic +parameters are threaded as ordinary values by the Python adapter. Nothing in extension-as-method +disturbs that, because a `Modifier` extension is not composable and a composable is not a +`Modifier` extension. + +What does need care is the arity mismatch: metadata reports Kotlin arity, the descriptor reports +2–5 more. Any code that derives arity from the descriptor will be wrong for all 500. Metadata +arity is the correct source, and the generated Kotlin passes the composer explicitly. + +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 +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 +"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 +`@Composable` from a non-`@Composable` lambda nor name `$composer`/`$changed`/`$default`, which are +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 +(first recorded in `docs/archive/pythonx-adapter-design.md` §5.6): + +- `@Composable` is `@Retention(BINARY)`: it appears in `RuntimeInvisibleAnnotations`, so the walker + has to read both annotation lists to recognise a composable. +- The trailing JVM shape is derivable from the Kotlin arity *n*: index *n* is the `Composer`, then + `ceil(n/10)` `$changed` ints, then `ceil(n/31)` `$default` ints exactly when the declaration + defaults something (`ComposableBindingTest.everyComposableJvmSignatureIsKotlinParamsThenComposerThenInts`). +- 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 + descriptor copied from the walked `MethodNode` at build time; no runtime lookup. +- The binding layer (`python_multiplatform.binding`) computes the `$default` mask from which + arguments the Python call wrote and threads the composer from a stack that one hand-written + `@Composable` entry pushes (`PythonComposition` in `ksp-fixtures/compose`). + +Proven by `ksp-fixtures/compose`'s render tests (`ComposableRenderTest`, `M3ProofRenderTest`, +`CallbackDrivenRenderTest`) on desktop only; Android, iOS and wasm are planned (SPEC N-2). Extension +composables and recomposition skipping remain open. + +--- + +## 5. What the 2024 attempt paid, for comparison + +`pythonx-compose` needed a **hand-written Kotlin shim** to make Compose reachable at all: +`Material3.kt`, 228 lines, 20 functions, **16 `@JvmName` annotations** whose only job is to +suppress mangling, 4 wrappers taking `color: Long` so that `Color(...)` could be applied on the +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 +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 +be made unambiguous for Compose no matter how carefully it is written. + +--- + +## 6. Open + +When this list was written, two of the five below had closed and one had moved; by 2026-10-03 the +overload dispatcher and the allowlist have closed too, and a sixth item (handle ownership) was +added. They are kept with their status rather than deleted, since each is the reason a piece of work +happened. + +- **Scoped modifiers.** *Still open.* 74 public member extensions on `Modifier` (`RowScope.weight`, + `ColumnScope.align`, `BoxScope.matchParentSize`, `LazyItemScope.fillParentMaxWidth`, …) need a + 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 + 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 + `Modifier` names is gone; §3.1 records the rule that replaced it and why nothing arbitrates. What + remains open is the half this layer cannot host: a Python-side dispatcher that inspects argument + 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 + 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: + 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 + 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 + 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 + 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. +- **klib.** *Still open, and untouched.* Everything here is JVM, and §3.2's 314 is still JVM only. + `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.)* diff --git a/docs/design/marshalling-design.md b/docs/design/marshalling-design.md new file mode 100644 index 000000000..4f48d587d --- /dev/null +++ b/docs/design/marshalling-design.md @@ -0,0 +1,163 @@ +# String marshalling across the FFI boundary + +## The problem, measured + +Every composed-versus-per-call result in this project turned out to be about string +marshalling rather than crossing count. Two observations pin that down: + +- `Python3.exec`'s per-call cost is **identical on API 26 and API 36** (17798 vs 17799 ns) on + devices whose per-crossing cost differs by 20x. +- 7.7 µs spread over ~11 crossings would be 700 ns each, two orders of magnitude above the ~7 ns + a crossing actually costs. + +The cost is the round trip that produces a C string. Today Android does it the expensive way: + + ffiAllocUtf8(s) JNI crossing → GetStringUTFChars → malloc → copy → return address + ffiFreeUtf8(p) JNI crossing → free + +Two crossings and a heap allocation **per string per call**. Desktop pays ~200 ns per string +where Android pays ~2500 ns, and the whole difference is that Panama allocates off-heap from +inside the JVM and never crosses. + +## What was measured + +`getAttr`, same device, four ways: + +| ns | API 36 | API 26 | +|---|---|---| +| was current (`ffiAllocUtf8` per call) | 2238.25 | 2488.62 | +| composed natively (`asmGetAttr`) | 528.85 | 255.19 | +| direct buffer + `toByteArray` | 694.03 | 800.96 | +| 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 +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. + +Attribute names, module names and method names are repeated literals, so encoding them again +on every call is pure waste. CPython interns its own strings for the same reason. + +### Status: adopted on desktop, Android and wasm + +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 — +`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. + +Desktop was wired later than Android (it was already at ~150 ns per string, since Panama allocates +off-heap without crossing a boundary). At the time of writing `desktopMain/.../bindings.kt` has 31 +`internedUtf8` and 27 `encodeScratchUtf8` call sites and no `withUtf8` call sites; `withUtf8` +survives only as a helper in `Panama.kt`. I did not find a desktop before/after measurement +recorded for the switch; the Android table above is the measured evidence. + +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 | + +Interning an `exec` source string would blow the cache; intern only where the argument is a name. + +## The design + +Centre the abstraction on **interning**, not on a general allocator. + + // 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) + +`internedUtf8` returns a stable address that stays valid for the process. Callers never free +it. On a miss it encodes once and caches. + +`encodeScratchUtf8` writes into a per-thread scratch buffer and returns its address. Nothing to +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 — +`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 +that boundary, so it is a tested guarantee rather than an implementation detail. + +`freeUtf8` exists for the cases that genuinely need an independent lifetime. + +### Which argument takes which path + +**The judgement is per argument, not per function.** `Py_CompileString(source, filename)` interns +`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 | +|---|---| +| 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 | + +Two cases are worth stating because they read the wrong way at a glance: + +- `PyErr_WarnExplicit`'s `filename` and `module` **intern** even though its `message` does not: + CPython uses those two as keys in the once-registry that decides whether a warning has already + been shown, so they repeat by construction. +- `PyErr_SyntaxLocation`'s `filename` **scratches**, unlike the `filename` above. It labels one + error being reported, not a registry key. + +No migrated function holds more than two scratch encodes live at once, so none of them can reach +the four-slot boundary. + +### Cache policy + +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 + evicting, since evicting would invalidate an address a caller may still hold +- is keyed by the string itself, so equal strings share one C buffer +- 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. + +### ASCII fast path + +`String.toByteArray(UTF_8)` allocates a `byte[]` on every call, and API 26's ART is markedly bad +at it: 800.96 ns against 248.94 ns for writing chars straight into the buffer. Python +identifiers are ASCII in practice, so the scratch encoder should take that path and fall back to +full UTF-8 only when it sees a character above 0x7F. + +### Platform implementations + +| | allocation | address | +|---|---|---| +| desktop | Panama, via the existing `Panama` allocator | already a `long` | +| android | `ByteBuffer.allocateDirect` | `GetDirectBufferAddress`, **once per buffer** | + +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 +`AndroidUnsafe.allocateMemory` to `sun.misc.Unsafe`, a JVM intrinsic with no crossing. + +### What this replaces + +Composition for string-carrying operations, at every API level. `getAttr` composed costs +528.85 ns on API 36; interned costs 160.25 ns, and needs no `artMain` export, no per-operation +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 +iteration, measured at 11x for a 1000-element list. + +## Non-goals + +- A general `MemorySegment`/`Arena` API. Non-string buffers still need allocation, but that is a + smaller surface and not what the measurements point at. +- Vendoring PanamaPort. Its memory model is the part that mattered here, and this obtains the + same result in a few dozen lines without ART-internal offset tables or GPL vendoring. diff --git a/docs/design/object-lifetime.md b/docs/design/object-lifetime.md new file mode 100644 index 000000000..a33b44e14 --- /dev/null +++ b/docs/design/object-lifetime.md @@ -0,0 +1,282 @@ +# Object lifetime across the boundary + +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 + +CPython counts references; a Kotlin wrapper takes one on construction and gives it back when it +closes. + + Python creates the object refcount 1 + a Kotlin wrapper takes it refcount 2 + Python drops its reference refcount 1, still alive + 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 +somewhere unrelated, which is how the interpreter corruption during the lifetime work first +appeared, as a segfault inside `Py_Finalize` far from its cause. + +A wrapper's release is **GC-driven**: no `close()` is needed, and an explicit release is idempotent and +runs exactly once (SPEC M-1). `Python3.initialize()` parks its thread state with `PyEval_SaveThread()`, +so a cleaner thread's `PyGILState_Ensure` attaches instead of blocking on the GIL the initialising +thread would otherwise keep. The release path is verified on desktop (`java.lang.ref.Cleaner`), iOS +(`createCleaner`) and wasm, and Android below API 33 takes the `PhantomReference` fallback (SPEC M-2). +History: ROADMAP §1 and §4. + +On a **free-threaded** build, a decrement from a thread that does not own the object is queued to the +owner and only drained at an eval-loop checkpoint, which a pure C-API embedder never reaches; the +library therefore takes a periodic checkpoint itself (`Python3.drainPendingReleases`, +`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 + +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: + + private val liveHandles = HashMap<Long, Any>() // handle -> Kotlin object + + fun register(obj: Any): Long { val h = next++; liveHandles[h] = obj; return h } + fun release(h: Long) { liveHandles.remove(h) } + +Python holds only the integer handle; the map holds the object. This is what JNI's +`NewGlobalRef` does, made explicit. + +### Who calls `release`, and what happened when nobody did + +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 +`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. + +It was found by a measurement that was not looking for it. `GeneratedProxyCostTest`'s constructor +row builds ten thousand proxies and discards every one; the desktop run ended with **78 002 live +handles**, and the constructor figure was unusable because every row in the report had been timed +against a table that grew by tens of thousands of entries while it ran. + +Two separate defects wore that one number, and it is worth keeping them apart: + +| | who owns the root | what was wrong | +|---|---|---| +| 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 +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 +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. + +Note what this does **not** solve: the table still holds *strongly*, so the `K1 -> P -> K2 -> K1` +case below is untouched. What changed is only that "Python has finished with it" now reaches Kotlin +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 +`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. + +## Cycles + +### Why reference counting cannot do it + +Not a boundary problem — an inherent limit of reference counting, in any language: + + a = []; b = [] + a.append(b); b.append(a) + del a, b # each still holds one, count never reaches 0 + +This is why CPython carries a cyclic collector on top of refcounting. So "refcounting cannot +free cycles" is not a defeat; it was never refcounting's job. + +### What the cyclic collector needs + +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 +4. `tp_clear` drops those references and breaks the cycle + +**Everything depends on step 1 being able to see through whatever holds the reference.** + +### The cycle we can create + + P -> PK (Python proxy) -> [handle] -> K (Kotlin) -> PyObject wrapper -> P + +Without help this never collects, and each collector has a defensible reason: + +- CPython's GC traverses `PyObject`s. `PK` holds an opaque integer, so traversal stops there and + `P` looks externally referenced. +- The JVM's GC collects cycles fine, but `K` is held by a strong entry in the handle map, which + is a GC root. + +Each side sees a live root it cannot reason about, and concludes the other is responsible. + +### The resolution + +Make the Kotlin object's Python references visible to `tp_traverse`. `PK`'s traverse reaches +through the handle and enumerates the `PyObject`-typed fields of `K`; CPython then sees the +cycle as internal and can collect it. + +**This is cheap here specifically because the exposure table is generated at build time.** KSP +already walks every exposed class to build the function table, and at that point it knows which +fields are `PyObject`-typed. A traverse entry is one more generated function: + + // generated + fun traverse_MyClass(obj: MyClass, visit: (Long) -> Unit) { + obj.someField?.let { visit(it.pointer.toRawValue()) } + obj.otherField?.let { visit(it.pointer.toRawValue()) } + } + +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. + +### What is still hard + +**`tp_traverse` runs during collection**, and CPython requires it not to allocate or run arbitrary +code, while ours has to call into the JVM. The generated traverse is therefore restricted to field +reads and `visit` calls. `ProxyTypeFactory`'s `tp_traverse` (every target) calls only the generated +`traverse` function and `visit`; `CycleCollectionTest` / `WasmCycleCollectionTest` / +`RefHolderCycleCollectionTest` exercise it through a real `gc.collect()` (SPEC M-3). + +**`tp_clear` needs no Kotlin field mutation.** The first design assumed breaking the cycle meant +nulling `K`'s `PyObject` fields (so they would have to be `var`, or cleared through a generated +accessor). That is not what was built. `ProxyTypeFactory.tp_clear` (`desktopMain/.../ProxyTypeFactory.kt`, +mirrored on every other target) never touches `K`'s fields; it calls `HandleTable.release()` on `PK`'s +handle and nothing else, deliberately not `close()`-ing the held `PyObject`s: CPython's own cyclic +collector, not this callback, reclaims the other side, and `close()` here would double-free. Dropping +the strong root `PK -> [handle] -> K` is enough to let the JVM collect `K` once CPython's collector +also breaks `P`'s incoming edge. `tp_dealloc` is overridden separately for the non-cyclic route; the +two are safe to run one after the other (`HandleTable.release` is idempotent). + +**Cycles that close on the Kotlin side.** `K1 -> P -> K2 -> K1` needs the JVM's collector to see +through `P`, and there is no traverse hook on that side. Worse, the handle map holds `K2` +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 +using it" becomes "the JVM may collect it". That inverts who owns the strength and has not been +worked through. + +### Status of an older claim + +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 +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. + +## Conversion caching, and where it stops + +`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 +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. + +What makes the cache safe is a **per-type rule**, because a cached native value and the +`PyObject` it came from have different lifetimes. Caching a value that copied itself out of +CPython is fine; caching one that still points into Python-owned memory is a use-after-free +waiting for the address to be reused. The rule below is derived from what each conversion +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 +`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<Any?>` | no — every element is itself one of the rows above | yes, as a **snapshot** | +| `dict` | `toNativeMap()`, recursive over a snapshot of the entries | `Map<Any?, Any?>` | no | yes, as a **snapshot** | +| `set`, `frozenset` | `toNativeSet()`, recursive over `PySequence_Tuple` | `Set<Any?>` | 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 | + +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`, +`Double`, `Boolean`, `String` and `null`, and holds no `PyObject`, no `NativePointer` and no +borrowed C pointer. `PyValueLazyConversionTest` walks a converted nested container and fails on +anything else, so this is checked rather than asserted in prose. + +Being a copy is also what makes a container **stale** rather than dangling: mutating the Python +container afterwards does not update it. `PyProxy.invalidateNativeCache()` is the way out, and the +staleness and the recovery are both pinned by tests. + +### The rule, per strategy + +| `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 | +| `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 | + +### How the rule is enforced + +`isIndependentOfPythonMemory` in `PyProxy.kt` is the rule as code: `true` for `null`, the Kotlin +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 +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. + +### The mirror-image rule, and the bug it caught + +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 +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 +`PyValueLazyConversionTest.pyContextGivesEveryPyValueItsOwnReference`, and fixed by taking an +independent reference on the fallback path. Both branches now hand the `PyValue` a wrapper nobody +else holds. + +### What this does not cover + +- **`bytes` and `bytearray` convert through `hex()`, not through the buffer.** This entry used to + say they had no conversion at all and that the bindings could not get there. The second half is + still true: `PyBytes_AsString`/`PyByteArray_AsString` are declared to return `String?`, so each + platform decodes the buffer as a NUL-terminated UTF-8 string and truncates binary data at the + first zero byte. They cannot represent arbitrary bytes. + + 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 + `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 + deliberate and consistent with `pyObjectToNative`, but it means `class MyInt(int)` gets no + conversion even though `PyLong_AsLongLong` would work on it. +- **The cache is not synchronised.** Two threads converting the same proxy can both convert; the + result is idempotent, so this costs work rather than correctness. +- **A self-referential container recurses.** `pyObjectToNative` has no visited set, so a `list` + that contains itself has no defined behaviour here. Not measured; not reached by any current + caller. diff --git a/docs/design/pyi-generation-design.md b/docs/design/pyi-generation-design.md new file mode 100644 index 000000000..59eb1732c --- /dev/null +++ b/docs/design/pyi-generation-design.md @@ -0,0 +1,631 @@ +# 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 +> `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 +> references still resolve; sections that moved say so where they used to be. +> +> **Revised again for issue #31 (typed stubs).** The stub no longer annotates every object as `int`: +> it names the declared Kotlin type with a stub class per bound type, puts each extension function on +> its receiver's class as well, and states function types, nullability, value classes and overload +> sets (§3.1, §3.3–§3.6, §4.1). Everything is still under Kotlin names; the "`Dp | float`, `@overload` +> sets, `Modifier` callback-Protocol shape" that the paragraph above says left the plugin are back +> **as Kotlin-name typing**, not as a Pythonic product -- no snake_case, no `pythonx`. + +The binder serves Kotlin-named modules (`androidx.compose.foundation.layout`, `junit.runner.Version`, +a consumer's own package) that exist only as `sys.modules` entries made at run time +(`PythonProxySource`, and lazily `python_multiplatform.binding`). Nothing an editor can see is ever +enumerated. `.pyi` is what pays that back: the stubs carry the fully enumerated surface **under the +Kotlin names**, so an IDE sees every bound declaration while the runtime enumerates nothing. Making +that surface Pythonic is pythonx-compose's job, which reads `inspect.signature` and +`python_multiplatform.describe` at run time (`KotlinSurface.kt`) and ships its own stubs. Generation +belongs to the Gradle plugin, the way PyREPL did it (`AGENTS.md` §12.5); that is settled and this file +does not revisit it. + +What this file answers: **what the generator reads**, **what it writes**, and **where the result +goes**. The generator is `PythonStubsTask` + `renderKotlinFqnStubs` (`PyiRendering.kt`, in +`python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/`); its tests are +`PyiRenderingTest`, `KotlinNamesOnlyStubTest` and `ksp-fixtures/artifact`'s `WalkedArtifactStubTest`. + +Everything under "Observed" was read from working copies or run as a command on 2026-08-14, with the +path or the command given, unless dated otherwise. Everything under "Judged" is a decision, and the +alternatives it was chosen over are named. "Current" statements were checked against the code on +2026-10-03 by reading it; no Gradle build was run for this revision. + +--- + +## 0. What was read, what was run, and what was not + +### Read + +| 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` | +| 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 | +| plugin wiring | `python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt` lines 145–344 | §6 | +| the Python package being stubbed | `/Volumes/macMini/thisisthepy/pythonx-compose/pythonx/**` | §5 (now archived) | +| Compose desktop jars | `/Volumes/macMini/thisisthepy/pythonx-compose/pythonx/compose/lite/release/app/*.jar` | §5 (now archived) | + +### Run + +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. +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 + 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. +- **`toolchain-legacy` has no stub generator.** `grep -rIl pyi` over the tree returns one file, + `toolchain/android/toolchaincl.py`, and the hit is inside the word `copying`. It is a pure-Python + kivy fork and contributes nothing here. +- **PyCharm / IntelliJ.** Every claim in §4 about what a checker infers was measured **with mypy + only**. PyCharm's Python plugin has its own inference engine and was not tested; neither was + pyright (no `node` on this machine). This matters more than usual, because the IDE a user of this + stack is in is most likely IntelliJ or Android Studio. **Marked as needing verification + 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 + document. + +--- + +## 1. Observed — PyREPL's generator + +### 1.1 The pipeline + +`app/build.gradle.kts` registers one task, `createKotlinMetaPackageForPython`, and hooks it onto the +IDE's own import step: + + project.tasks.getByName("prepareKotlinIdeaImport").dependsOn(createKotlinMetaPackageForPython) + +`resolveDependenciesForPython()` (line 211) walks `kotlinExtension.sourceSets`, and for each source +set makes a **resolvable copy** of its `implementation` and `api` configurations: + + configurations.create(configuration.name + "Resolved") { extendsFrom(configuration); isCanBeResolved = true } + +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 +unresolved and printed. + +The output directory is derived per source set: from `sourceSet.kotlin.srcDirs`, take the one under +`<name>/kotlin`, and emit into its sibling `generated/meta` — i.e. `src/<sourceSet>/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: + + chaquopy { sourceSets { getByName("main") { srcDirs( + "src/androidMain/python", "src/androidMain/generated/meta", + "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** — +`outputs.upToDateWhen { false }` and the `if (!isChanged) return@doLast` guard is commented out, so +the task runs every time. + +`processJarForMetaPackage` reads every `.class` in a jar with ASM into a `ClassNode` map, then emits +for each class whose binary name has no `$` and which is not `private`/`protected`. + +### 1.2 What it writes + +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. + + class Version: + + id: String + + 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 +value-class mangling suffix, discarded rather than decoded; skip numeric (anonymous) nested classes; +emit `...` for a class that ends up empty. + +Types come from `toReadableType` (line 486), which maps JVM descriptors to **JNI-ish names**: `I` → +`jint`, `J` → `jlong`, `Z` → `jboolean`, `V` → `jvoid`, `[X` → `X[]`. Only fields get a type +annotation; every method is `def name(self, *args, **kwargs): ...`. `class $className:` is emitted +unconditionally, so a Kotlin file facade appears as a Python class named `StringsKt`. + +Collisions between `commonMain` and a platform source set are handled by `checkForOverlappingClass` +(line 525): find `__init__.pyi` paths present in both trees, rename **both** to +`_<Class>.pyi` and `_<Class>_<target>.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` +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` + +`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`: + + fun createMetaPythonFile(node: ClassNode, outputDir: File): File { + ... + return File(packageDir, "$className.py") // f0dd368, and today's createMetaClass.kt + } + +So the runtime `.py` variant is not a mutation of the stub variant. **The lineage that became +`toolchain` never carried the `.pyi` generator at all**; PyREPL's `.pyi` version is a separate +development on the PyREPL side (per-package `__init__.pyi` instead of per-class `.py`, `...` bodies +instead of `jclass` delegation, the overlap/re-export pass, and a `-` filter applied to fields as +well as methods). Anyone porting "PyREPL's generator" must take it from +`/Volumes/macMini/PyREPL/app/build.gradle.kts` and not from `toolchain`, and the diff between them +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 +this repository's `KotlinMetadata.kt` acted on, two years later. + +### 1.4 What survives the port + +| PyREPL | keep? | why | +|---|---|---| +| resolvable copy of `implementation`/`api` per source set | **superseded** | `PythonArtifactBindingsTask` already takes `configuration.incoming.artifacts`, which carries coordinates as well as files | +| per-package `__init__.pyi` | **keep** | it is what makes `from androidx.compose.material3 import Text` resolve, and §6.1 confirms a directory of `.pyi` with no `.py` and no `__init__.py` resolves | +| `...` bodies | **keep** | a stub never has to compile or run | +| `def f(self, *args, **kwargs)` | **reject** | it is the whole of what makes these stubs useless for anything but name completion. Parameters are the point (§3) | +| JNI-ish type map (`jint`, `jlong`) | **reject** | not Python types; a checker infers nothing from them | +| drop names containing `-` | **reject** | `docs/design/kotlin-extensions-in-python.md` §2.2: it discards 52 of the 177 chainable `Modifier` extensions, and metadata supplies the real name | +| `class StringsKt:` for a file facade | **reject** | there is no `StringsKt` in the Kotlin namespace; a top-level function must be a module-level `def` | +| commonMain/platform overlap → rename + re-export | **keep the idea, rewrite** | the shape is right and the implementation is Windows-only and silent (§1.2) | +| `prepareKotlinIdeaImport` dependency | **keep** | §6.2 | +| `meta_json.lock` | **reject** | Gradle's own up-to-date checking does this, and PyREPL's was disabled anyway | + +--- + +## 2. The input — judged + +### 2.1 Observed (2026-08-14): what the two producers kept then + +> Current: the loss described here is repaired for the stub path by `DeclarationModel` (§2.2). The +> table is kept because it is the reason the model exists. + +Both producers exist and both already read the right *sources*. Neither keeps enough of what it read. + +`python.multiplatform.ksp.CallableEntryModel` (`Model.kt` lines 9–24) and +`python.multiplatform.gradle.artifact.ArtifactCallable` (`ArtifactRendering.kt` lines 26–37) are +field-for-field near-identical: + +| field | `CallableEntryModel` | `ArtifactCallable` | +|---|---|---| +| `name` | Kotlin FQN | Kotlin FQN | +| `arity` | yes | yes | +| `paramTags` | `List<Tag>` | `List<String>` | +| `returnTag` | `Tag` | `String` | +| `kind` | `"FUNCTION"`, `"METHOD"`, … | — (always FUNCTION) | +| `lambdaBody` | generated Kotlin source text | generated Kotlin source text | +| `isSuspend` | yes | — (always false) | +| `imports` | — | extension-call aliases | + +`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 + 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 + (`KotlinMetadata.kt` line 206), so the fact never leaves the scanner +- the **declared Kotlin type** — `Dp` becomes `Tag.FLOAT`, `List<String>` 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` + 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 + inside a closure to build a wrap/unwrap expression, never surfaced + +### 2.2 Judged: the input is a third representation, upstream of both renderers + +**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 +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 +`renderFragmentSource` must ignore, on a path where every field today is load-bearing. + +So: **one declaration model, produced by each scanner, consumed by two renderers.** + + KSP (KSFunctionDeclaration) ─┐ + ASM + kotlin-metadata-jvm ─┼─► DeclarationModel ─┬─► binding renderer (exists) + LibraryAbiReader (klib) ─┘ └─► .pyi renderer (this document) + +**Current (2026-10-03):** implemented as `DeclarationModel` / `DeclaredParameter` / +`KotlinTypeModel` / `ValueClassModel` in +`python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt`. +It is produced by `ArtifactScanner.scanDeclarations` (jars) and `KlibScanner` (klibs, through the +isolated worker), in the same walk that produces the bindings. **KSP does not produce it**, so the +consumer's own KSP-bound declarations get no stub. Two differences from the plan below: the model +carries each declaration's **boundary tags** (`receiverBoundaryTag`, `DeclaredParameter.boundaryTag`, +`returnBoundaryTag`) and its table key (`bindingName`) or `declineReason`, because the stub annotates +what crosses, not what Kotlin declares (§3.1); and of the annotations it keeps only `isComposable` +(no `@Deprecated`). + +`DeclarationModel` carries, per declaration: Kotlin qualified name; owner; whether it is a top-level +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 — +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: + +1. **The two renderers cannot drift.** `docs/design/kotlin-extensions-in-python.md` §4.5 requires this and + the current code cannot deliver it: a `.pyi` written from `ArtifactCallable` would state + `padding(a0: float)` while the binding calls `Modifier.padding(Dp(...))`. +2. **Overloads survive to where they are needed.** The binder drops an ambiguous name because it + cannot dispatch (`docs/roadmap/ROADMAP.md` §16c: `Assert.assertEquals` has eight). A stub *can* state all of + 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__<types>` 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 + it. Today a declined declaration returns `null` and vanishes. + +**Rejected alternatives:** + +| alternative | why not | +|---|---| +| 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") | +| 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` | +| 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 | + +That the first two need **no new reading** is the strongest argument for this shape: the information +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 + +**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 +tag as the tiebreaker, and the Kotlin signature stays in the docstring: + + def padding__Dp(receiver: androidx.compose.ui.Modifier, /, all: androidx.compose.ui.unit.Dp | float) -> androidx.compose.ui.Modifier: + """Kotlin: androidx.compose.ui.Modifier.padding(all: androidx.compose.ui.unit.Dp): androidx.compose.ui.Modifier""" + ... + +| Kotlin | `.pyi` | why | +|---|---|---| +| `Boolean` | `bool` | | +| `Byte` `Short` `Int` `Long` | `int` | the boundary carries every one as `Long`; Python has one integer type | +| `Float` `Double` | `float` | | +| `String`, `Char` | `str` | | +| `ByteArray` | `bytes` | | +| `Unit` | `None` | | +| `Any` | `typing.Any` | | +| any other class or interface | **a stub class in the module of its own Kotlin package** (`class Modifier` in `androidx/compose/ui/__init__.pyi`), referenced as `androidx.compose.ui.Modifier` from other modules and bare from its own | the value crosses as a `HandleTable` integer and the binding layer wraps it in a proxy class named for the declared type (`PythonxAdapter._wrap`). A nested class is nested in its outer class's stub | +| a class that extends another | `class BitmapPainter(Painter)` | the nearest supertype the scanner read off the jar (`DeclarationModel.returnSupertypes`, the same list `_is_a` accepts a value for); only for types some bound declaration returns | +| value class bound as its primitive (`Dp`) | `Dp \| float` as a parameter, `float` as a result | a result is the raw number (`_wrap` only wraps `OBJECT`); the raw module accepts the number, and the binding layer's allowlist (`allow_raw_primitive`) decides at run time, which no stub can know | +| value class the binder cannot open (`TextUnit`) | its own class | it crosses as an object handle | +| `(A, B) -> R` | `Callable[[A, B], R]` | `kotlin.FunctionN`'s type arguments; `@Composable` and a receiver are not expressible and not faked | +| `T?` | `T \| None` | `KotlinTypeModel.isNullable` | +| type arguments of a non-function class | dropped (`State<Boolean>` is `State`) | the proxy class is one per Kotlin class | + +`typing` is imported as `_t` (`import typing as _t`) because a Kotlin declaration may be called `Any` +or `Callable`; other modules are imported whole (`import androidx.compose.ui`) and referenced +qualified, so a class never collides with a name the importing module defines. + +**A class whose name is also a function in its module falls back to `typing.Any`.** Compose has +`PaddingValues(...)` the factory and `PaddingValues` the interface, `Color(...)` and `Color`, `Dp(...)` +and `Dp`. One Python module cannot define both, and the runtime resolves the name to the function +(`_adapt` asks `_BY_PACKAGE` first), so the type is not reachable there either. In Compose 1.11.1 this +turns `PaddingValues`, `TextStyle`, `Color`, `AnnotatedString` and `Dp` itself into `Any` (`Dp` then +collapses to its `float`). It is the honest loss of this design, not a bug to chase: the alternatives +are a differently named class (a renamed spelling the binder does not have) or a private side module. + +**Stub paths are case-unique** (issue #44). `androidx.compose.ui.graphics.Shadow` (a class the runtime +exposes as a module) and `...graphics.shadow` (a package) would be one directory on macOS and Windows. +The rule: a class module whose path equals a sibling's under case folding (directories above a module +count) is emitted in its parent package's `__init__.pyi` -- as the class `Shadow`, its functions as +`@staticmethod`s, nested class modules as nested classes -- and the package keeps `shadow/__init__.pyi`. +In each colliding group the all-lower-case spelling is the package and keeps its path. A class module +without a collision still gets its own `<Class>/__init__.pyi`. The runtime import path is unchanged; only +the stub layout moves, and `from androidx.compose.ui.graphics import Shadow` resolves to the class. +If the parent package already defines a function of the same name the class cannot sit beside it, and +nothing is emitted for that module (the runtime resolves the name to the function too). +`StubCaseLayoutTest` asserts no two emitted paths are equal under `lowercase()`; +`PythonStubsTask` still warns (`caseCollidingPaths`) as a backstop, and the CI job runs on macOS as well. + +### 3.2 Java declarations have no types to map + +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 +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 — + + def assertEquals(__a0: int, __a1: int, /) -> None: ... + +— 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. + +This is a real asymmetry between a Kotlin jar and a Java jar and it should be visible in the output, +not hidden. + +> **Correction (2026-10-03):** the sentence above about `| None` on Java reference returns describes +> the plan, not the code. The current stub annotates a Java return by its boundary tag like any other +> (`def id() -> str:` for `junit.runner.Version.id`, asserted by `PyiRenderingTest` and +> `WalkedArtifactStubTest`); no `| None` is added. Positional-only `__a<index>` parameters for a Java +> declaration without `-parameters` are implemented as described (`parameterNamesKnown == false`, +> `PyiRenderingTest.aJavaDeclarationGetsAnonymousPositionalParameters`), under the overload-suffixed +> table key: `def assertEquals__Long_Long(__a0: int, __a1: int, /) -> None:` (§3.6). + +### 3.3 Nullability + +**Current.** The binder's gate: a nullable **object** binds (the handle is already nullable at the +boundary; `UpcallTrampoline.toKotlin` maps Python `None` to `null`), and `String?` and `ByteArray?` +bind; a nullable numeric or `Boolean` primitive and a nullable value class stay declined +(`resolveKotlinBoundary` and `nullablePrimitiveBoundaryTypeOf` in `KotlinMetadata.kt`). The stub marks +every nullable it renders `T | None`, for parameters and results alike +(`TypedStubTest.aFunctionTypeIsCallableAndANullableTypeIsOptional`). + +### 3.4 Value classes + +**Current.** A value class the binder binds as its primitive (`Dp`: public constructor, wraps a +`Float`) is `Dp | float` as a parameter and the raw `float` as a result; the class `Dp` is stubbed in +`androidx.compose.ui.unit` with a docstring saying what it wraps. A value class whose wrapper cannot be +opened from outside its module (`TextUnit`, every `packedValue` class) crosses as an object handle and +is an ordinary class in parameters and results. `Color`'s public constructor takes `ULong`, which +`kotlinPrimitiveBoundaryTypeOf` has no entry for, so it is an object handle too. + +The raw-primitive **allowlist** (`docs/design/kotlin-extensions-in-python.md` §4.4: `Dp` yes, `TextUnit` +and `Color` no) is not a stub concern. It is enforced at run time by the binding layer, where it starts +**empty** and is filled by pythonx-compose through `allow_raw_primitive` (`PythonxAdapter.kt`), so the +stub states the widest thing the raw module accepts (`Dp | float`) and the layer above narrows it. +The mypy evidence for the asymmetry (`Text(fontSize=16)` rejected, `padding(16)` accepted) is archive +§3.4; it needs the allowlist in the stub, which belongs to the Pythonic layer. + +### 3.5 Function types, and Compose's `content` + +**Current.** A function-typed parameter crosses as `OBJECT` (`ArtifactScanner.functionSlotOrNull`): +the runtime turns a Python callable into a Kotlin `FunctionN`. The stub says `Callable[[A], R]` from +`kotlin.FunctionN`'s type arguments (arguments the callback *receives* are rendered as results, the +callback's own result as a parameter); `Unit` is `None`. `@Composable` on a lambda and a function type +with a receiver (`ColumnScope.() -> Unit`, which the metadata spells as a leading argument) are not +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 + 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 + parameter name, where a positional-only prefix is already in play and a default is marked only when + no required parameter follows it. `StubSignatureAgreesWithRuntimeTest` compares name, kind, default + and type against `inspect.signature` for every stubbed function of three real modules. + +**Suspend functions are not stubbed** (`renderKotlinFqnStubs` filters `isSuspend`), although the +runtime makes them awaitable (SPEC U-5). §7. + +### 3.6 Overloads + +**Current.** `ArtifactScanner.disambiguateOverloads` gives every member of an overload set its own +table key, `name__<types>` (`padding__Dp`, `padding__Dp_Dp`, `padding__Dp_Dp_Dp_Dp`, +`padding__PaddingValues`; a group of one keeps the bare name; +`docs/design/kotlin-extensions-in-python.md` §3.1). The stub emits **one `def` per table key**, and +also the **base name** the binding layer serves (`padding`, `_Overloads` in `PythonxAdapter.kt`) as an +`@overload` set **in table-key order**: the artefact table is sorted by name (`ArtifactScanner.scanJar`), +the binding layer indexes candidates in that order and `_Overloads` tries them in it, and a checker +takes the first match, so the stub's order and the dispatcher's are one order. For `padding` that is +`all`, `horizontal/vertical`, `start/top/end/bottom`, `paddingValues`. A base name is stubbed whenever a +key has an overload suffix, even for a group of one (the runtime serves it), and not when a declaration +already has that exact name. + +`WalkedArtifactStubTest.everyStubbedFunctionIsATableKeyAndEveryTableKeyIsStubbed` asserts both +directions: every stubbed `def` is a table key or the base name of one, and every key is stubbed. + +Python's one `int`/`float` and a class that fell back to `Any` cannot tell two Kotlin overloads apart +(`lerp(Int, Int, Float)` and `lerp(Float, Float, Float)`), so mypy reports every later overload as +`overload-cannot-match`. The runtime dispatcher tells them apart and the explicit spelling reaches each, +so each overload after the first carries `# type: ignore[overload-cannot-match]` +(`TypedStubTest.everyOverloadAfterTheFirstSilencesTheCheckersNeverMatchedDiagnostic`). + +Parameters are keyword-capable under their **Kotlin** names (`all`, `fraction`, `alignmentLine`); +nothing is converted to snake_case (`KotlinNamesOnlyStubTest.kotlinNamesAreNeverSnakeCased`). A name +that is not a Python identifier or is a Python keyword, or an unknown name, forces positional-only up +to and including that parameter, spelled `__a<index>` +(`KotlinNamesOnlyStubTest.aParameterNamedLikeAPythonKeywordForcesAPositionalOnlyPrefix`). A Kotlin +default is `= ...`, because metadata carries `declaresDefaultValue` but not the value. + +## 4. Extension functions + +### 4.1 Placement (unchanged from `docs/design/kotlin-extensions-in-python.md` §4.1) + +| receiver | count | placement | +|---|---|---| +| 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` | + +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. + +> **Current (issue #31):** every extension function stays a module-level `def` in the module of its +> declaring package, with the receiver as the first positional-only parameter named `receiver` +> (`def fillMaxWidth(receiver: androidx.compose.ui.Modifier, /, fraction: float = ...) -> +> androidx.compose.ui.Modifier:`), **and** an extension whose receiver is a class is also a **callable +> attribute of that receiver's stub class**, because the runtime attaches it there (`PythonxAdapter._attach`, +> SPEC U-7) under both the Kotlin name and every `name__Types` key: +> +> class _Modifier_padding(_t.Protocol): +> @_t.overload +> def __call__(self, all: float) -> Modifier: ... +> ... +> class Modifier: +> padding: _t.ClassVar[_Modifier_padding] +> padding__Dp: _t.ClassVar[_Modifier_padding__Dp] +> +> A plain method cannot say both `Modifier.padding(16)` and `m.padding(16)` (the first binds `16` to +> `self`); a `ClassVar` of a `Protocol` with `__call__` can, and is the shape archive §4.4 measured +> with mypy. The receivers that stay module-level only are the ones the runtime cannot attach to: +> Kotlin primitives and `String`, function types, and value classes (never a proxy instance). The +> protocol classes are private (`_Receiver_name`), carry the Kotlin name unchanged and exist only for a +> type checker. `Modifier.padding(...)` at run time needs `register_empty` for `Modifier`; the stub does +> not know whether a consumer called it. + +### 4.2–4.4 Moved + +The `Modifier` metaclass proposal, its failure in CPython and mypy, and the callback-Protocol +attribute shape that replaced it are a Pythonic stub design: archive §4.2–§4.4. + +### 4.5 Member extensions are still out + +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 +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 — +`androidx/compose/foundation/layout/__init__.pyi`, `junit/runner/Version/__init__.pyi` for a static on +a class — and nothing is emitted under `pythonx/` +(`KotlinNamesOnlyStubTest.theStubLivesUnderTheKotlinModulePathAndNothingLivesUnderPythonx`, +`WalkedArtifactStubTest.theStubsUseKotlinNamesAndNothingIsExportedUnderPythonx`). 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 + +Of the two products the original §5.3 named, only the first remains: + +| product | namespace | content | +|---|---|---| +| Kotlin-FQN stubs | `androidx.compose.material3`, `junit.runner`, … | 1:1 with the table keys `PythonProxySource` publishes into `sys.modules`; Kotlin names, declared-type annotations with a stub class per bound type, extension functions also as callable attributes of their receiver's class, the Kotlin signature in each docstring | + +It is the direct descendant of PyREPL's output and the only thing an IDE can see for those modules, +since they exist only as runtime `sys.modules` entries. The Pythonic stubs a user of `pythonx` +imports against are pythonx-compose's product. + +--- + +## 6. Where the files go, and how an IDE picks them up + +### 6.1 Observed: what makes stubs take effect + +Of the mypy runs in `/Volumes/macMini/tmp/pyicheck`, the two that bear on the Kotlin-FQN product +(measurements 1 and 2, about `.pyi` beside a `pythonx` `.py` and `py.typed` placement, are in archive +§6): + +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 + accompanying files. +4. Namespace packages need no `__init__.py` anywhere above the stub; mypy's `--namespace-packages` + is on by default. + +### 6.2 Judged: build directory, plus a registered root + +PyREPL wrote into the **source tree** (`src/<sourceSet>/generated/meta/`) and got away with it +because chaquopy defines a Python source-directory concept a Gradle plugin can add to. This +repository has no equivalent: the current plugin puts generated *Kotlin* into +`build/generated/pythonArtifactBindings/<sourceSet>` and registers it via +`sourceSet.kotlin.srcDir(task)` (`PythonBindingsPlugin.addKotlinSourceDirectory`), which works only +because the Kotlin compiler is the consumer. **Nothing compiles a `.pyi`.** + +So: + +- **Emit to `build/generated/pythonStubs/<sourceSet>/`.** Generated output does not belong in the + source tree; `.gitignore` churn and stale files from a removed dependency are exactly what + `PythonArtifactBindingsTask` already deletes the whole output directory to avoid. +- **Keep PyREPL's `prepareKotlinIdeaImport` dependency.** It is the one hook that makes the stubs + exist before the IDE indexes, and it costs one line. +- **Register the directory wherever the environment has a mechanism**: chaquopy `sourceSets.srcDirs` + 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 +`build/generated/pythonStubs/<sourceSet>/`, 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 +§6). + +### 6.3 The commonMain/platform overlap + +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 +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 +merges an overlap. + +--- + +## 7. Open + +- **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 + 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. +- **The consumer's own KSP-bound declarations** are not stubbed: KSP does not produce + `DeclarationModel` (§2.2), although §2.3 records it would need to read nothing new. +- **Generic declarations.** `BindingPolicy` rejects them, so they are not stubbed; if the binder ever + 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 + is chosen. +- **IDE pick-up** (§6.2): registering `build/generated/pythonStubs/<sourceSet>/` with an interpreter + or IDE is per-environment and none of it is implemented or verified; PyCharm was never tested + (§0). Only mypy has seen the typed shape (§8). +- **commonMain/platform overlap** (§6.3) is not implemented. + +## 8. Checked with mypy, and published + +- `tools/stubs/check-stubs.sh <stub-dir>` runs `mypy --strict` over `tools/stubs/consumer.py` (a + `Modifier` chain from the class and from an instance, `Checkbox`, `Text`, `Column` with its keyword + `content`, and four lines that must be rejected) against stubs generated for the real Compose jars + (`:ksp-fixtures:compose:generatePythonStubs`, which `ksp-fixtures/compose` now enables). Errors inside + the stubs are reported as well, not only in the consumer. +- `.github/workflows/stubs.yml` runs the stub task and that check on every push to `develop`, uploads + the stubs as the workflow artifact `kotlin-stubs` (with a README naming the Compose version and the + producing commit) and attaches `kotlin-stubs.zip` to the release of every `v*` tag (issue #32). diff --git a/docs/design/threading-and-abi.md b/docs/design/threading-and-abi.md new file mode 100644 index 000000000..da806838e --- /dev/null +++ b/docs/design/threading-and-abi.md @@ -0,0 +1,127 @@ +# Threading model and Stable ABI + +## Decision + +Parallelism comes from **free-threaded CPython**, not from per-interpreter GIL sub-interpreters +(AGENTS.md §12.8). + +The library supports **two flavours**, chosen at build time: + +| Interpreter | Threading behaviour | Status | +|---|---|---| +| `python3.X` (GIL) | Kotlin threads each attach a thread state and serialise on the GIL | default (`pythonFreeThreaded=false` in `gradle.properties`) | +| `python3.Xt` (free-threaded) | Each Kotlin thread talks to its own free-running Python thread | **3.14t works on desktop**, opt-in with `-PpythonFreeThreaded=true` | + +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 +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). + +## Why not per-interpreter GIL + +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 — +verified with `nm` on `libpython3.14.dylib`: + +``` +00000000003009bc T _Py_NewInterpreterFromConfig +``` + +and `PyInterpreterConfig` is only seven ints, so defining it on our side is not hard: + +```c +typedef struct { + int use_main_obmalloc; + int allow_fork; + int allow_exec; + int allow_threads; + int allow_daemon_threads; + int check_multi_interp_extensions; + int gil; // PyInterpreterConfig_OWN_GIL = 2 +} PyInterpreterConfig; +``` + +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 +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. + +## Stable ABI on free-threaded builds + +CPython's Limited API / Stable ABI (`abi3`) is not offered by free-threaded builds in 3.13 and 3.14. +That does not block this project: the ~330 bindings are symbols resolved dynamically at run time, and `Py_LIMITED_API` is not defined anywhere in the build, so no +compile-time ABI contract is in play (SPEC T-2). What matters is that the symbols exist in the +free-threaded `libpython3.14t`, and the suite passing on it shows they do. The cost of the +free-threaded name changes is in the loader: such an install ships `libpython3.14t.dylib`, +`lib/python3.14t/` and `bin/python3.14t` only (`Versions.abiFlags` carries the `t`; ROADMAP §9). + +[PEP 803](https://peps.python.org/pep-0803/) (`abi3t`, targeting 3.15) would make a Stable ABI +available on free-threaded builds for extension *modules*. What it asks of us if we ever adopt it: + +| 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. | +| `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` +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. + +The two PEPs are not in conflict: PEP 803 explicitly frames `abi3t` as a transitional state ahead of +`abi2026`. + +If PEP 809 lands, the plan simplifies: + +| | PEP 803 only | With PEP 809 | +|---|---|---| +| Kotlin artefacts | two (abi3 / abi3t) | **one** (abi2026) | +| Interpreter distributions | two (`3.X` / `3.Xt`) | two (unchanged) | + +Design for two artefacts, but keep the split shallow enough to collapse later. + +## Prebuilt availability + +Desktop (macOS arm64/x86_64, Linux x86_64, Windows x86_64) has GIL and free-threaded builds on the +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 — +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. + +## Thread-state management + +Every C API call holds an attached thread state (AGENTS.md §16, `commonMain/README.md`): +`withGIL { }` (`python/multiplatform/ffi/GILScope.kt`) calls `PyGILState_Ensure`/`Release` at the +outermost scope with a per-thread nesting counter, and `withoutGIL { }` wraps +`PyEval_SaveThread`/`RestoreThread` for long non-Python work. `Python3.withPython` goes through +`withGIL`. This is needed on **both** flavours: free-threading removes contention on a global lock, +not the requirement that a thread be attached before it touches any object. + +Entry points reached from C (upcalls) take their own unconditional `PyGILState_Ensure` instead of +trusting the nesting counter (`UpcallTrampoline.attached`; reason in `commonMain/README.md`). + +Consequences specific to free-threading, all recorded in ROADMAP §9 and +`docs/design/object-lifetime.md`: + +- Deallocation of an object decref'd by a non-owning thread is deferred to the owner's eval-loop + checkpoint; a pure C-API embedder never reaches one, so the library takes a periodic checkpoint + itself (`maybeReachEvalCheckpoint`, `Python3.drainPendingReleases`; `Python3.autoDrainInterval` defaults on for free-threaded builds and off on the GIL build). +- Kotlin-side caches become concurrently accessed. `PyType.getInstance` still memoises into a plain + `mutableMapOf` (`python/multiplatform/ffi/PyType.kt`), which relies on the caller holding the GIL; + under free-threading that is a data race once two Kotlin threads create types in parallel. The + desktop suite passing on 3.14t does not exercise that case. +- `PyGILState_Ensure` per call is expensive; the nesting counter keeps inner scopes cheap, and on a + free-threaded build widening the outermost scope costs other threads nothing. diff --git a/docs/design/typedpython.md b/docs/design/typedpython.md new file mode 100644 index 000000000..ff3bc4639 --- /dev/null +++ b/docs/design/typedpython.md @@ -0,0 +1,334 @@ +# TypedPython — 설계 제안 + +> **참고 (2026-10-03):** §4.4 가 전제하는 스텁 매니페스트(`pythonx-map.toml`, 그리고 그것을 읽어 +> `pythonx.*` 스텁을 내던 `StubManifest`/`renderPythonicStubs`)는 이 저장소의 스텁 생성기에서 +> 제거되었고(`238119b7`), **pythonx-compose 저장소로 옮겨 갔다.** 이 저장소의 `PythonStubsTask` 는 +> 이제 Kotlin 모듈 경로·Kotlin 이름의 스텁만 낸다(SPEC B-7, +> [`pyi-generation-design.md`](pyi-generation-design.md)). 아래 제안 본문은 수정하지 않았다. + +> **이 문서는 설계 제안이지 사양이 아니다.** 사양은 `docs/SPEC.md`, 의도는 `docs/INTENT.md` 에 있고, +> 이 문서의 내용을 그 둘에 반영할지는 사용자가 정한다. 아래의 이름·표기·단계 구분은 전부 논의용이며 +> 구현하면서 달라질 수 있다. + +> **결정 (2026-10-02, 사용자):** TypedPython 은 별도 레포가 아니라 **PythonMultiplatform 의 기본 +> 동작**이다. 범위는 **"검사는 기본, 컴파일은 opt-in"** — 사용자 프로젝트의 파이썬 코드는 빌드할 때 +> 항상 정적 타입 검사를 거치고, 네이티브 컴파일은 표시한 코드에만 적용된다 (§2.3). + +--- + +## 1. 동기 + +Rust 와 TypeScript 가 AI 에이전트와 잘 맞는 이유는 언어 자체보다 **컴파일러가 돌려주는 피드백 루프**에 +있다. + +- 에러가 위치와 원인을 짚는다. +- 타입이 곧 문서다. 에이전트가 읽을 계약이 코드 안에 있다. +- "컴파일된다" 가 정합성의 기준선이 된다. + +파이썬에는 이 루프가 없다. 그래서 AI 가 코드를 쓰는 비중이 커질수록 파이썬은 불리해진다. +TypedPython 의 목표는 **새 언어를 만들지 않고, 파이썬 문법 그대로 이 루프를 갖추는 것**이다. +여기에 두 가지를 더한다. + +1. 타입이 확정된 코드는 **네이티브로 컴파일**해서 앱에 싣는다. PythonMultiplatform 의 배포 목표인 + "파이썬 부분도 컴파일된 채로 들어간다" 와 같은 자리다. +2. GPU 커널을 **별도 언어(CUDA, Metal Shading Language, Mojo) 없이** 파이썬 서브셋으로 쓴다. + +## 2. 원칙 + +### 2.1 유효한 파이썬만 + +새 문법·키워드를 만들지 않는다. TypedPython 코드는 **수정 없이 CPython 에서 그대로 실행돼야 하고**, +그 상태가 디버깅 모드다. + +모조는 `fn` 같은 새 문법을 넣으면서 "파이썬 슈퍼셋" 약속에서 물러섰다. 기존 도구(IDE, 린터, 테스트 +러너, CPython 자체)와의 호환을 잃는 순간 "파이썬을 남게 한다" 는 목표와 어긋나므로 그 길을 피한다. + +### 2.2 코틀린식 계약 + +**시그니처는 명시하고, 지역은 추론한다.** + +- Rust/TS 의 관례와 같다. 공개 경계에 타입이 적혀 있어야 에이전트와 사람이 계약을 읽는다. +- Pyrefly 가 파라미터를 추론하지 않는 것과도 맞는다 (§4.1). +- 코틀린의 추론이 되는 이유는 추론기가 강해서가 아니라 **언어가 재할당을 금지하기 때문**이다. + TypedPython 은 같은 효과를 **금지 목록**으로 얻는다 (§3). + +### 2.3 검사는 기본, 컴파일은 opt-in + +축이 둘이다. **검사**는 PythonMultiplatform 빌드의 기본 동작이고, **컴파일**은 표시한 코드에만 +적용된다. + +| 등급 | 대상 / 표기 (안) | 보장 | 출력 | +|---|---|---|---| +| 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 | + +- **사용자 코드는 표시하지 않아도 검사된다.** 타입 오류가 있으면 Kotlin 컴파일 에러처럼 빌드가 + 실패한다. TS 의 `strict` 기본값과 같은 위치다. +- **서드파티는 검사하지 않는다.** 그 대신 사용자 코드가 서드파티를 부르는 지점에서, 그 패키지의 + 스텁(`.pyi`, `py.typed`)이 있으면 그것을 믿고 없으면 그 경계를 `Any` 출처로 표시한다. `Any` 가 + 사용자 코드 안으로 퍼지면 그 지점에서 에러가 난다 (§4.2). +- **컴파일은 선택이다.** `checked` 코드는 타입이 이미 확정돼 있으므로 `@compiled` 를 붙이는 비용은 + §3.1 금지 목록을 지키는 것뿐이다. +- 등급 사이의 경계에서는 **검사된 변환**이 일어난다. + +## 3. 등급 1 — checked / compiled + +### 3.1 금지 목록 + +`compiled` 코드에서는 다음을 컴파일 에러로 다룬다. `checked` 코드에서는 경고로 보고한다 — 기본 검사가 +기존 파이썬 관용구를 막아 채택을 해치지 않게 하면서, `@compiled` 로 옮기기 전에 무엇을 고쳐야 하는지 +미리 보이게 하려는 것이다. (이 경고/에러 구분은 제안이다 — §8.) + +- 변수의 타입 재할당 (`x = 1` 뒤 `x = "a"`) +- `eval` / `exec`, 이름을 문자열로 만드는 `getattr`/`setattr` +- 런타임 클래스 변경, 몽키패칭 (`compiled` 클래스는 암묵적으로 `@final` + 고정 슬롯) +- 원소 타입이 섞인 컨테이너 (`list[int | str]` 는 명시하면 허용, 추론으로 섞이는 것은 거부) + +이것이 코틀린의 `val`/`var` 보장을 파이썬 문법 안에서 재현하는 방법이다. + +### 3.2 수치 타입 + +- 일반 `int` 는 **임의 정밀도 의미를 유지**한다. 조용히 64비트로 바꾸지 않는다. +- 고정폭이 필요하면 `i32`, `i64`, `f32` 같은 타입을 쓴다. `Annotated` / `NewType` 기반으로 정의해 + **유효한 파이썬이면서 CPython 에서도 동작**하게 한다. +- 고정폭 정수의 오버플로 의미(래핑 / 디버그 빌드에서 검사)는 미결 (§8). + +## 4. 등급 1 의 구현 + +### 4.1 프론트엔드 — Pyrefly + +Meta 의 Pyrefly 를 타입 추론기로 쓴다. + +- Rust 구현이라 빠르고, 대규모 코드베이스(Instagram, PyTorch, JAX)에서 쓰이고 있다. +- 지역 변수와 반환 타입을 추론하고, `pyrefly infer` 로 애노테이션을 생성할 수 있다. +- 빈 컨테이너 타입을 첫 사용처로 정하는 등 실용적인 규칙을 갖고 있다. + +**포크하지 않는다.** 처음에는 CLI 와 기계 판독 가능한 출력 수준에서 붙이고, 라이브러리 임베드는 +API 안정성을 확인한 뒤로 미룬다. + +### 4.2 건전성 게이트 — TypedPython 고유의 일 + +타입 체커와 컴파일러는 목표가 반대다. + +- 타입 체커: 확신이 없으면 `Any` 로 물러서는 것이 미덕이다. 거짓 경고를 안 내야 하니까. +- 컴파일러: `Any` 를 만나면 최적화를 못 한다. 그것은 결함이다. + +그래서 Pyrefly 를 "통과" 한 코드가 "컴파일 가능한" 코드와 같지 않다. 이 간극을 메우는 층을 둔다. + + 사용자 코드(`checked` · `compiled`)에서 + Any · 미해결 타입 · object 가 섞인 유니온 → 경고가 아니라 에러 + 시그니처에 애노테이션 없음 → 에러 + §3.1 금지 목록 위반 → `compiled` 는 에러, `checked` 는 경고 + +**이 게이트만으로도 독립적인 가치가 있다.** 컴파일러가 없어도 "사용자 코드에는 `Any` 가 없다" 는 +보장이 생기고, 그것이 AI 친화적 피드백 루프의 핵심이다. 이후 모든 단계가 이 게이트를 전제로 한다. + +### 4.3 백엔드 — 자체 typed IR → C (결정 2026-10-03, #41) + +**Cython 은 쓰지 않는다** (사용자: "Cython 의존 별로야. 2단계로 바로 진행하도록 해"). Cython 경로 +(PR #39)는 닫았고, 거기서 검증된 의미 보존 규칙만 IR 계약으로 옮겼다. + + 소스 ─ Pyrefly 추론 ─▶ frontend ─▶ typed IR ─▶ verify ─▶ cgen ─▶ C ─▶ 플랫폼 C 컴파일러 ─▶ 확장 모듈 + (ir.py) (증명) (CPython C API + tp_runtime.h) + +- **IR 이 계약이다** (`typedpython/ir.py`). 각 노드는 CPython 동작으로 정의된다: 검사되는 i64 산술과 + 오버플로 시 deopt(인터프리터 재실행), 파이썬식 floor 나눗셈·나머지, 정확한 int/float 비교, 리스트식 + 인덱싱, `math` 모듈의 예외. C 런타임 API 는 `typedpython/runtime/API.md` 에 고정했다. +- **왜 C 인가, LLVM 이 아니라.** 모든 타깃에 C 컴파일러가 이미 있다(clang, NDK, Xcode) — 새 의존이 없다. + CPython C API 가 C 이고, 플랫폼 디버거가 그대로 쓰인다. 타입이 확정된 스칼라·배열 코드에서 중요한 + 최적화(인라이닝, 벡터화)는 C 컴파일러가 이미 한다. IR 이 안정 계층이라 LLVM 백엔드는 나중에도 붙일 수 있다. +- **왜 Rust 가 아닌가.** 파이썬의 별칭과 공유 변경은 Rust 소유권에 맞지 않아 생성 코드가 + `Rc<RefCell>`/`unsafe` 로 채워지고, 앱 빌드마다 Rust 툴체인 비용이 붙는다(사용자 확정). +- **모듈 모양.** 확장 모듈은 원래 소스를 자기 네임스페이스에서 먼저 실행하고, 컴파일된 함수만 C 함수로 + 바꾼다. 원래 함수는 `__typedpython_interpreted__` 에 남아 deopt 때 같은 전역·같은 클래스로 실행된다. +- **순서.** 스칼라 int/float/bool 과 컴파일된 함수끼리의 호출 → `list[float]`/`list[int]` 의 네이티브 + 배열 → 고정 레이아웃 클래스. + +### 4.3.1 안전성은 IR 에서 증명한다 (SPEC N-9) + +사용자 결정: "IR 단계에서 안전성을 증명하도록 해". C 를 만들기 전에 `verify.py` 가 IR 에서 증명한다 — +정의 전 사용 없음, 노드 타입 일치, 비순수 함수에 deopt 노드 없음, 배열 접근은 경계 증명 또는 런타임 검사 +유지, 배열 파라미터는 인덱싱·저장·len 만, OBJ 참조는 하나의 소유 규칙. **증명하지 못한 함수는 컴파일하지 +않고** 인터프리터에 남기며 이유를 진단으로 낸다. 생성 C 는 `tp_runtime.h` 헬퍼로만 메모리와 `PyObject` +를 다룬다. 증명 밖의 안전망: ASan/UBSan, 인터프리터와의 차등 테스트, IR 수준 퍼징. + +### 4.3.2 증분 컴파일 (SPEC N-10) + +사용자 결정: "증분 컴파일도 지원하도록 해. 빌드 속도 너무 오래 안걸리게". 단위는 모듈. 키는 소스 해시 + +의존 모듈의 **인터페이스** 해시(타입 시그니처) + 컴파일러 버전 + 플랫폼 + 빌드 플래그. 본문만 바뀌면 +인터페이스 해시가 그대로라 의존 모듈은 다시 컴파일하지 않는다. C 오브젝트는 모듈별로 캐시한다. +Gradle 태스크는 입력·출력을 정확히 선언해 up-to-date 판정과 빌드 캐시가 함께 작동한다. + +### 4.3.3 고정 레이아웃 클래스 (SPEC N-11, M2b) + +**레이아웃이 CPython 에서도 고정일 때만** 고정 접근으로 컴파일한다 — 사용자가 `__slots__` 를 직접 쓴 클래스. +컴파일러가 `__slots__` 를 몰래 더하면 `__dict__`·`vars()`·속성 추가가 CPython 과 달라지므로("같은 결과" 원칙 +위반) 하지 않는다. binary-trees 벤치의 `Node` 는 그래서 `__slots__ = ("left", "right")` 를 소스에 적어야 +컴파일되고, 그 변경은 모든 언어 열에 공평하게 적용되도록 벤치 소스 자체에 넣는다(CPython 기준선 재측정 필요). + +- **필드 접근:** `type(x) is C` 로 가드된 값의 필드는 member descriptor 의 오프셋으로 바로 읽고 쓴다. 오프셋은 + 모듈 초기화 때 클래스 사전의 descriptor 가 정말 member descriptor 인지 확인하고 잡는다. 값은 객체 그대로다. +- **순수성:** 슬롯 읽기는 사용자 코드를 실행하지 않으므로 효과가 아니다 — `check(node)` 같은 재귀 순회가 + 순수 함수로 남아 i64 경로를 쓸 수 있다. +- **생성:** `__init__` 이 매개변수를 각 슬롯에 한 번씩 대입하기만 하면 `C(a, b)` 를 할당 + 슬롯 채우기로 + 내린다. 그 밖의 `__init__` 은 객체 호출로 남는다. +- **메서드:** `self` 를 `type(self) is C` 로 가드한 함수로 컴파일한다. +- **IR 확장(예정):** `FieldGet(obj, cls, field)`, `FieldSet(obj, cls, field, value)`, `New(cls, args)`, + 그리고 정확한 타입 가드 `IsExact(obj, cls)` 조건. 검증기는 가드 없는 필드 접근, 슬롯 밖 필드, 생성자 규칙 + 위반을 거부한다. + +검토하고 기각한 경로: + +- **Cython** — 사용자 결정으로 폐기(위). +- **mypyc** — 타입 체커가 mypy 에 묶여 있어 Pyrefly 와 판정이 갈라진다. +- **Nuitka** — 동작 동일성을 우선해 타입 특수화를 하지 않는다. +- **LPython** — 업스트림 활동이 LFortran 쪽으로 옮겨간 것으로 보인다. +- **Rust 생성** — 위. + +### 4.3.3 재귀 (SPEC N-8, #57) + +호출 사이클(직접·상호 재귀)은 컴파일한다. 컴파일된 프레임에는 파이썬 프레임이 없으므로 모든 컴파일 +함수의 진입에서 `tp_enter_call()`, 모든 출구(ok·error·deopt)에서 `tp_leave_call()` 을 부른다. 진입은 +스레드별 컴파일 프레임 수에 현재 파이썬 프레임 깊이(`PyThreadState_GetFrame`/`PyFrame_GetBack`, 공개 API 만, +최상위 프레임 동일성으로 캐시)를 더해 `Py_GetRecursionLimit()` 와 비교하고, 넘으면 CPython 과 같은 +`RecursionError("maximum recursion depth exceeded")` 를 낸다. 이어서 `Py_EnterRecursiveCall` 이 C 스택을 +지킨다. **측정(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` — 인터프리터는 같은 한도에서 파이썬 한도 메시지를 낸다. +(2) 카운터는 모듈별(헤더가 `static`)이라, 한 컴파일 모듈의 두 프레임 사이에 **다른** 컴파일 모듈의 +프레임이 끼면 그 프레임은 세지 않는다. 테스트는 두 경우 모두 "둘 다 예외를 내고 죽지 않는다" 만 단언하고 +깊이를 기록한다(`test_recursion.py`). + +### 4.4 Kotlin 경계의 직접 호출 + +이 플랫폼에서만 가능한 이점이다. + +현재 Kotlin ↔ Python 호출은 `PyObject` 를 거친다. 실측 비용은 다운콜 데스크톱 약 260 ns, +업콜 데스크톱 약 550 ns, iOS 시뮬레이터 약 1.6 µs / 2.3 µs 다 (`docs/investigations/cost-table.md` 계열 측정). + +그런데 **Kotlin 쪽 타입은 스텁 생성기(`PythonStubsTask`)가 이미 안다.** `compiled` 코드가 Kotlin 을 +부를 때 양쪽 타입이 컴파일 타임에 확정되면, `PyObject` 마샬링을 건너뛰고 **C ABI 로 직접 호출하는 +코드를 생성**할 수 있다. 경계 비용을 컴파일 타임에 지우는 것이고, CPython 을 임베드할 뿐인 모조는 +구조상 할 수 없는 일이다. + +**선행 조건:** 스텁 생성기가 픽스처 대역이 아닌 실제 `pythonx-map.toml` 을 읽고, 생성된 스텁이 +IDE·타입 체커가 찾는 자리(pythonx-compose 배포본)에 설치돼야 한다. 2026-10 현재 미완료. + +## 5. 등급 2 — 별도 언어 없는 GPU + +### 5.1 하드 제약: AOT + +**iOS 는 런타임 코드 생성을 금지한다.** torchnative `docs/design/DESIGN.md` 는 이 이유로 Triton 을 +기각했다. Android 에도 인앱 GPU 컴파일러 툴체인이 없고, autotuning 워밍업은 짧은 세션에서 상각되지 +않는다. 따라서 **GPU 커널은 빌드 타임에 컴파일된다.** JIT 은 데스크톱 개발 편의로만 허용한다. + +### 5.2 커널 모델 + +Triton · NVIDIA Warp 처럼 파이썬 함수에 `@kernel` 을 붙인다. + +- **처음에는 SIMT** (스레드 인덱스 기반). 컴퓨트 셰이더에 그대로 대응된다. +- 타일 추상화(Triton · cuTile 식)는 그다음. +- 커널 안: 고정 dtype 스칼라, 버퍼·텐서 뷰, 산술, 제어 흐름, 공유 메모리, 배리어. + 객체 생성·힙 할당·예외·파이썬 호출은 없다. 위반은 등급 1 과 같은 게이트에서 컴파일 에러. +- 커널 함수도 유효한 파이썬이다. CPU 에서 참조 구현으로 실행해 수치를 대조할 수 있다. + +### 5.3 컴파일 경로 + + @kernel 서브셋 → 자체 소형 IR → SPIR-V ─┬─ Vulkan (Android, 데스크톱) + ├─ SPIRV-Cross → MSL (iOS, macOS) + └─ naga → WGSL (웹) + └──────────── PTX (데스크톱 CUDA, 별도) + +한 소스에서 모바일·데스크톱·웹 타깃이 나온다. + +### 5.4 착지점 — torchnative + +컴파일된 커널을 **`torch.library` 커스텀 op 로 등록**하고 mps / vulkan 구현을 붙인다. 그러면 +`transformers` 코드가 수정 없이 그 커널을 쓴다. + +torchnative 의 설계는 이미 이 자리를 비워두고 있다. 융합 어텐션 같은 커널 계층을 "prefill 문제의 +예정된 해법" 으로 지목했고, 커널 소스 후보로 손으로 쓴 Metal/NEON, CubeCL, AOT Triton 을 올려 +두었다. TypedPython 등급 2 는 **같은 자리를 파이썬으로 채우는 네 번째 후보**다. + +### 5.5 선례 — Taichi + +파이썬 문법으로 Vulkan / Metal 커널을 AOT 컴파일해 모바일에 실었던 가장 가까운 선례다. 지금은 +활동이 크게 줄었다. 기술보다는 **독자 런타임과 독자 생태계로 고립된 것**이 원인으로 보인다. +TypedPython 은 그래서 자체 텐서 런타임을 만들지 않고 **torch op 로 착지해 기존 생태계에 붙는다.** + +## 6. 단계 제안 + +| 순서 | 작업 | 산출물 | 의존 | +|---|---|---|---| +| 1 | 건전성 게이트 | Pyrefly 출력을 받아 `Any` 유출을 에러로, 금지 목록 위반을 경고/에러로 내는 도구 | 없음 | +| 1b | 빌드 기본 단계로 연결 | PythonMultiplatform Gradle 플러그인이 사용자 파이썬 소스에 게이트를 항상 돌리고, 에러면 빌드 실패 | 1 | +| 2 | typed IR → C 백엔드 (#41) | 프론트엔드·검증기·C 생성·런타임, 데스크톱에서 끝까지 | 없음 (pypackpack 슬롯은 3 의 의존) | +| 3 | Kotlin 직접 호출 | 스텁 타입 기반 C ABI 호출 생성 | 스텁 배선 완료 | +| 4 | 커널 하나 | 융합 softmax 를 Vulkan · Metal 양쪽에서 upstream 과 수치 일치 | torchnative 착지 승인 | +| 5 | 커널 컴파일러 일반화 | SIMT 서브셋 전체, 이후 타일 | 4 | + +각 단계는 AGENTS.md §5·§14 의 TDD 와 측정 테스트 규정을 따른다. 특히 3 은 경계 비용 감소를, 4 는 +upstream 대비 수치 오차를 기준선으로 박아 둔다. + +### 6.1 단계 1 의 현재 상태 (2026-10-03, #16) + +- **위치:** `python-multiplatform-ksp/src/main/python/typedpython/`, 테스트는 + `python-multiplatform-ksp/src/test/python/`. 사용자 지시("ksp랑 통합")에 따라 KSP 모듈 안에 둔다. + KSP 프로세서는 Kotlin 심볼만 보고 파이썬 파일 변경으로는 다시 돌지 않으므로, **실행**은 1b 에서 + Gradle 태스크가 맡는다 — KSP 모듈은 소스와 배포(jar 리소스)의 자리다. +- **구현된 규칙:** SPEC N-7. Pyrefly 의 `Any` 유출 kind 6개를 에러로 올리고, Pyrefly 의 + per-expression 타입 리포트(`--report-pysa`)로 사용자 코드 안의 `Any` 식을 찾는다(`any-flow`). + `cast()` 인자, 버려지는 식 문장의 값, 함수 시그니처 자체는 제외한다. +- **재할당·혼합 컨테이너 (#22):** Pyrefly 1.3.2 는 둘 다 보고하지 않고, 타입 리포트에 대입 대상의 + 타입이 없다. 대신 이름을 *읽는* 위치의 타입은 기록한다. 그래서 대입문 바로 뒤에 같은 줄로 + `; x` 를 붙인 사본을 만들어(줄 번호 불변) 한 번 더 돌리고, 그 위치의 타입으로 판정한다. 두 번 + 이상 대입되는 이름이나 컨테이너 디스플레이가 있는 파일에서만 돈다. `for` 대상과 컴프리헨션 + 변수는 아직 탐침하지 않는다. +- **측정:** 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). 절대값보다 비율을 기준으로 본다. 차이의 + 대부분은 타입 리포트가 typeshed 사본까지 디스크에 쓰는 비용으로 보인다(추정, 미분해). 1b 에서 + 빌드마다 돌리기 전에 줄일 대상이다. +- **고정:** Pyrefly `==1.3.2`. 타입 리포트는 Pyrefly 가 버전을 매기는 포맷이지 공개 API 가 아니다. + +## 7. 위험 + +- **Pyrefly 의 외부 API 안정성.** 라이브러리 임베드는 확인 전까지 미룬다 (§4.1). +- **IR 이 표현하지 못하는 코드.** 게이트를 통과한 코드라도 IR 밖의 구문이 있으면 그 함수는 컴파일되지 + 않고 인터프리터에 남는다(`Module.skipped`). 그 목록이 IR 확장의 요구사항이 된다. + 그 목록이 2단계 IR 의 요구사항이 된다. +- **등급 2 컴파일러의 규모.** 가장 큰 작업이다. 커널 하나를 끝까지 하는 것(§6 의 4)을 첫 검증 + 단위로 삼아 범위를 제한한다. +- **기본 검사의 오탐.** 검사가 기본값이므로, 오탐 하나가 모든 사용자의 빌드를 막는다. 1b 를 켜기 전에 + 이 저장소의 실제 사용자 측 파이썬 코드(`sample/`, pythonx-compose 의 소비 예시)에 게이트를 돌려 + 에러 수와 그 원인을 기준선으로 박아 둔다. +- **서드파티 스텁 부재.** 스텁이 없는 의존성을 많이 쓰면 `Any` 출처가 늘어 에러가 잦아진다. 이 경우를 + 에러로 둘지, 경계 한 곳의 명시적 캐스트로 해소하게 할지가 채택을 가른다. +- **파이썬 의미론과의 괴리.** 금지 목록이 실제 코드에서 얼마나 흔한지 측정해야 한다. 너무 흔하면 + `@compiled` 가 채택되지 않는다. + +## 8. 미결 사항 (사용자 결정) + +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 는 검색 경로의 그 파일을 추가 + 내장으로 읽는다(실측). 모듈 단위 표기는 `# typedpython: compiled` 주석을 유지한다: `# pyright: strict` 와 같은 + 도구 pragma 형태이고, 실행 시 이름 조회가 필요 없어 일반 CPython 에서 부작용이 없으며, 데코레이터 없이 + 모듈 전체를 지정할 수 있다. +2. ~~고정폭 정수의 오버플로 의미~~ — **결정됨:** `int` 는 i64 로 컴파일하고 오버플로 시 인터프리터 재실행으로 큰 정수로 승격(의미 보존). +3. ~~pypackpack Cython 슬롯 인터페이스 소유~~ — **결정됨:** pypackpack 이 정한다. TypedPython 은 생성한 C 소스, 빌드 플래그, C 를 낸 모듈 목록을 넘긴다. 확장이 원본 소스를 품으므로 별도 `.py` 폴백은 없다(Cython 폐기 후 갱신). +4. GPU 착지를 torchnative 커스텀 op 로 할 것인가 (다른 레포 변경). +5. ~~TypedPython 을 별도 레포로 분리할 것인가~~ — **결정됨 (2026-10-02):** PythonMultiplatform 의 + 기본 동작. 검사는 기본, 컴파일은 opt-in. +6. 기본 검사에서 빠져나가는 방법을 둘 것인가 — 예: 동적 기능이 꼭 필요한 모듈 하나를 `checked` 에서 + 빼는 표기. 두지 않으면 보장이 강해지고, 두면 이식이 쉬워진다. +7. §3.1 금지 목록을 `checked` 에서 경고로 둘 것인가, 에러로 둘 것인가. diff --git a/docs/design/upcall.md b/docs/design/upcall.md new file mode 100644 index 000000000..aaf9a4076 --- /dev/null +++ b/docs/design/upcall.md @@ -0,0 +1,1331 @@ +# 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 +[`downcall-design.md`](downcall-design.md); what is exposed at all is +[`binding-policy.md`](binding-policy.md); object lifetimes and cycle collection are +[`object-lifetime.md`](object-lifetime.md); thread state is [`threading-and-abi.md`](threading-and-abi.md). + +## 0. Status and how to read this document + +This is a **design record**. The behavioural contract is [`SPEC.md`](../SPEC.md) §5 (U-1 … U-8) and +the boundary is [`INTENT.md`](../INTENT.md) §2.1, §2.2, §2.6; where this document and those disagree, +they win. It replaces three earlier documents (`upcall-design.md`, `upcall-table-design.md`, +`upcall-async-design.md`); their history is in git. + +What is implemented, in one paragraph: a KSP processor generates a function table for every `public` +declaration of a module at build time (U-1); one entry point per platform marshals arguments into it +(U-2); generated Python proxies make Kotlin packages importable under their own names with classes, +properties, companions and statics (U-3, U-4); `suspend` functions are awaitable (U-5); upcalls work in +a GraalVM native image (U-6, manually verified); every function on a Kotlin-named module carries Kotlin's +own surface (U-8). It runs on desktop, Android (ART), iOS, androidNative and wasmJs, with the exceptions +listed in §8. + +Conventions used below: + +- **Measured** means it was run in this repository and the test that prints or asserts it is named. + **Inferred** means it was read from code or documentation and not run. **(unverified)** marks a + statement this consolidation could not check against code or tests. +- Every measurement carries its conditions (platform, warmup, run count, commit where recorded). + Nothing in the test suite asserts a duration: wall-clock thresholds on a shared machine, an emulator or + a Node host are flake generators. Tests assert structure (the call reached Kotlin, the figure is + positive) and print the numbers. +- The authoritative, harness-cut cost figures are [`cost-table.md`](../investigations/cost-table.md) + (`./benchmarks/cost-table.sh --runs 3`). Hand-cut tables here are records of the round they were + taken in; prefer the cost table where they disagree. + +--- + +## 1. Decisions + +### 1.1 No runtime reflection + +Two reasons, the second more fundamental: + +1. **GraalVM native image** is closed-world: runtime reflection does not work without registration. +2. **Kotlin/Native has effectively no reflection** (`::class.simpleName`, no member enumeration). iOS + and androidNative have no reflective path regardless of GraalVM. + +A multiplatform library therefore has **no option but a table generated at build time** +(AGENTS.md §12.3, INTENT §2.1). The same choice is what makes cycle collection possible (§6). + +### 1.2 Resolve a name once, then pass a handle + +Objective-C is fast not because it has a table but because a selector is an interned pointer: +`objc_msgSend` finds the IMP in the class's method cache in a few instructions without touching a string. +The equivalent here: + +| When | What happens | +|---|---| +| 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 +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`, +`UpcallTable.kt`). The in-Kotlin part of the comparison is measured by +`commonTest/.../reflection/UpcallOverheadTest.kt` (`testNameResolutionVersusCachedHandle`, 200-entry +table); that test's numbers are a lower bound on the gap because the boundary is not in them. + +A table entry is a **generated lambda**, not a `KFunction`: `KFunction.call` re-checks arity and types and +boxes on every call (50–100 ns, measured in this project's benchmarks) and does not exist on +Kotlin/Native, while `{ args -> greet(args[0] as String) }` compiles to a direct call there and gives the +linker a static reference (§2.6). + +### 1.3 Route by data, so a few trampolines serve everything + +Every CPython callback convention passes the relevant object as an argument (checked against the headers): + +| Slot | Signature | What identifies the target | +|---|---|---| +| `PyCFunction` | `(PyObject *self, PyObject *args)` | `self` | +| `destructor` | `(PyObject *self)` | `self` | +| `getter` / `setter` | `(PyObject *self, void *closure)` | the explicit `closure` | +| `initproc` | `(PyObject *self, args, kwargs)` | `self` | +| `newfunc` | `(PyTypeObject *type, args, kwargs)` | `type` | + +So routing is done **with data, not function pointers**: a handful of shared trampolines serve an +unbounded number of Kotlin targets, and **no runtime code generation** (LLVM JIT, libffi closures) is +needed. JPype, PyO3 and JEP all work this way. The only case that would need runtime code generation is a +pure C callback with no context argument at all (a `qsort` comparator); the CPython object model has none. + +Argument passing needs exactly **one** carrier shape (§3.2), already inside the downcall vocabulary. + +### 1.4 One entry point per platform, one table behind all of them + +Behind the per-platform entry point every platform uses the same generated table and the same +`commonMain` trampoline (`UpcallTrampoline`); a platform contributes only the few lines that give Python +the address of an entry point (§3.7). INTENT §2.6. + +### 1.5 Exposure is a blacklist + +Every `public` declaration is registered; `@PythonInternal` opts a class or member out. A whitelist +(`@PythonAPI`) was rejected (AGENTS.md §12.7; [`binding-policy.md`](binding-policy.md)). This is the +premise the tree-shaking decision (§2.6) rests on and must not be re-litigated there. + +### 1.6 Kotlin names only + +A Kotlin package is importable under its own fully-qualified name and nothing else. Functions on a +Kotlin-named module take keyword arguments by **Kotlin parameter names**, honour Kotlin defaults for +omitted parameters, and expose their signature through `inspect.signature` and +`python_multiplatform.describe` (SPEC U-8; contract in the KDoc of +`python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt`). Nothing is +renamed: no snake_case, no synthetic `pythonx` module (INTENT §2.2–2.3, AGENTS.md §12.1–12.2). Making the +surface Pythonic is pythonx-compose's job, built on this metadata. See §4.3. + +--- + +## 2. Table generation + +### 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 +point. Each module emits one **fragment object** into the well-known package +`python.multiplatform.generated.fragments`: + +```kotlin +// GENERATED by python-multiplatform-ksp. Do not edit. +package python.multiplatform.generated.fragments + +object Fragment_io_github_thisisthepy_ksp_fixtures_library : python.multiplatform.reflection.FunctionTableFragment { + override val moduleName: String = "io_github_thisisthepy_ksp_fixtures_library" + override fun entries(): List<ExposedCallable> = listOf( + ExposedCallable(name = "fixture.library.greet", arity = 1, paramTypes = listOf(TypeTag.STRING), + returnType = TypeTag.STRING, paramNames = listOf("name"), callable = { args -> fixture.library.greet(args[0] as String) }), + // ... + ) + override fun classes(): List<ReflectedClass> = listOf(/* ... */) +} +``` + +(Shape from `SourceRendering.kt`'s `renderFragmentSource`; abbreviated.) + +- **Name**: `Fragment_<moduleName>`, where `moduleName` is the KSP option + `python.multiplatform.moduleName` with `.` and `-` replaced by `_` (`Constants.kt`). The Gradle plugin + derives it from the **Maven group plus the project path** (`:ksp-fixtures:app` with group + `io.github.thisisthepy` → `io_github_thisisthepy_ksp_fixtures_app`), because fragments from every + artifact share one package and two libraries both called `:core` would otherwise collide. The name is + not load-bearing for discovery (the whole package is scanned) but prevents collisions. +- **Visibility: `public`.** An `internal` fragment does not work: `internal` is enforced per Kotlin + module, and the app module's aggregator referencing a library's fragment is another module even inside + one Gradle build ("cannot access … it is internal"). +- **Each `ExposedCallable` carries** the Python-visible qualified name, `arity`, a `TypeTag` per + parameter and for the return, a `CallableKind` (where the arguments are: `FUNCTION`, `CONSTRUCTOR`, + `METHOD`, `GETTER`, `SETTER`, `STATIC_GETTER`, `STATIC_SETTER`), `isSuspend` (§5.3), the Kotlin + `paramNames`, declared type names, extension/receiver data, `paramHasDefault`, and the lambda. + `ReflectedClass` entries carry class metadata and, for classes with `PyObject`-typed properties, a + generated `tp_traverse` body (§6). +- The library's own packages (`python.native.ffi`, `python.multiplatform.ffi`, `…reflection`, `…ref`) + and the generated packages are always excluded; `python.multiplatform.excludePackages` adds more. + +### 2.2 What is scanned + +| Declaration | Exposed | Notes | +|---|---|---| +| `public` top-level functions | yes | `main()` with no parameters or one `Array<…>` is excluded (`BindingPolicy.isExposedTopLevelFunction`) | +| `public` top-level properties | yes | module attribute: `STATIC_GETTER` / `STATIC_SETTER` | +| `public` classes + constructors | yes | constructor only if concrete and non-`inner` (`isConstructible`) | +| `public` member functions | yes | | +| `public` properties | yes | getter; setter only for a `var` whose setter is itself public (`private set` / `internal set` are read-only from Python, `isExposedSetter`) | +| `companion object` members | yes | under the **owner's** name, no receiver | +| `object` declarations | yes | same shape, no constructor | +| `interface` | yes | members only; not constructible; receiver arrives as a handle | +| `enum class` | yes | one `STATIC_GETTER` per entry, plus `name`/`ordinal`/`valueOf` | +| nested classes / interfaces / enums / objects | yes | under `Outer.Inner`; the walk stops at a non-exposed owner | +| `suspend` functions | **yes** | body wrapped in `PendingCall.start { }`, `isSuspend = true` (§5.3) | +| `annotation class` | no | reading one back needs runtime reflection; an instance has nothing to attach to | +| enum `values()` / `entries` | no | return collections; `ReflectedClass.enumEntryNames` carries the same data | +| generic declarations (`class Box<T>`, `fun <T> f`) | no | `args[0] as T` does not compile | +| abstract / sealed class constructors | no | "Cannot create an instance of an abstract class" | +| `internal` / `private` / `protected` | no | | +| extension functions / extension properties | no (KSP) | the artifact walker does bind extensions, as methods on the receiver's proxy ([`kotlin-extensions-in-python.md`](kotlin-extensions-in-python.md); SPEC U-7 is `partial` and not asserted for KSP-generated proxies) | +| `@Composable` functions in the module's own source | no | a generated non-composable lambda cannot call one; the artifact walker binds composables instead (`BindingPolicy.isComposable` KDoc) | +| declarations whose type is a `suspend` function type, or one nested in a generic argument | no, **with a KSP warning** | `"has a type with no exposable classifier"` (`FragmentScanner.warnUnexposableType`; `GeneratedSuspendTest`) | +| compiler-generated `copy`, `componentN` | no | the subset KSP reports for a `data class` (no `equals`/`hashCode`/`toString`) | +| `expect` declarations | no | the `actual` is found on the platform side | +| `@PythonInternal` | no | opt-out | + +Asserted against KSP-generated fragments, not hand-written ones: +`ksp-fixtures/app/src/desktopTest/.../GeneratedDeclarationKindsTest.kt`, +`GeneratedSuspendTest.kt`, `GeneratedTableTest.kt` (which runs `UpcallTableTest`'s scenarios — constructor +/ method / getter / setter round trip through `HandleTable`, `@PythonInternal` exclusion, narrow +`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 + +Kotlin/Native has no `ServiceLoader`; a top-level `object` that nothing references is never initialised +(lazy initialisation is the default since K/N 1.7.20); and reflection cannot enumerate. So a fragment that +registers itself in its own initialiser never runs. Fragments must be referenced explicitly by something +reachable. + +The **app-role** module's KSP therefore generates an aggregator that names every fragment: + +```kotlin +package python.multiplatform.generated + +object FunctionTable { + val fragments: List<FunctionTableFragment> = listOf( + python.multiplatform.generated.fragments.Fragment_my_library, + python.multiplatform.generated.fragments.Fragment_my_app, + ) + fun installInto() { python.multiplatform.reflection.UpcallTable.install(fragments) } +} +``` + +(`renderAggregatorSource`.) `UpcallTable.install` clears the table (bumping the epoch) and registers each +fragment; a fragment already registered under the same `moduleName` is a no-op, and **a name claimed by two +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_<app>`. 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`. + +KSP cannot see other modules' *sources*, but this query reads the classpath, which is why it works. The key +is the well-known package, not an annotation: `getSymbolsWithAnnotation` is documented as potentially +expensive because it scans the whole classpath; the package query is cheap. (A marker-interface filter on +`FunctionTableFragment` would also work; the package is the primary mechanism.) + +`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. + +The same mechanism serves every target: + +| Platform | Discovery source | Status | +|---|---|---| +| JVM desktop | JARs on the classpath | `ksp-fixtures/app` desktop tests | +| Android | JAR/AAR classpath | `ksp-fixtures/android` (`GeneratedAndroidTableTest`, unit and instrumented) | +| GraalVM native image | classpath at build time; the table is statically reachable, so no reflection config | verified manually, [`graal-native-image-verification.md`](../platforms/graal-native-image-verification.md) (SPEC U-6) | +| Kotlin/Native | klib metadata | `ksp-fixtures/{library,app}` on `androidNativeArm64`: round 2 found the library's fragment in its `.klib`, `FunctionTable` compiled and linked, and `ksp-fixtures/app/src/androidNativeArm64Test/.../NativeSmokeTest.kt` runs it (SPEC U-3) | + +`ServiceLoader` could work on JVM/Android, but one aggregator everywhere avoids a platform split and runtime +classpath scanning. + +### 2.4 Reaching `FunctionTable` from shared code: `@InstallsUpcallTable` + +`FunctionTable` is emitted into each leaf compilation, so only that target's own source set can name it; +an intermediate source set (`commonMain`, `iosMain`, `androidNativeMain`) gets +`Unresolved reference 'FunctionTable'`. Generating in `kspCommonMainMetadata` instead would fix visibility +and break the scan (a common-only scan sees only common declarations). So generation stays in the leaves +and the **seam** is generated: + +```kotlin +// commonMain +@InstallsUpcallTable +expect fun installGeneratedUpcallTable() +``` + +The app-role processor finds the annotated `expect` (a leaf compilation resolves its whole source-set +closure) and writes one `actual` per leaf compilation into the `expect`'s package: + +```kotlin +@python.multiplatform.reflection.PythonInternal +actual fun installGeneratedUpcallTable() { python.multiplatform.generated.FunctionTable.installInto() } +``` + +`@PythonInternal` because the `actual` is a public top-level function in the user's package and an +incremental round could otherwise expose "reinstall the table" to Python. A `library`-role module carrying +the annotation is an error (it aggregates nothing). `expect`/`actual` rather than an interface plus a +runtime registry, because the reference chain must stay static or the Kotlin/Native linker may drop what +nothing names. The seam's shape is checked in `InstallSeam.kt` (top-level, no parameters, returns `Unit`, +public or internal). + +### 2.5 The processor, its options, and the Gradle plugin + +One `SymbolProcessorProvider` (`PythonBindingProcessorProvider.kt`) with two roles: + +| `python.multiplatform.role` | Does | +|---|---| +| `library` | scan the module, emit its fragment | +| `app` | emit its fragment (round 1), discover all fragments and emit `FunctionTable` and the `@InstallsUpcallTable` actuals (round 2) | + +| KSP option | Required | Meaning | +|---|---|---| +| `python.multiplatform.role` | yes | `library` or `app` | +| `python.multiplatform.moduleName` | yes | sanitised fragment name | +| `python.multiplatform.excludePackages` | no | comma-separated packages to skip, on top of the always-excluded ones | + +**The Gradle plugin is built**: `python-multiplatform-gradle-plugin/`, id +`io.github.thisisthepy.python.multiplatform.bindings`. Applying it applies KSP, adds the processor to every +target's **main** KSP configuration (never a `…Test` one and never `kspCommonMainMetadata`, either of which +would emit a second fragment under the same name), infers the role (`application` / +`com.android.application` → `app`, else `library`; override with `pythonBindings { role.set("app") }`) and +derives `moduleName`. Notes from building it: + +- **It does not compile against KSP's Gradle plugin.** `kotlin-dsl` builds against Gradle's embedded + Kotlin (2.0.20 on Gradle 8.11.1) and KSP 2.3.11 is compiled with Kotlin 2.3 ("compiled with an + incompatible version of Kotlin"). The dependency is `runtimeOnly` and the `ksp { arg(...) }` calls go + through one reflective lookup of `arg(String, String)`. +- **Test configurations are matched as a camel-case word `Test`.** AGP puts the build type last + (`kspAndroidTestDebug`), so an `endsWith("Test")` check once put the processor on a unit-test + compilation, which emitted a duplicate fragment shadowing `main`'s. `ksp-fixtures/android` carries the + Android plugin for this reason ([ROADMAP](../roadmap/ROADMAP.md) §13). + +### 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 +static reference to `greet`; a reachable fragment makes every target reachable. Function-level DCE +(`-ffunction-sections` + `--gc-sections`) cannot help, because the references are real. Three things +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 +remaining options, one line each: + +| Option | Verdict | +|---|---| +| B. Finer fragments (per file/class) | Does not help alone: `FunctionTable` still references every fragment unconditionally; `by lazy` still captures the reference. (Per-file fragments are still the lever for incremental KSP, §2.7.) | +| C. Opt-in manifest (`python_bindings.txt`) | A different product: contradicts the blacklist and the "write Kotlin, import from Python" promise | +| D. `ServiceLoader` on JVM, aggregator on Native | A platform split for no gain: the JVM does not tree-shake anyway | +| E. Two-tier table (skeleton + lazily initialised body) | Saves runtime lambda objects, **not binary size**: the `when` arms still statically reference every target on Kotlin/Native | + +**Measured.** `ksp-fixtures/library` got 200 synthetic top-level functions (`fun bulkN(x: Long): Long = x + N`); +`ksp-fixtures/app`'s `androidNativeArm64` debug test binary was linked before and after +(`linkDebugTestAndroidNativeArm64`) and stripped with the NDK's `aarch64-linux-android-strip`: + +| | bytes | delta | +|---|---|---| +| baseline (unstripped) | 7,578,480 | | +| +200 entries (unstripped) | 8,343,184 | 764,704 (≈3.8 KB/entry) | +| baseline (stripped) | 3,264,056 | | +| +200 entries (stripped) | 3,632,696 | 368,640 (**≈1.84 KB/entry**) | + +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, +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 + +`Dependencies(false)` would never regenerate the aggregator and miss a newly added fragment; +`Dependencies.ALL_FILES` is correct but reprocesses on every change. A narrower declaration buys nothing. +Measured with `ksp.incremental.log=true` on `:ksp-fixtures:app` (two files), modifying one: + +| aggregator `Dependencies` | dirty / all | +|---|---| +| `ALL_FILES` | 100.00% | +| `Dependencies(aggregating = true, <generated fragment files>)` | 100.00% | + +A module's own `Fragment_<module>` 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. + +### 2.8 Alternatives rejected + +| Alternative | Why not | +|---|---| +| `@EagerInitialization` | Deprecated and slated for removal; does not answer who calls `register()`; defeats tree shaking by definition | +| Resource files under `META-INF/` | Not needed while `getDeclarationsFromPackage` works; more complex (classpath I/O) and less type-safe. Kept as the fallback behind `FragmentDiscovery` | +| Manual `FunctionTable.register(Fragment_x)` in `main()` | Contradicts "apply the plugin and nothing else"; every new dependency needs a call | +| A compiler (IR) plugin | Harder to write and maintain, less stable API, and KSP suffices | + +### 2.9 What is still not verified for generation + +- **Transitive dependencies** (app → libA → libB with a fragment in libB): should work because transitive + classes are on the compilation classpath. (unverified) +- **Published Maven artifacts** consumed as `implementation("g:a:v")` rather than `project(...)`: should + work the same way. (unverified) + +--- + +## 3. Runtime path + +### 3.1 From install to call + +``` +Kotlin, once: UpcallBootstrap.publishToGlobals() publishes _pm_resolve / _pm_invoke / _pm_bind* / _pm_release / _pm_cancel + installGeneratedUpcallTable() -> FunctionTable.installInto() -> UpcallTable.install(fragments) + PythonProxySource.install() renders Python from UpcallTable + ClassLookup, exec's it +Python: from demo.calc import Counter sys.modules hit -- no import hook + c = Counter(10) proxy __init__ -> _pm_invoke(handle, (10,)) -> UpcallTrampoline.invoke + c.increment(5) _pm_invoke(method_handle, (c._pm_handle, 5)) +``` + +(`*` `_pm_bind` is published by the `PyMethodDef` bootstraps, not by desktop's.) Names are resolved to +handles when the generated module is executed; the calls afterwards carry only handles (§1.2). +`UpcallBootstrap` is the consumer-facing route (`commonMain/.../ffi/upcall/UpcallBootstrap.kt`); calling it +twice is safe (`UpcallBootstrapTest`). The published names live in `__main__` and do not survive +`Py_Finalize`; the addresses behind them are process-lifetime, so a caller that re-initialises must publish +again (inferred from code; not covered by a test because the suite shares one interpreter). + +### 3.2 The trampoline and its one carrier shape + +`python.multiplatform.ffi.upcall.UpcallTrampoline` (`commonMain`) depends on nothing platform-specific. +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 +closed world. The remaining slots were already in the downcall vocabulary: + +| Slot | C signature | Carrier shape | | +|---|---|---|---| +| `PyCFunction` | `PyObject *(PyObject *self, PyObject *args)` | `(long, long) -> long` | the argument-passing shape | +| `getter` | `PyObject *(PyObject *self, void *closure)` | `(long, long) -> long` | same one | +| `initproc` / `setter` / `traverse` | `int(long, long, long)` | `(long, long, long) -> int` | pre-existing | +| `inquiry` (`tp_clear`) | `int(PyObject *self)` | `(long) -> int` | pre-existing | +| `destructor` | `void(PyObject *self)` | `(long) -> void` | pre-existing | +| 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 +through a `CONSTRUCTOR` entry called from Python (§4.2). + +### 3.3 What crosses, per tag + +The `Array<Any?>` an entry receives has one representation per `TypeTag`, which the generated cast +(`TypeShape.castExpression`) narrows from: + +| `TypeTag` | Python | Kotlin | +|---|---|---| +| `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` | +| `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 | + +`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 +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 + 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 + (`commonTest/.../native/ffi/UpcallEntryTest.kt`, + `theReferenceReturnedToPythonIsTakenOverExactlyOnce`). +- **Nothing is thrown out of a trampoline.** The return path is C; a Kotlin exception crossing a Panama + upcall stub terminates the VM, on Kotlin/Native the process. Every failure leaves as `NULL` with the error + indicator set; a Kotlin exception becomes a Python exception and `Unit` becomes `None` (SPEC U-2, + `UpcallTrampolineTest`). + +### 3.5 The GIL is not the caller's to promise + +`withGIL` skips `PyGILState_Ensure` when the thread's nesting depth is non-zero. That is right for Kotlin +code and **wrong for an entry from C**, because C may have released the GIL inside a scope that is still +open. `ctypes.CFUNCTYPE` does exactly that (unlike `PYFUNCTYPE`); the first version of the trampoline +segfaulted in `_PyThreadState_GET` (`PyErr_Occurred+0x1c`) on a thread whose own counter said it held the +GIL. Every trampoline entry (`invoke`, `releaseObject`, `cancelCall`) therefore takes its own +`PyGILState_Ensure`/`Release` pair **unconditionally** (`UpcallTrampoline.attached`), on every platform. + +On Android `PyGILState_Ensure` is bound as **ordinary JNI** (`PyGILState_EnsureN`): a `@FastNative` or +`@CriticalNative` binding would let a thread blocked on the GIL stall the ART collector and deadlock the two +runtimes the moment upcalls exist ([`androidMain/README.md`](../../python-multiplatform/src/androidMain/README.md)). + +### 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 + 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). + +### 3.7 Per-platform entry points + +Only the address-publishing step differs; the marshalling is shared. + +| Platform | How Python reaches `UpcallTrampoline` | Names published | +|---|---|---| +| **Desktop** | FFM `Linker.upcallStub` (`UpcallStub.invokeWithArgsStubAddr`, `cancelCallStubAddr`, …) wrapped by `ctypes.CFUNCTYPE` in `UpcallBootstrap.desktop.kt` | `_pm_resolve`, `_pm_invoke`, `_pm_release`, `_pm_cancel` | +| **iOS / androidNative** | `python.native.ffi.UpcallEntry` (`nativeMain`): a `PyMethodDef` whose `ml_meth` is a `staticCFunction` over the trampoline, `self` carrying the handle; Kotlin/Native fills the struct, no C glue | all five | +| **Android (ART)** | C shims in `artMain/cinterop/jni_onload.def` (`pmp_upcall_invoke_meth`, `pmp_upcall_invoke_free_meth`, `pmp_upcall_resolve_meth`, …, `pmp_upcall_cancel_meth`) calling `python/native/ffi/UpcallCallbacks` via `CallStaticLongMethod` | all five | +| **wasmJs** | one `@WasmExport("pmp_invoke")` placed in CPython's `__indirect_function_table` with `Table.set`, five `PyCFunction`s over it told apart by an op code in `self` (§3.10) | all five | + +Tests: `UpcallEntryTest` (`commonTest`, runs on every target through the per-platform `bindUpcallOrNull`), +`nativeTest/.../UpcallRawEntryPointTest.kt`, `androidInstrumentedTest/.../UpcallThreadAttachTest.kt`. + +The `str`/`bytes` contract of `_pm_resolve`: the generated support code sends **bytes**, because desktop's +`ctypes.CFUNCTYPE(c_long, c_char_p)` refuses a `str`; every `PyMethodDef` bootstrap accepts both +(`PyUnicode_AsUTF8`, on failure `PyErr_Clear` then `PyBytes_AsString`). The generated module's own +name → handle helper is called `_pm_lookup` so it never overwrites a bootstrap's `_pm_bind` +(handle → callable). `PythonProxySourceTest` guards that the generated support defines none of the five +bootstrap names. How these were found: when `PythonProxyInstallTest` was first run outside desktop, iOS +failed 14/14 because `UpcallEntry.publish` did not publish `_pm_invoke`, then 11/11 on the `bytes` +argument; ART later failed 11/11 on the same `bytes` gap in its separate C shim +(`TypeError: bad argument type for built-in operation`, on `pmp_api26` and `pmp_api36`). The rule that +came out of it: the generator's assumptions about bootstraps are only proven by running +`PythonProxyInstallTest` on each target. + +### 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 +`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 +only for the one cold `upcallPublish(long)`. This is the same inversion the cycle-collecting proxy type uses +for `tp_traverse`/`tp_clear` (`ProxyCallbacks`), down to reusing `pmp_attach`. + +**A Python worker thread is a bare pthread ART has never seen**, so `GetEnv` fails and the shim must +`AttachCurrentThreadAsDaemon` (SPEC C-5). +`UpcallThreadAttachTest.anUpcallArrivesOnAThreadCPythonCreatedRatherThanFailingToFindTheJvm` upcalls from a +`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` +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` +lets an attached pthread exit undetached on `pmp_api26` and `pmp_api36` with no abort and no logcat warning. +**The detach is still mandatory**, for a different reason: an attachment never given back leaves its +`java.lang.Thread` in the thread list as a GC root. So the detach happens at thread death, in a +`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 +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 +each sees one ART thread and no two share one, and requires every peer reclaimable afterwards (green on both +emulators). The instrument reads GC reachability of the peer, not `ThreadGroup.enumerate`, which on API 26 +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 +pair (§3.5). + +### 3.9 iOS and androidNative: the `@CName` + `ctypes.CDLL(None)` route, measured + +The original design said Python would reach `@CName` symbols via `ctypes.CDLL(None)` because both live in one +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 | + +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 +(`pm_upcall_invoke`, `pm_upcall_resolve`, `pm_upcall_release_object`, `pm_upcall_cancel_call`) are kept for a +C host and for `ctypes` on Android; `UpcallEntry.invokeAddress` publishes the same addresses without the +link (`UpcallRawEntryPointTest.theRawEntryPointsAreCallableCFunctionsOfTheDocumentedShape`). The real route is +the `PyMethodDef` of §3.7. + +### 3.10 wasmJs: one export, five names + +`@WasmExport` is valid only in the compilation that produces the `.wasm`, so the embedding application +declares `@WasmExport("pmp_invoke") fun pmpInvoke(selfPtr: Int, argsPtr: Int): Int = +UpcallEntry.invokeMethod(selfPtr, argsPtr)`; this repository reproduces that file in +`wasmJsTest/.../UpcallExports.kt` so the suite takes the application's path. `wasmJsMain`'s `UpcallEntry` +puts the export in CPython's table (`pmpRegisterUpcall`) and builds `PyCFunction_NewEx(def, self, NULL)` +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 +export, and no op argument**: + +| `self` | Name | Shape | +|---|---|---| +| `-2` | `_pm_resolve` | `(name: str \| bytes) -> handle`, `-1` if none | +| `-3` | `_pm_invoke` | `(handle, args_tuple) -> result` | +| `-4` | `_pm_bind` | `(handle) -> callable` | +| `-5` | `_pm_release` | `(handle) -> int` | +| `-6` | `_pm_cancel` | `(handle) -> int` | +| other | bound callable | `UpcallTrampoline.invoke(self, args)` | + +Negative ops are safe because `CallableHandle.isValid` is `raw >= 0` and `ObjectReference`'s generation is +≥ 1; `-1` is not an op because it is `CallableHandle.NONE` and must still reach the trampoline's own refusal. +The application's file and `verifyWasmAbiSignatures` (317 `@WasmImport` + 3 glue) are unchanged by it. +Removing the `UpcallEntry.publish` call makes `PythonProxyInstallTest` fail 11/11 with the generated module's +guard ("the raw upcall entry points are not bound …"), so the passing run depends on the bootstrap. + +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). + +--- + +## 4. Generated Python proxies + +### 4.1 Rendered at run time, not shipped as a `.py` + +`PythonProxySource.render` (`commonMain/.../ffi/upcall/PythonProxySource.kt`) renders Python from the +installed table; `install()` executes it. Not a KSP-emitted `.py` resource, because: + +1. **There is no resource path that works everywhere.** Kotlin/Native has no `getResourceAsStream`. + `PythonPayload` now puts a directory on `sys.path` on desktop (jar resource) and Android (APK asset), but + iOS, androidNative and wasm have no packaging step, so a shipped file would exist on two of five. +2. **KSP does not know what will be installed.** The table is assembled at run time and an application may + install a subset; build-time Python would describe a table that may not exist. +3. **`ExposedCallable` already carries everything** (`kind`, `arity`, `isSuspend`, names); a second + generator could disagree with the first. + +`render` is a pure function from entries to source text, pinned without an interpreter by +`PythonProxySourceTest`. Nothing here reintroduces reflection, so the native-image argument (§1.1) holds. + +### 4.2 Shapes + +- **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 + `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 + 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 + `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 + 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 + 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: + + async def _pm_f_0(a0): + _pm_r = _pm_invoke(_pm_h_0, (a0,)) + if hasattr(_pm_r, '__await__'): + return await _pm_r + return _pm_r + +### 4.3 Kotlin-named surface + +`KotlinSurface` (`commonMain/.../ffi/upcall/KotlinSurface.kt`) installs `python_multiplatform`, the binder's +root module, shared by both installers (`PythonProxySource` and the binding layer `PythonxAdapter`, which +lives at `python_multiplatform.binding`). Its KDoc is the contract (SPEC U-8): + +- keyword arguments by **Kotlin parameter names**; an extension receiver is positional-only; +- a parameter with a Kotlin default has `default is python_multiplatform.KOTLIN_DEFAULT`; leaving it out (or + passing that object) means Kotlin's own default; +- a required parameter after a defaulted one is keyword-only; +- annotations are the declared Kotlin type names; synthetic `$composer`/`$changed`/`$default` never appear; +- an overload set answers `(*args, **kwargs)`; +- `inspect.signature(fn)` (built lazily via `__wrapped__`) and `python_multiplatform.describe(fn)` give the + metadata. The answer is the same whichever installer ran first (stamped epochs). + +No member or parameter is renamed and the binder creates no `pythonx` module; a real `pythonx` package on +disk is what `import pythonx` loads. Tests: `desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, +`ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt`. `.pyi` stubs are generated by the Gradle plugin +under Kotlin module paths only (SPEC B-7). + +### 4.4 Inheritance + +A generated proxy is an ordinary Python class, so Python can subclass it and override methods; instances of +such subclasses keep their handle and are collected through cycles (SPEC M-3; e.g. +`desktopTest/.../ref/CycleCollectionTest.kt` `aPythonSubclassOfTheProxyTypeSurvivesBeingUntrackedTwice`). +**A Python override is not visible to Kotlin callers**: nothing dispatches a Kotlin virtual call to a Python +method (no test asserts it, and the table has no such path). The original design's "Kotlin calls the +override through `PyObject_CallMethod`, no new stubs" is therefore not implemented. + +### 4.5 Where proxies run + +`PythonProxyInstallTest` lives in `commonTest`. Each test target declares `publishesProxyEntryPoints` and +`proxyBootstrapSupportsAsyncio` as constants (`commonTest/.../ffi/upcall/ProxyBootstrap.kt` and its +`actual`s) instead of probing `globals()` at run time, so a target that stops publishing fails loudly instead +of silently taking the other branch. + +| Target | `publishesProxyEntryPoints` | `proxyBootstrapSupportsAsyncio` | +|---|---|---| +| desktop | true | true | +| iOS / androidNative (`nativeTest`) | true | true | +| Android ART | true | true | +| wasmJs | true | **false** (§5.8) | + +Threaded delivery tests are split by how they create a thread: `desktopTest/.../PythonProxyDeliveryTest.kt` +(`java.lang.Thread`) and `nativeTest/.../PythonProxyNativeDeliveryTest.kt` (`pthread_create`). + +--- + +## 5. `suspend` functions (async upcalls) + +SPEC U-5: `await g.greetNow(1)` works; a function that never suspends completes without a Future; cancelling +the Python future cancels the Kotlin coroutine. Implemented on desktop and Kotlin/Native; on ART the sample +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 +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 +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<T>` | +| **(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. + +**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 +(`PendingCallTest.aBlockThatNeverSuspendsIsAlreadyCompleteBeforeStartReturns`), and its value is returned +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 +`java.lang.Thread` and works.) + +### 5.3 KSP: a different body, not an exclusion + +`BindingPolicy.isSuspending` selects a body shape: + + { args -> fixture.library.blockingTopLevel(args[0] as Long) } // ordinary + { args -> python.multiplatform.ffi.upcall.PendingCall.start { fixture.library.suspendingTopLevel(args[0] as Long) } } // suspend + +The braces are a `suspend` lambda; it compiles with the stdlib only. `kind` is unchanged (a suspending +member is still `METHOD`, receiver in `args[0]`) and `returnType` is the **declared** return type, so the +fast path marshals exactly like a synchronous entry. `ExposedCallable.isSuspend` is a flag rather than +`SUSPEND_*` variants of `CallableKind` because the axes are orthogonal: `kind` says where the arguments are, +`isSuspend` what comes back. Pinned by `ksp-fixtures/app/.../GeneratedSuspendTest.kt` against +`ksp-fixtures/library/.../Suspending.kt`, which pairs every suspending declaration (top-level, member, +companion, interface, object) with a non-suspending control in the same scope. + +A `suspend` **function type** (`val h: suspend (Long) -> Long`, a parameter or return of that type, nullable, +behind a `typealias`, or nested in a generic argument) is **not exposed** and KSP **warns** +(`FragmentScanner.warnUnexposableType`); `GeneratedSuspendTest` asserts each rejection. (An earlier state +exposed it as an opaque `OBJECT` handle no entry could call. The `BindingPolicy.isSuspending` KDoc still says +that; it is stale.) + +### 5.4 `PendingCall` + +`commonMain/.../ffi/upcall/PendingCall.kt`, pinned by `PendingCallTest`: + +- **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 + 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 + completion that reaches Python takes the GIL anyway. + +### 5.5 Delivery + +`AsyncUpcall.deliver` runs on `UpcallTrampoline.invoke`'s `entry.isSuspend` branch: + +| Case | Returned | Cost | +|---|---|---| +| already complete, success | the real value (`marshalResult`) | not even `import asyncio` | +| already complete, failure | `NULL` + error indicator | identical to a synchronous failure | +| really suspended | `asyncio.Future` (new reference) | `get_running_loop` + `create_future` | + +On completion: `withGIL { loop.call_soon_threadsafe(_pm_settle, future, ok, payload) }`. `withGIL`, not the +trampoline's unconditional pair, because this is not a C entry: a fresh completion thread has depth 0 and +really ensures; a completion inside a later upcall correctly nests. The Kotlin wrapper and Python each hold a +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` +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 — +`PythonProxyInstallTest.theSameGeneratedProxyBuildsNoFutureWhenTheKotlinBodyNeverSuspends`). + +**No running loop:** a call that really suspends fails with `RuntimeError: no running event loop`, and the +coroutine has already started (whether it suspends is only known by starting it); its resumption is dropped +(`AsyncUpcallDeliveryTest.aSuspendingEntryThatSuspendsWithNoRunningLoopFailsInsteadOfReturningSomethingUnusable`). +A suspending entry that never suspends works with no loop at all +(`aSuspendingEntryThatNeverSuspendsHandsBackTheRealValueAndBuildsNoFuture`). + +### 5.6 Cancellation + +**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: +`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 +awaiting while Kotlin schedules, then cancels). + +**Kotlin-side cancellation is cooperative, by construction.** The continuation `startCoroutine` receives is +the coroutine's *completion*; the suspension point's continuation belongs to whoever suspended (the user's +`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 +cancelled either. The element is not a `ContinuationInterceptor`, so the fast path is untouched. + + suspend fun slowSum(n: Long): Long { + var total = 0L + for (i in 0 until n) { ensureActive(); total += step(i) } + return total + } + +**Early notification.** Python's `fut.cancel()` reaches Kotlin before completion: + + fut.cancel() -> done callback (call_soon) -> _pm_cancel(handle) (long) -> int + -> UpcallTrampoline.cancelCall (unconditional PyGILState_Ensure) + -> HandleTable.resolveRaw -> PendingCall.cancel() -> next ensureActive() throws + +`_pm_cancel` has `_pm_release`'s shape (no new stub shape). The `PendingCall` handle is captured as a default +argument of the done callback rather than set as a `Future` attribute. `AsyncUpcall.armCancellationNotice` +registers the handle only if `_pm_cancel` and `_pm_release` exist in `__main__`; a host without them keeps +completion-time observation and leaks nothing (a `NameError` inside a loop callback would be another +unactionable log). `AsyncUpcallEarlyCancellationTest` was red first, with the old behaviour as its message +("Kotlin did not learn about the cancellation until the call completed"); it asserts +`isCancelled && !isDone` on a call never resumed since cancellation (the old code also set the flag, but only +at completion), then resumes once and requires `CancellationException` and `isDone`. + +**Two releasers by design.** `_pm_watch`'s done callback calls `_pm_cancel` if cancelled and always +`_pm_release`; `AsyncUpcall.resolve` releases too, in a `finally`. They cover different holes: + +| Releaser | Only it covers | +|---|---| +| 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 +`HandleTable.liveCount` (`AsyncUpcallEarlyCancellationTest`): + +| Case | Live handles | +|---|---| +| while a really-suspended call is pending | baseline + 1 | +| 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`) | + +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 +continuation already. + +### 5.7 Desktop and Kotlin/Native tests + +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 +`CycleCollectionTest.testDeallocOnAThreadCPythonCreated` measures for `tp_dealloc`. + +| `nativeTest` | Desktop counterpart | Re-verifies | +|---|---|---| +| `AsyncUpcallNativeDeliveryTest` (2 tests) | `AsyncUpcallDeliveryTest` | delivery from a foreign thread while the loop runs; fast path takes no asyncio | +| `AsyncUpcallNativeEarlyCancellationTest` | `AsyncUpcallEarlyCancellationTest` | `_pm_cancel` reaches `ensureActive()` before completion | +| `AsyncUpcallNativeCancellationTest` | `AsyncUpcallCancellationTest` | a completion on a cancelled `Future` is dropped; no error indicator leaks | + +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. + +### 5.8 Per platform + +**Desktop** — everything above (`AsyncUpcallDeliveryTest`, `AsyncUpcallCancellationTest`, +`AsyncUpcallEarlyCancellationTest`, `PythonProxyDeliveryTest`). + +**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` +(`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` | + +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 +(unverified).) The iOS sample cannot yet show that row (a Kotlin/Native completion thread needs a +`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: + +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 + `SuspendError: trying to suspend without WebAssembly.promising`, which unwound CPython's C frames without + `Py_END_ALLOW_THREADS`, so the outer `PyGILState_Release` hit + `Fatal Python error: PyGILState_Release: thread state … must be current`, then `abort()`. Cause: Emscripten + decides JSPI **at run time** (`if (WebAssembly.Suspending) …`) and wraps only `main` in + `WebAssembly.promising`; this library never calls `main` (it issues `Py_Initialize` as an ordinary wasm + call). The same `python.wasm` returns `[]` on Node 22 (no JSPI) and dies on Node 26 (JSPI on by default, no + 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 — + 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): + `poll`/`select` return; `DefaultSelector is PollSelector`; `asyncio`, `asyncio.events`, + `asyncio.base_events` import; a coroutine runs to completion on a selector-free loop. +2. *The default event loop still cannot be built.* `BaseSelectorEventLoop.__init__` → + `_make_self_pipe` → `socket.socketpair()` → Emscripten `___syscall_listen` → `require('ws')` → + `MODULE_NOT_FOUND`. Installing `ws` (tried temporarily with `npm("ws", "8.18.0")` and a probe, then + reverted) only exposes the next wall: `OSError: [Errno 28]` from `lsock.accept()`, because Emscripten's + socket emulation needs a real WebSocket handshake to populate `pending` and `_fallback_socketpair()` runs + `bind/listen/connect/accept` inside one synchronous wasm call, so Node's event loop never ticks in between. + No npm package fixes that, and re-enabling JSPI brings back §1's `SuspendError`. In a browser `listen()` + does not exist at all (`ENVIRONMENT_IS_NODE` only). +3. *Decision:* `proxyBootstrapSupportsAsyncio` stays **false** on wasmJs. A selector-free loop works as a + mechanism (the `WasmSelectorsImportTest` run), but shipping one means keeping the whole + `Runner`/`BaseEventLoop` contract (`call_later`, `shutdown_asyncgens`, `shutdown_default_executor`, + exception handling, `KeyboardInterrupt`) correct across CPython versions. Whether the library ships a + custom loop is an open decision (§8). Until then a wasm application can rely on the fast path and on + failure-before-suspend (`AsyncUpcallPortabilityTest`). + +--- + +## 6. Cycle collection is part of the table's job + +A Python proxy holding a Kotlin object that holds a Python object forms a cycle neither collector can break +alone: CPython's traversal stops at the opaque handle, and the JVM sees the handle table as a live root. +Reference counting never frees cycles in any language. The fix makes the Kotlin side's Python references +visible to `tp_traverse`: the proxy's traverse reaches through the handle and enumerates the `PyObject`-typed +properties of the Kotlin object. KSP already walks every exposed class, so a traverse body per class is one +more generated field (`FragmentScanner.traverseBody`, `ClassModel.traverseBody`), not a new mechanism. A +reflection-based binding cannot afford this, which is why mature ones document "do not create cycles" +instead. Generated proxies reach the traversable type through the GC base of §4.2. + +Status: SPEC M-3 — implemented on desktop (`desktopTest/.../ref/CycleCollectionTest.kt`, +`ksp-fixtures/app/.../RefHolderCycleCollectionTest.kt`), partial elsewhere. Mechanism and the hard parts +(traverse during collection, `tp_clear` mutating Kotlin state, cycles closing on the Kotlin side) are in +[`object-lifetime.md`](object-lifetime.md). + +--- + +## 7. Measurements + +How to reproduce: the cost table is cut by `./benchmarks/cost-table.sh --runs 3` (see +[`cost-table.md`](../investigations/cost-table.md) "다시 뽑는 법"). Individual tests print their own blocks: + +| Test | Source set | Prints | +|---|---|---| +| `UpcallBoundaryCostTest` | `commonTest` (`python/native/ffi/`) | one upcall vs. downcall of the same shape vs. trampoline, with controls | +| `GeneratedProxyCostTest` | `commonTest` (`ffi/upcall/`) | proxy layer over the raw call; install cost | +| `UpcallOverheadTest` | `androidInstrumentedTest` | worker vs. instrumentation thread, first upcall on a fresh worker | +| `UpcallOverheadTest` | `commonTest` (`reflection/`) | name resolution vs. cached handle, inside Kotlin | +| `WasmUpcallRouteOverheadTest` | `wasmJsTest` | `Table.set` route vs. `addFunction` around a JS closure | + +Rules: run alone, unfiltered, after checking `uptime` (AGENTS.md §8); ranges are min–max over the stated runs; +a single reading is not a measurement. + +### 7.1 One upcall, per platform + +Current figures, from `cost-table.md` (commit `958c0082b294`, 2026-08-14, Apple M1, `UpcallBoundaryCostTest` +warmup 100 000, 10 000 iterations per loop, 3 runs per target; desktop/iOS/wasmJs/androidNative from +`cut-hostless`, ART from `cut-api36` / `cut-api26`): + +| Platform | upcall | downcall, same shape | trampoline alone | upcall / downcall | upcall / trampoline | +|---|---|---|---|---|---| +| desktop (JVM 21.0.12, macOS arm64) | 542.84–576.45 ns | 260.27–265.81 ns | 170.44–247.42 ns | 2.04–2.20x | 5.54–6.46x | +| wasmJs (Node) | 302.41–310.72 ns | 96.53–100.95 ns | 149.50–164.88 ns | 3.07–3.14x | 2.39–2.46x | +| iOS simulator (26.2, arm64) | 2253.20–2312.97 ns | 1585.46–1655.40 ns | 2794.42–2843.16 ns | 1.39–1.42x | 1.14–1.15x | +| androidNative `pmp_api36` | 3348.75–3443.23 ns | 2194.26–2365.61 ns | 3957.01–4069.40 ns | 1.45–1.54x | 1.19–1.20x | +| androidNative `pmp_api26` † | 3234–3310 ns | 2470–2554 ns | 2731–3228 ns | 1.28–1.32x | 1.02–1.20x | +| ART `pmp_api36` ‡ | 995.61–1154.10 ns | 804.27–838.60 ns | 1100.87–1167.17 ns | 1.18–1.43x | 1.10–1.29x | +| ART `pmp_api26` ‡ | 1196.52–1272.28 ns | 1253.41–1295.79 ns | 1800.86–1863.97 ns | 0.92–1.01x | 1.05–1.18x | + +† Not in the cost table (it cuts androidNative on `pmp_api36` only); hand-cut at warmup 100 000, five runs. +Its spread is the emulator's: in two rounds a different single run was the outlier in a different row (5554 ns +downcall at warmup 3 000; a 3227.87 ns GIL-held trampoline at 100 000, which alone widens `upcall / trampoline` +from 1.16–1.18x). Read `pmp_api36` for the shape and `pmp_api26` for how much confidence an emulator supports. +No androidNative figure is a hardware number. +‡ The cost table marks both ART cuts "failed"; the failures were in `PhantomCleanerRegistryTest`, not the +benchmark, and were fixed in `c180c46f`. + +Controls from the same cut: + +| Target | empty Python loop | pure-Python callee | GIL-held trampoline | +|---|---|---|---| +| desktop | 8.67–8.68 ns | 25.77–26.09 ns | 85.52–97.95 ns | +| iOS simulator | 9.84–10.17 ns | 36.80–38.32 ns | 1948.58–2020.43 ns | +| wasmJs | 14.74–15.34 ns | 47.89–49.22 ns | 123.15–129.83 ns | +| androidNative `pmp_api36` | 17.22–17.31 ns | 48.85–58.45 ns | 2790.19–2857.59 ns | +| ART `pmp_api26` | 20.01–20.08 ns | 56.10–65.66 ns | 1073.40–1142.08 ns | +| ART `pmp_api36` | 17.22–18.14 ns | 58.40–65.30 ns | 873.48–911.95 ns | + +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 +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. + +### 7.2 Warmup: why 100 000, and what the old figures were measuring + +`UpcallBoundaryCostTest` warmed 3 000 calls and every figure it published was taken before the host JIT +finished tiering up, so the number depended on how much upcall traffic **the rest of the suite** had pushed +through the process first. Diagnosis: on wasmJs, commit `4472f83a` made generated proxies installable, so +`GeneratedProxyCostTest` began driving ~270 000 upcalls before this test, and short-circuiting that one test +restored the figure; the original absolutes (desktop 861–1313 ns, wasmJs 703–1075 ns) fell on re-measurement +with the downcall column standing still. The fix (`7e9c6b8c`) is the warmup count, chosen from a measured +convergence point; bisections, the controlled experiment and the 40-rep sweep that identified host JIT tier-up +and ruled out CPython-side state are in [`downcall-design.md`](downcall-design.md) ("Ratio consistency" and +"The benchmark was measuring the benchmark"). + +**Check that it is fixed:** at warmup 100 000, short-circuiting `GeneratedProxyCostTest` moves desktop +510–560 → 501–530 ns and wasmJs 290–304 → 287–301 ns (overlapping); at 3 000 the same lever moved desktop +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: + +| re-measured at 100 000 | upcall, old → new | downcall, old → new | +|---|---|---| +| iOS simulator | 2263–2502 → 2266–2301 ns | 1599–1826 → 1599–1610 ns | +| androidNative `pmp_api36` | 3339–3598 → 3228–3275 ns | 2170–2222 → 2155–2229 ns | +| androidNative `pmp_api26` | 3314–4140 → 3234–3310 ns | 2636–5554 → 2470–2554 ns | + +against desktop 674 → 537 ns, wasmJs 322 → 292 ns and ART `pmp_api36` 2301–3086 → 944–1063 ns (API 26 barely +moved, 1209–1329 → 1146–1233 ns). ART has a JIT and moved like desktop and wasmJs. "Did not fall" is not "needed +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: + +| Target | rep 1 / plateau (Python-driven upcall) | flat from | margin at 100 000 | +|---|---|---|---| +| iOS simulator | 1.01x | ~10 000 | 10x | +| 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`'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 +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: +40 000 cold / 70 000 warm on desktop, 70 000 on wasm; `overhead/BenchmarkTest`'s worst desktop row +(`PyUnicode_AsUTF8 (8 chars)`) reads 557 ns at 40 000 and 78–84 ns from 70 000. **100 000 is therefore the +constant everywhere**: `UpcallOverheadTest` (ART only) now uses it for its Python-driven loops and Kotlin-driven +downcall rows (previously 3 000 and 2 500), and `overhead/BenchmarkTest` uses it uniformly. With no margin on +ART `pmp_api36`, a slower device or busier host may read the boundary before it settles. The +`GeneratedProxyCostTest` warmup of 5 000 per row is deliberate: nineteen rows warm the shared `_pm_invoke` path +19 × 5 000 before the first timing. + +**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 +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 +full-suite runs): + +| filtered vs full suite | upcall, filtered | upcall, full | empty Python loop | pure-Python callee | +|---|---|---|---|---| +| iOS simulator | 2320–2322 ns | 2266–2301 ns | 9.9–11.4 vs 9.9–10.3 ns | 36.8–38.0 vs 36.8–38.3 ns | +| androidNative `pmp_api36` | 3275–3294 ns | 3228–3275 ns | 17.21 vs 17.22 ns | 48.9–58.2 vs 48.9–74.2 ns | +| androidNative `pmp_api26` | 3245–3280 ns | 3234–3310 ns | 20.0–23.6 vs 20.0–20.6 ns | 51.7–55.3 vs 51.7–62.0 ns | +| ART `pmp_api36` | 996–1031 ns | 944–1063 ns | 17.2–19.3 vs 17.2–22.8 ns | 49.0–60.0 vs 49.2–63.3 ns | +| ART `pmp_api26` | 1118–1171 ns | 1146–1233 ns | 20.0–20.1 vs 20.0–21.5 ns | 51.7–63.4 vs 51.7–65.6 ns | + +So device absolutes are quotable as absolutes: there CPython is native code the host executes, not a wasm +module the host compiles. + +**ART rows are measured on the instrumentation thread**, which is fair now that the attach is amortised (§7.6). +On API 26 the worker is 43–249 ns dearer than the instrumentation thread, so the ART row is that much optimistic +against a `threading.Thread` caller. + +**androidNative had no test run task** until `:python-multiplatform:androidNativeArm64Test` +(`python-multiplatform/build.gradle.kts`): KGP only registers execution tasks for the host and simctl. The task +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. + +### 7.3 The trampoline column needs a control + +`UpcallTrampoline` takes its GIL pair unconditionally (§3.5), so driving it from a bare Kotlin thread pays a real +GIL acquisition per call while Python drives it already holding the GIL. Uncontrolled, the boundary prices +**negative** on devices: at warmup 100 000, five runs each, upcall / trampoline was 0.79–0.82x on the iOS +simulator, 0.82–0.85x and 0.77–0.78x on androidNative `pmp_api36`/`pmp_api26`, 0.86–0.95x and 0.65–0.70x on ART +`pmp_api36`/`pmp_api26`. It is measured twice, differing in that one thing, with an empty +`Python3.withPython { }` for scale (hand-cut round, warmup 100 000): + +| | desktop | wasmJs | iOS sim | androidNative 36 | androidNative 26 | ART 36 | ART 26 | +|---|---|---|---|---|---|---|---| +| trampoline, caller holds nothing | 164–181 ns | 146–164 ns | 2794–2862 ns | 3789–3893 ns | 4174–4267 ns | 1093–1148 ns | 1693–1822 ns | +| trampoline, GIL already held | 76–91 ns | 122–129 ns | 1962–1990 ns | 2699–2760 ns | 2731–3228 ns | 793–907 ns | 1012–1087 ns | +| `Python3.withPython { }`, empty | 50–57 ns | 24–28 ns | 720–731 ns | 932–1002 ns | 1112–1221 ns | 276–303 ns | 498–549 ns | + +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 +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 — + 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 + 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`. +- **Desktop: a real boundary.** GIL-held trampoline 76–91 ns against a 510–560 ns upcall (hand-cut round): ~420–480 + 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 + 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 + 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 + (§7.6). Neither is on the marshalling path. + +### 7.5 What a user calls: the generated proxy over the boundary + +`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 +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 +proxy), not against §7.1's absolutes. + +| Surface a user writes | raw boundary | through the proxy | ratio | proxy layer adds | +|---|---|---|---|---| +| **desktop** (JVM 21.0.12, macOS arm64) | | | | | +| `demo.calc.ping()` module function | 452–475 ns | 468–485 ns | 1.00–1.03x | +5…+17 ns | +| `c.increment(5)` instance method | 514–558 ns | 537–573 ns | 1.02–1.05x | +14…+27 ns | +| `c.value` property read | 495–527 ns | 509–542 ns | 1.02–1.03x | +14…+19 ns | +| `c.label = 'x'` property write | 522–548 ns | 569–609 ns | 1.09–1.11x | +47…+62 ns | +| `Counter.created` static read (metaclass) | 449–485 ns | 480–510 ns | 1.01–1.07x | +6…+34 ns | +| `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) | +| *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 | +| `c.increment(5)` instance method | 2901–2987 ns | 2895–3029 ns | 0.98–1.01x | −44…+42 ns | +| `c.value` property read | 2504–2601 ns | 2510–2620 ns | 0.99–1.00x | −3…+19 ns | +| `c.label = 'x'` property write | 2832–2940 ns | 2925–3092 ns | 1.03–1.05x | +92…+153 ns | +| `Counter.created` static read (metaclass) | 1838–1931 ns | 1906–1982 ns | 1.01–1.03x | +28…+68 ns | +| `Counter.created = 12` static write | 2180–2315 ns | 2277–2365 ns | 1.02–1.04x | +50…+103 ns | +| `demo.calc.tally` top-level read | 1838–1931 ns | 1927–1981 ns | 1.02–1.05x | +48…+99 ns | +| `demo.calc.tally = 9` top-level write | 2180–2315 ns | 2286–2363 ns | 1.02–1.04x | +48…+105 ns | +| `Counter(10)` constructor | 2591–2724 ns | 2769–2864 ns | 1.05–1.06x | +140…+177 ns | +| *resolution* | | | | *+15…+79 ns* | + +**The boundary is the whole cost**: the generated layer adds tens of ns (1.00–1.13x); bypassing proxies to call +`_pm_invoke` by hand would recover almost nothing. The desktop constructor row is not reportable: when it was +taken nothing released constructed objects, so both rows ran against a `HandleTable` growing to 78 002 live +handles and the raw baseline moved between 511 and 775 ns (since fixed by `__del__`, §4.2). On the simulator it +is a stable +140…+177 ns (`type.__call__` + `__init__` frame + attribute store). Half the simulator rows are +inside its resolution; desktop resolves to ±10 ns. + +**wasmJs** (`wasmJsNodeTest`, one run as printed; harness floor 62.48 ns): + +``` + module function raw 301.91 ns proxy 318.98 ns 1.05x (+17.06 ns) + constructor raw 719.75 ns proxy 1070.98 ns 1.48x (+351.23 ns) + instance method raw 339.22 ns proxy 357.47 ns 1.05x (+18.24 ns) + property read raw 290.94 ns proxy 308.41 ns 1.06x (+17.47 ns) + property write raw 360.42 ns proxy 438.77 ns 1.21x (+78.34 ns) + static read raw 228.82 ns proxy 300.94 ns 1.31x (+72.12 ns) + module read raw 228.82 ns proxy 324.39 ns 1.41x (+95.57 ns) +Resolution: two identical raw rows differ by -73.09 ns +HandleTable roots before the timed loops: 1, after: 1 +``` + +The static and module rows (+72…+96 ns) are the size of the resolution (73 ns) and are not numbers; the +constructor's +351 ns is (`type.__call__` + `__init__` + attribute store + `__del__`). Roots 1 → 1 shows ten +thousand `Counter(10)`s all released through `__del__` → `_pm_release` (a mis-dispatched release would have +leaked silently). Android and androidNative also run this `commonTest` file; no proxy-cost rows were recorded +for them (missing, not zero). + +**The top-level property fix.** A top-level `val` read used to cost +551…+587 ns over its boundary on desktop +(2.16–2.29x) against +6…+34 ns for the metaclass doing the same job for a class static. A standalone probe, net of +an empty call: plain module attribute 14 ns; PEP 562 module `__getattr__` 214 ns; type-level `__getattr__` hook +(the old shape) 569 ns. After moving to a per-module data descriptor: + +| desktop, net of the boundary | before | after | +|---|---|---| +| `demo.calc.tally` read | +551…+587 ns (2.16–2.29x) | +14…+36 ns (1.03–1.07x) | +| `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. + +**The await fast path costs one coroutine frame:** + +| | desktop | iOS simulator | +|---|---|---| +| `_pm_invoke(h, (21,))` in the same coroutine loop, no await | 561–617 ns | 3025–3055 ns | +| `await` a pure-Python coroutine that never suspends | 67–70 ns | 84.5–84.8 ns | +| `await demo.calc.doubleNow(21)` through the generated `async def` | 618–647 ns | 3186–3239 ns | +| ratio, and what the wrapper adds | 1.03–1.10x, +24…+57 ns | 1.04–1.06x, +152…+185 ns | +| `asyncio.Future`s constructed during the timed loops | **0** (asserted) | **0** (asserted) | + +**Install is a startup cost.** For the 11-entry, 2-class fixture (306 lines / 12.4 kB): + +| | desktop | iOS simulator | wasmJs (338 lines, 14 052 chars) | +|---|---|---|---| +| `PythonProxySource.render()` | 226–271 µs | 606–664 µs | 186.63 µs | +| `Python3.exec` of the result | 816–927 µs | 2481–2532 µs | 2155.50 µs | +| `install()` end to end | 1.06–1.20 ms | 3.09–3.20 ms | 2342.14 µs | + +Compiling and executing the Python is 75–80% of it; it scales with table size (ten times the surface ≈ tens of +ms, less than `Py_Initialize`). + +### 7.6 Android: the attach, priced + +All figures below were taken by `UpcallOverheadTest` (`androidInstrumentedTest`) at its old 3 000-call warmup, +two runs per emulator unless stated; they are kept as the record of how the per-call attach was found and +removed. **The within-run differences and ratios are quotable; the absolute ns are not** (they were read before +tier-up and are expected to fall at 100 000). + +Per-call attach/detach (before): + +| per upcall | API 26 | API 36 | +|---|---|---| +| instrumentation thread (ART already knows it) | 1307–1695 ns | 5307–6693 ns | +| Python worker thread, steady state | 59712–63293 ns | 19820–31219 ns | +| **the attach/detach pair** | **58405–61598 ns** | **14514–24526 ns** | +| upcall / downcall of the same shape, worker | 44.59–47.93x | 11.69–13.23x | + +Attach once per thread (after, commit `409da6fc`): + +| per upcall | API 26 | API 36 | +|---|---|---| +| instrumentation thread | 1280–1327 ns | 2982–5086 ns | +| Python worker thread, steady state | **1209–1329 ns** | **2301–3086 ns** | +| worker minus instrumentation thread | −118 to +49 ns | −681 to −2000 ns | +| 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 +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 | +|---|---|---| +| instrumentation thread | 1085–1273 ns | 1032–1229 ns | +| Python worker, steady state | 1249–1334 ns | 982–1219 ns | +| worker minus instrumentation | **+43 to +249 ns** | **−50 to +12 ns** | +| 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 +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. + +--- + +## 8. Open items + +Only what is really open; each with what proves the done part. + +1. **Async proxies on wasmJs.** Synchronous proxies run (`PythonProxyInstallTest` 10/11 on `wasmJsNodeTest`); + `import asyncio` works (`WasmSelectorsImportTest`); the default event loop cannot be built (§5.8). Open + decision: whether the library ships a selector-free loop. SPEC U-5 `planned` on wasm. +2. **Kotlin-side forced cancellation.** Structurally impossible without `kotlinx.coroutines`' `Job` tree; only + cooperative `ensureActive()` exists (§5.6). Not planned unless that dependency is taken. +3. **A handle left by a dead loop.** The Kotlin `finally` release is meant to cover a loop that stopped before + its done callback ran; that case was never constructed and observed (unverified). +4. **Silent exclusions without a KSP warning.** A `suspend` function *type* warns (§5.3); a `suspend fun` with + an extension receiver or type parameters is still dropped silently by the shape checks + (`BindingPolicy.isExposedFunctionShape`). The warning belongs there too. +5. **Extension functions through KSP.** KSP excludes them; the artifact walker binds them as receiver methods + (SPEC U-7 `partial`). +6. **Kotlin calling a Python override.** Not implemented (§4.4). +7. **Per-target gaps outside desktop.** U-3/U-4 are asserted on desktop, with smoke coverage elsewhere + (`NativeSmokeTest`, `GeneratedAndroidTableTest`); the native async tests have no recorded per-test + androidNative result (§5.7); no proxy-cost rows for Android/androidNative (§7.5); the iOS sample cannot show + the really-suspending demo (§5.8). +8. **`BYTES` marshalling** is per item until `PyBytes_AsStringAndSize` is bound (§3.3). +9. **Incremental KSP** needs per-file fragments (§2.7). **Transitive and published-artifact discovery** are + unverified (§2.9). +10. **Native-image verification is manual** (SPEC U-6, N-5); automation is planned. +11. **Proxy-cost re-measurement.** §7.5's tables predate the 100 000 warmup; §7.6's absolutes predate it too. diff --git a/docs/guide/.nojekyll b/docs/guide/.nojekyll new file mode 100644 index 000000000..e69de29bb diff --git a/docs/guide/assets/site.js b/docs/guide/assets/site.js new file mode 100644 index 000000000..4b80c6882 --- /dev/null +++ b/docs/guide/assets/site.js @@ -0,0 +1,55 @@ +// Language and theme toggles. Preferences live in localStorage when it is available; +// the page renders correctly without it. +(function () { + var root = document.documentElement; + function load(key) { try { return window.localStorage.getItem(key); } catch (e) { return null; } } + function save(key, value) { try { window.localStorage.setItem(key, value); } catch (e) { /* storage unavailable */ } } + + function initialLang() { + var stored = load('pm-guide-lang'); + if (stored === 'en' || stored === 'ko') return stored; + var nav = (navigator.language || 'en').toLowerCase(); + return nav.indexOf('ko') === 0 ? 'ko' : 'en'; + } + function applyLang(lang) { + root.setAttribute('lang', lang); + var t = document.querySelector('[data-title-' + lang + ']'); + if (t) document.title = t.getAttribute('data-title-' + lang); + var btn = document.getElementById('lang-toggle'); + if (btn) { btn.textContent = lang === 'en' ? '한국어' : 'English'; btn.setAttribute('aria-label', lang === 'en' ? '한국어로 보기' : 'View in English'); } + } + function applyTheme(theme) { + if (theme === 'light' || theme === 'dark') root.setAttribute('data-theme', theme); else root.removeAttribute('data-theme'); + var btn = document.getElementById('theme-toggle'); + if (btn) btn.textContent = isDark() ? '☾' : '☀'; + } + function isDark() { + var t = root.getAttribute('data-theme'); + if (t) return t === 'dark'; + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + } + + applyLang(initialLang()); + applyTheme(load('pm-guide-theme')); + + document.addEventListener('DOMContentLoaded', function () { + applyLang(root.getAttribute('lang')); + applyTheme(root.getAttribute('data-theme')); + var lb = document.getElementById('lang-toggle'); + if (lb) lb.addEventListener('click', function () { + var next = root.getAttribute('lang') === 'en' ? 'ko' : 'en'; + applyLang(next); save('pm-guide-lang', next); + }); + var tb = document.getElementById('theme-toggle'); + if (tb) tb.addEventListener('click', function () { + var next = isDark() ? 'light' : 'dark'; + applyTheme(next); save('pm-guide-theme', next); + }); + var mb = document.getElementById('menu-toggle'); + var nav = document.getElementById('site-nav'); + if (mb && nav) mb.addEventListener('click', function () { + var open = nav.classList.toggle('open'); + mb.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + }); +})(); diff --git a/docs/guide/assets/style.css b/docs/guide/assets/style.css new file mode 100644 index 000000000..bf63eb2ec --- /dev/null +++ b/docs/guide/assets/style.css @@ -0,0 +1,132 @@ +/* python-multiplatform guide — shared thisisthepy tokens, accent #3b6cff */ +:root { + --bg: #fbfbfa; --fg: #16181d; --muted: #5b6270; --line: #e6e6e3; --code-bg: #f3f3f1; + --radius: 10px; --maxw: 72rem; + --font-sans: "Inter", "IBM Plex Sans KR", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + --accent: #3b6cff; --accent-soft: rgba(59, 108, 255, .10); --surface: #ffffff; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { + --bg: #0f1115; --fg: #e8e9ec; --muted: #9aa1ad; --line: #252a33; --code-bg: #161a21; + --accent: #6d8fff; --accent-soft: rgba(109, 143, 255, .14); --surface: #13161b; color-scheme: dark; } } +:root[data-theme="dark"] { + --bg: #0f1115; --fg: #e8e9ec; --muted: #9aa1ad; --line: #252a33; --code-bg: #161a21; + --accent: #6d8fff; --accent-soft: rgba(109, 143, 255, .14); --surface: #13161b; color-scheme: dark; } + +/* language switching: only the active language is shown */ +html[lang="en"] [data-lang="ko"], html[lang="ko"] [data-lang="en"] { display: none !important; } + +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; background: var(--bg); color: var(--fg); + font: 16px/1.65 var(--font-sans); font-feature-settings: "cv11", "ss01"; + overflow-x: hidden; +} +html[lang="ko"] body { word-break: keep-all; line-height: 1.75; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; text-underline-offset: 3px; } +code, pre { font-family: var(--font-mono); font-size: .88em; } +:not(pre) > code { background: var(--code-bg); padding: .1em .35em; border-radius: 5px; overflow-wrap: anywhere; } +pre { + background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--radius); + padding: 1rem 1.1rem; overflow-x: auto; line-height: 1.55; margin: 1.1rem 0; +} +.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 16px; } +@media (min-width: 720px) { .wrap { padding: 0 32px; } } + +/* header */ +.site-header { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: saturate(1.4) blur(10px); -webkit-backdrop-filter: saturate(1.4) blur(10px); + border-bottom: 1px solid var(--line); } +.bar { display: flex; align-items: center; gap: 1rem; min-height: 60px; flex-wrap: wrap; } +.brand { display: flex; align-items: center; gap: .55rem; color: var(--fg); font-weight: 650; letter-spacing: -.01em; } +.brand:hover { text-decoration: none; } +.brand .mark { width: 22px; height: 22px; flex: none; } +.nav { display: flex; gap: .2rem; flex-wrap: wrap; margin-left: auto; } +.nav a { color: var(--muted); padding: .35rem .6rem; border-radius: 8px; font-size: .93rem; } +.nav a:hover { color: var(--fg); background: var(--accent-soft); text-decoration: none; } +.nav a[aria-current="page"] { color: var(--fg); font-weight: 600; } +.tools { display: flex; gap: .4rem; } +.tool { font: inherit; font-size: .85rem; color: var(--fg); background: transparent; border: 1px solid var(--line); + border-radius: 8px; padding: .3rem .6rem; cursor: pointer; min-width: 2.4rem; } +.tool:hover { border-color: var(--accent); } +.menu-toggle { display: none; } +@media (max-width: 820px) { + .menu-toggle { display: inline-block; margin-left: auto; } + .nav { display: none; width: 100%; order: 3; flex-direction: column; padding: .3rem 0 .8rem; margin-left: 0; } + .nav.open { display: flex; } + .tools { order: 2; } +} + +/* hero */ +.hero { padding: 4.5rem 0 3rem; border-bottom: 1px solid var(--line); } +.eyebrow { font: 500 .8rem/1 var(--font-mono); letter-spacing: .08em; text-transform: uppercase; color: var(--accent); } +.hero h1 { font-size: clamp(2rem, 5.2vw, 3.5rem); line-height: 1.12; letter-spacing: -.03em; margin: 1rem 0 1.2rem; font-weight: 700; max-width: 22ch; } +html[lang="ko"] .hero h1 { letter-spacing: -.02em; max-width: 20ch; } +.hero p.lead { font-size: clamp(1.05rem, 2vw, 1.2rem); color: var(--muted); max-width: 46rem; margin: 0 0 2rem; } +.cta { display: flex; gap: .7rem; flex-wrap: wrap; } +.btn { display: inline-block; padding: .65rem 1.1rem; border-radius: 9px; border: 1px solid var(--line); color: var(--fg); font-weight: 550; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn:hover { text-decoration: none; border-color: var(--accent); } + +/* content */ +main { padding: 2.5rem 0 4rem; } +.page-head { padding: 3rem 0 1.5rem; border-bottom: 1px solid var(--line); margin-bottom: 2rem; } +.page-head h1 { font-size: clamp(1.8rem, 4vw, 2.6rem); letter-spacing: -.025em; margin: .6rem 0 .6rem; line-height: 1.2; } +.page-head p { color: var(--muted); max-width: 46rem; margin: 0; } +.prose { max-width: 46rem; } +.prose h2 { font-size: 1.45rem; letter-spacing: -.015em; margin: 2.8rem 0 .8rem; line-height: 1.3; } +.prose h3 { font-size: 1.1rem; margin: 2rem 0 .5rem; } +.prose p, .prose li { color: var(--fg); } +.prose .muted { color: var(--muted); } +h2 .num { font: 500 .8rem var(--font-mono); color: var(--accent); margin-right: .6rem; vertical-align: .2em; } + +.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); margin: 1.5rem 0; } +.card { border: 1px solid var(--line); border-radius: var(--radius); padding: 1.2rem 1.25rem; background: var(--surface); } +.card h3 { margin: 0 0 .4rem; font-size: 1.02rem; } +.card p { margin: 0; color: var(--muted); font-size: .95rem; } +a.card { color: inherit; display: block; transition: border-color .15s; } +a.card:hover { border-color: var(--accent); text-decoration: none; } + +.principles { counter-reset: p; list-style: none; padding: 0; margin: 1.5rem 0; display: grid; gap: 0; } +.principles li { counter-increment: p; display: grid; grid-template-columns: 2.6rem 1fr; gap: .5rem; padding: 1.1rem 0; border-top: 1px solid var(--line); } +.principles li::before { content: counter(p, decimal-leading-zero); font: 500 .85rem/1.9 var(--font-mono); color: var(--accent); } +.principles strong { display: block; margin-bottom: .15rem; } +.principles span.d { color: var(--muted); } + +.table-wrap { overflow-x: auto; margin: 1.2rem 0; border: 1px solid var(--line); border-radius: var(--radius); } +table { border-collapse: collapse; width: 100%; font-size: .93rem; } +th, td { text-align: left; padding: .6rem .85rem; border-bottom: 1px solid var(--line); vertical-align: top; } +th { font-weight: 600; color: var(--muted); font-size: .82rem; text-transform: uppercase; letter-spacing: .04em; } +tr:last-child td { border-bottom: 0; } +.pill { display: inline-block; font: 500 .75rem/1.5 var(--font-mono); padding: .05rem .5rem; border-radius: 999px; border: 1px solid var(--line); white-space: nowrap; } +.pill.ok { color: #0f9d76; border-color: color-mix(in srgb, #0f9d76 40%, transparent); } +.pill.part { color: #b7791f; border-color: color-mix(in srgb, #b7791f 40%, transparent); } +.pill.plan { color: var(--muted); } + +.note { border-left: 3px solid var(--accent); background: var(--accent-soft); padding: .8rem 1rem; border-radius: 0 var(--radius) var(--radius) 0; margin: 1.2rem 0; } +.note p { margin: 0; } + +figure.diagram { margin: 2.5rem 0 1rem; } +figure.diagram svg { width: 100%; height: auto; display: block; } +figure.diagram figcaption { color: var(--muted); font-size: .9rem; margin-top: .6rem; } +.d-box { fill: var(--surface); stroke: var(--line); stroke-width: 1.5; } +.d-box.accent { stroke: var(--accent); } +.d-label { fill: var(--fg); font: 600 15px var(--font-sans); } +.d-sub { fill: var(--muted); font: 400 12.5px var(--font-mono); } +.d-line { stroke: var(--accent); stroke-width: 2; fill: none; } +.d-line.muted { stroke: var(--muted); stroke-dasharray: 4 4; } +.d-arrow { fill: var(--accent); } +.d-tag { fill: var(--accent); font: 500 12px var(--font-mono); } + +.pager { display: flex; justify-content: space-between; gap: 1rem; margin-top: 3.5rem; padding-top: 1.5rem; border-top: 1px solid var(--line); flex-wrap: wrap; } +.site-footer { border-top: 1px solid var(--line); padding: 2rem 0 3rem; color: var(--muted); font-size: .9rem; } +.site-footer .wrap { display: flex; gap: 1rem; justify-content: space-between; flex-wrap: wrap; } +@media (max-width: 420px) { + .bar { gap: .5rem; } + .brand { font-size: .95rem; } + .tool { padding: .3rem .45rem; } + .hero { padding: 3rem 0 2rem; } +} diff --git a/docs/guide/concepts.html b/docs/guide/concepts.html new file mode 100644 index 000000000..d45a88645 --- /dev/null +++ b/docs/guide/concepts.html @@ -0,0 +1,91 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Concepts · python-multiplatform + + + + + + + + + + +
+
Concepts개념
+

Two directions, one boundary두 방향, 하나의 경계

+

Every design decision in this project splits along the direction of the call. The two directions are orthogonal and use entirely different mechanisms.이 프로젝트의 모든 설계는 호출 방향을 따라 갈린다. 두 방향은 직교하며 메커니즘이 완전히 다르다.

+
+
+
+ + Kotlin and CPython in one process: downcalls through the Stable ABI, upcalls through one generated table + + + Kotlin + + PyObject · PyList · PyDicttyped object model타입이 있는 객체 모델 + + EmbedAPI~330 expect fun · Stable ABI + + Generated function table생성된 함수 테이블KSP · Gradle artifact walkerKSP · Gradle 아티팩트 워커 + + CPython + + Interpreter + C extensions인터프리터 + C 확장the real CPython진짜 CPython + + Proxies: Greeter(...), Modifier프록시: Greeter(...), Modifierordinary Python평범한 Python + + downcall다운콜 + + upcall · 1 entry업콜 · 진입점 1개 + +
One process, two runtimes. Behind each platform's single entry point, every platform uses the same generated table.한 프로세스, 두 런타임. 플랫폼마다 진입점은 하나이고, 그 뒤에서는 모든 플랫폼이 같은 생성 테이블을 쓴다.
+
+

01Downcall: Kotlin → Python다운콜: Kotlin → Python

+

The CPython Stable ABI is declared once, in EmbedAPI.kt, as about 330 expect functions. Each platform supplies the actual: Panama FFM with invokeExact on the desktop JVM, JNI registered through RegisterNatives on Android, cinterop on Kotlin/Native, and an Emscripten build on wasm.CPython Stable ABI 를 EmbedAPI.kt 에 약 330개의 expect 함수로 한 번 선언한다. 플랫폼마다 actual 을 둔다: 데스크톱 JVM 은 invokeExact 를 쓰는 Panama FFM, Android 는 RegisterNatives 로 등록한 JNI, Kotlin/Native 는 cinterop, wasm 은 Emscripten 빌드.

+

On top sits a typed object model, sketched by the maintainer in python_for_kotlin_binding.mermaid: PyObject at the root, PyType, PyException as a Throwable, and collections that implement the Kotlin interfaces.그 위에 타입이 있는 객체 모델이 있다. 메인테이너가 python_for_kotlin_binding.mermaid 에 스케치한 대로, 최상위에 PyObject, 그리고 PyType, Throwable 인 PyException, Kotlin 인터페이스를 구현하는 컬렉션들이 있다.

+ +

02Upcall: Python → Kotlin업콜: Python → Kotlin

+

Kotlin/Native has no useful reflection and a GraalVM native image is a closed world, so nothing is looked up at run time. At build time, a KSP processor (for your sources) and the Gradle plugin's artifact walker (for prebuilt jars) generate a function table. Python reaches it through one entry point per platform, wrapped in generated proxies that read like ordinary Python.Kotlin/Native 에는 쓸 만한 리플렉션이 없고 GraalVM 네이티브 이미지는 닫힌 세계이므로, 런타임에 아무것도 조회하지 않는다. 빌드 타임에 KSP 프로세서(내 소스)와 Gradle 플러그인의 아티팩트 워커(빌드된 jar)가 함수 테이블을 생성한다. Python 은 플랫폼마다 하나인 진입점으로 그 테이블에 닿고, 평범한 Python 처럼 읽히는 생성된 프록시가 그것을 감싼다.

+

Exposure is a blacklist: every public declaration is registered, and @PythonInternal opts one out.노출은 블랙리스트다. 모든 public 선언이 등록되고, @PythonInternal 로 제외한다.

+ +

03Lifetime across the boundary경계를 넘는 수명

+

CPython counts references; Kotlin collects garbage. A Kotlin wrapper takes one reference and gives it back when the wrapper is collected (a Cleaner, or a PhantomReference path on older Android). Cycles that run through a Kotlin proxy are made visible to Python's cyclic GC, so they are collected too.CPython 은 참조를 세고, Kotlin 은 가비지를 수거한다. Kotlin 래퍼는 참조 하나를 잡고, 래퍼가 수거되면 돌려준다 (Cleaner, 구형 Android 에서는 PhantomReference 경로). Kotlin 프록시를 지나는 순환도 Python 의 순환 GC 에 보이게 하므로 함께 수거된다.

+ +

04Threads스레드

+

Every 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 패키지이고, 자기 저장소에 있다.

+

Design records for each topic are in docs/design.주제별 설계 기록은 docs/design 에 있다.

+ +
+ + + diff --git a/docs/guide/ecosystem.html b/docs/guide/ecosystem.html new file mode 100644 index 000000000..ec5089fcd --- /dev/null +++ b/docs/guide/ecosystem.html @@ -0,0 +1,57 @@ + + + + + +Ecosystem · python-multiplatform + + + + + + + + + + +
+
Ecosystem생태계
+

One boundary, several projects하나의 경계, 여러 프로젝트

+

python-multiplatform owns the language boundary. The repositories around it build on it.python-multiplatform 은 언어 경계를 맡는다. 주변 저장소들이 그 위에 선다.

+
+
+ + + diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html new file mode 100644 index 000000000..2336f8197 --- /dev/null +++ b/docs/guide/getting-started.html @@ -0,0 +1,88 @@ + + + + + +Get started · python-multiplatform + + + + + + + + + + +
+
Getting started시작하기
+

From clone to a running sample클론부터 샘플 실행까지

+

No release is published yet, so you build from source. The sample app is the shortest path to seeing both directions work.아직 배포된 릴리스가 없으므로 소스에서 빌드한다. 두 방향이 동작하는 것을 보는 가장 짧은 길은 샘플 앱이다.

+
+
+

01Requirements준비물

+
    +
  • A JDK for Gradle, and Git.Gradle 용 JDK 와 Git.
  • +
  • The Android SDK (with ANDROID_HOME set) for Android targets.Android 타깃에는 Android SDK (ANDROID_HOME 설정).
  • +
  • macOS with Xcode for iOS targets.iOS 타깃에는 Xcode 가 있는 macOS.
  • +
+

You do not install Python: Gradle downloads the CPython build for each platform, verifies it, and extracts it. The version is set by pythonVersion in gradle.properties.Python 을 설치하지 않는다. Gradle 이 플랫폼마다 CPython 빌드를 내려받아 검증하고 푼다. 버전은 gradle.properties 의 pythonVersion 으로 정한다.

+ +

02Clone and run클론하고 실행하기

+
git clone https://github.com/thisisthepy/python-multiplatform
+cd python-multiplatform
+./gradlew :sample:run
+

The desktop sample starts CPython, publishes a Kotlin-built list into Python, evaluates an expression, and drives a Kotlin class from Python.데스크톱 샘플은 CPython 을 시작하고, Kotlin 에서 만든 리스트를 Python 에 게시하고, 식을 평가하고, Python 에서 Kotlin 클래스를 움직인다.

+ +

03Your first call첫 호출

+
import python.multiplatform.ffi.PyObject
+import python.multiplatform.ffi.Python3
+import python.multiplatform.ffi.types.basic.PyInt
+import python.multiplatform.ffi.types.collections.PyList
+
+fun main() {
+    Python3.initialize()
+
+    val numbers = PyList.fromList(listOf(2L, 3L, 5L, 7L, 11L).map { PyInt.from(it) })
+    Python3.import("__main__").setAttr("kotlin_numbers", numbers)
+
+    val globals = Python3.import("__main__").dict
+    val result: PyObject = Python3.eval("sum(kotlin_numbers) * 2", 258, globals, globals)
+    println("${result.Type.name}: $result")   // int: 56
+}
+

There is no close() in that code, and none is missing: every wrapper releases its Python reference when the Kotlin object is collected.위 코드에는 close() 가 없고, 빠진 것도 아니다. 모든 래퍼는 Kotlin 객체가 수거될 때 Python 참조를 놓는다.

+ +

04Verify a change변경 검증하기

+
./gradlew :python-multiplatform:compileKotlinAndroidNativeArm64 --console=plain > .tmp/build.log 2>&1; echo "EXIT=$?"
+./gradlew :python-multiplatform:desktopTest --rerun --console=plain > .tmp/test.log 2>&1; echo "EXIT=$?"
+./gradlew :ksp-fixtures:app:desktopTest --rerun
+./gradlew :ksp-fixtures:compose:desktopTest --rerun
+./gradlew :ksp-fixtures:artifact:desktopTest --rerun
+

Run the fixture modules one by one: a single invocation has hidden an ordering dependency before. Read exit codes from a file, never through a pipe.픽스처 모듈은 하나씩 따로 돌린다. 한 번에 돌리면 순서 의존성이 가려진 적이 있다. 종료 코드는 파이프가 아니라 파일에서 읽는다.

+ +
+ + + diff --git a/docs/guide/guide-binding-libraries.html b/docs/guide/guide-binding-libraries.html new file mode 100644 index 000000000..cb52f2712 --- /dev/null +++ b/docs/guide/guide-binding-libraries.html @@ -0,0 +1,64 @@ + + + + + +Bind a prebuilt library · python-multiplatform + + + + + + + + + + +
+
Guide가이드
+

Bind a prebuilt library빌드된 라이브러리 바인딩

+

The Gradle plugin walks jars you depend on and generates the same kind of table KSP generates for your sources.Gradle 플러그인이 의존하는 jar 를 훑어, KSP 가 내 소스에 대해 만드는 것과 같은 종류의 테이블을 생성한다.

+
+
+

01Apply the plugin플러그인 적용

+
plugins {
+    id("io.github.thisisthepy.python.multiplatform.bindings") version "<version>"
+}
+

The walker reads Kotlin metadata with ASM, generates Kotlin source for every reachable declaration, and lets kotlinc compile it. It never looks up JVM methods by name: value-class name mangling makes that impossible in principle.워커는 ASM 으로 Kotlin 메타데이터를 읽고, 닿을 수 있는 모든 선언에 대해 Kotlin 소스를 생성해 kotlinc 가 컴파일하게 한다. JVM 메서드를 이름으로 찾지 않는다. 값 클래스 이름 맹글링 때문에 그것은 원리적으로 불가능하다.

+

02What you get얻는 것

+
    +
  • Default arguments can be omitted from Python.Python 에서 기본 인자를 생략할 수 있다.
  • +
  • Value classes round-trip; allow-listed ones such as Dp accept a raw number.값 클래스가 왕복한다. Dp 처럼 허용 목록에 있는 것은 숫자를 그대로 받는다.
  • +
  • Python callables can fill Kotlin function parameters (value-returning slots are not supported yet).Python 호출 가능 객체로 Kotlin 함수 파라미터를 채울 수 있다 (값을 돌려주는 슬롯은 아직 미지원).
  • +
  • @Composable functions can be called from Python inside a composition; clicks reach Kotlin callbacks and redraw.@Composable 함수를 컴포지션 안에서 Python 으로 부를 수 있다. 클릭이 Kotlin 콜백에 닿고 다시 그려진다.
  • +
  • .pyi stubs so an editor sees the surface (partial).에디터가 표면을 보도록 .pyi 스텁 생성 (부분).
  • +
+

Compose from Python is verified on desktop only. Kotlin/Native klibs are scanned but not bound yet.Python 에서의 Compose 는 데스크톱에서만 검증되었다. Kotlin/Native klib 은 스캔만 되고 아직 바인딩되지 않는다.

+

Specification: B-1 to B-7. Tests: ArtifactScannerTest, WalkedArtifactPythonImportTest, ComposableRenderTest.스펙: B-1 ~ B-7. 테스트: ArtifactScannerTest, WalkedArtifactPythonImportTest, ComposableRenderTest.

+ +
+ + + diff --git a/docs/guide/guide-kotlin-to-python.html b/docs/guide/guide-kotlin-to-python.html new file mode 100644 index 000000000..c3e73eb5c --- /dev/null +++ b/docs/guide/guide-kotlin-to-python.html @@ -0,0 +1,73 @@ + + + + + +Use Python from Kotlin · python-multiplatform + + + + + + + + + + +
+
Guide가이드
+

Use Python from KotlinKotlin 에서 Python 쓰기

+

Start CPython, move values across, and treat Python errors as Kotlin exceptions.CPython 을 시작하고, 값을 건네고, Python 오류를 Kotlin 예외로 다룬다.

+
+
+

01Start the interpreter인터프리터 시작

+

Python3.initialize() is idempotent. On Android, call PythonBootstrap.initialize(context) instead (see platform setup).Python3.initialize() 는 여러 번 불러도 된다. Android 에서는 대신 PythonBootstrap.initialize(context) 를 부른다 (플랫폼별 준비 참고).

+
import python.multiplatform.ffi.PyObject
+import python.multiplatform.ffi.Python3
+import python.multiplatform.ffi.types.basic.PyInt
+import python.multiplatform.ffi.types.collections.PyList
+
+fun main() {
+    Python3.initialize()
+
+    val numbers = PyList.fromList(listOf(2L, 3L, 5L, 7L, 11L).map { PyInt.from(it) })
+    Python3.import("__main__").setAttr("kotlin_numbers", numbers)
+
+    val globals = Python3.import("__main__").dict
+    val result: PyObject = Python3.eval("sum(kotlin_numbers) * 2", 258, globals, globals)
+    println("${result.Type.name}: $result")   // int: 56
+}
+

02Statements and expressions문장과 식

+

Python3.exec(source) runs statements; Python3.eval(expr, mode, globals, locals) returns a PyObject; Python3.import(name) returns a PyModule.Python3.exec(source) 는 문장을 실행하고, Python3.eval(expr, mode, globals, locals) 는 PyObject 를 돌려주며, Python3.import(name) 은 PyModule 을 돌려준다.

+

03Collections are Kotlin collections컬렉션은 Kotlin 컬렉션이다

+

PyList implements MutableList, PyDict implements MutableMap, PySet implements MutableSet, PyTuple implements List. Build them with fromList, fromMap, fromSet.PyList 는 MutableList, PyDict 는 MutableMap, PySet 은 MutableSet, PyTuple 은 List 를 구현한다. fromList, fromMap, fromSet 으로 만든다.

+

04Errors오류

+

A Python exception raised by exec or eval is thrown as PyException, carrying the real exception type and message. exec is built on PyRun_String rather than PyRun_SimpleString, which would print and clear the error before Kotlin could see it.exec 나 eval 에서 일어난 Python 예외는 실제 예외 타입과 메시지를 담은 PyException 으로 던져진다. exec 는 PyRun_SimpleString 이 아니라 PyRun_String 위에 있다 — 앞의 것은 Kotlin 이 보기 전에 오류를 출력하고 지워 버린다.

+

Specification: L-1, O-1 to O-7. Tests: Python3Test, PyListTest, PyExceptionTest.스펙: L-1, O-1 ~ O-7. 테스트: Python3Test, PyListTest, PyExceptionTest.

+ +
+ + + diff --git a/docs/guide/guide-platforms.html b/docs/guide/guide-platforms.html new file mode 100644 index 000000000..c0d0f0441 --- /dev/null +++ b/docs/guide/guide-platforms.html @@ -0,0 +1,64 @@ + + + + + +Set up each platform · python-multiplatform + + + + + + + + + + +
+
Guide가이드
+

Set up each platform플랫폼별 준비

+

CPython reads its standard library from the file system. Each platform gets one there differently.CPython 은 표준 라이브러리를 파일 시스템에서 읽는다. 플랫폼마다 그것을 두는 방법이 다르다.

+
+
+

01Desktop데스크톱

+

Apply the Gradle plugin. Its stagePythonHome task downloads the matching CPython, verifies it against the release's SHA256SUMS, caches it for the whole machine, and sets PYTHONHOME on run and test. A PYTHONHOME you set yourself is never touched.Gradle 플러그인을 적용한다. stagePythonHome 태스크가 맞는 CPython 을 내려받아 릴리스의 SHA256SUMS 로 검증하고, 기기 전체용 캐시에 두고, run 과 test 에 PYTHONHOME 을 설정한다. 직접 설정한 PYTHONHOME 은 건드리지 않는다.

+
pythonBindings { stagePythonHome.set(false) }   // opt out
+

02AndroidAndroid

+
class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        PythonBootstrap.initialize(this)
+    }
+}
+

The stdlib ships inside the APK and is unpacked to app-private storage on first launch (237–480 ms measured on API 26–36), then skipped by a stamp check (4–28 ms). The stamp is written last, so an interrupted copy is never mistaken for a complete one.stdlib 는 APK 안에 들어 있고 첫 실행에 앱 전용 저장소로 풀린다 (API 26–36 에서 237–480 ms 측정). 이후에는 스탬프 확인으로 건너뛴다 (4–28 ms). 스탬프를 마지막에 쓰므로 중단된 복사를 완료된 것으로 착각하지 않는다.

+

03iOSiOS

+

The CPython framework carries no stdlib, so PYTHONHOME must point at one. Simulator tests receive environment variables through the SIMCTL_CHILD_ prefix. Building requires macOS with Xcode.CPython 프레임워크에는 stdlib 가 없으므로 PYTHONHOME 이 stdlib 를 가리켜야 한다. 시뮬레이터 테스트는 SIMCTL_CHILD_ 접두사로 환경 변수를 받는다. 빌드에는 Xcode 가 있는 macOS 가 필요하다.

+

04Web (experimental)웹 (실험적)

+

The wasmJs target runs an Emscripten build of CPython in Node and the browser. There are no threads, so suspend upcalls are not available there.wasmJs 타깃은 Emscripten 으로 빌드한 CPython 을 Node 와 브라우저에서 돌린다. 스레드가 없으므로 그곳에서는 suspend 업콜을 쓸 수 없다.

+ +
+ + + diff --git a/docs/guide/guide-python-to-kotlin.html b/docs/guide/guide-python-to-kotlin.html new file mode 100644 index 000000000..24f76aa40 --- /dev/null +++ b/docs/guide/guide-python-to-kotlin.html @@ -0,0 +1,80 @@ + + + + + +Use Kotlin from Python · python-multiplatform + + + + + + + + + + +
+
Guide가이드
+

Use Kotlin from PythonPython 에서 Kotlin 쓰기

+

Write a Kotlin class; import it from Python under its Kotlin package name.Kotlin 클래스를 쓰고, Python 에서 그 Kotlin 패키지 이름으로 import 한다.

+
+
+

01Write KotlinKotlin 작성

+

Every public declaration is exposed. Annotate with @PythonInternal to keep one out.모든 public 선언이 노출된다. 빼고 싶으면 @PythonInternal 을 붙인다.

+
package org.thisisthepy.python.multiplatform.demo.bindings
+
+class Greeter(subject: String) {
+    var subject: String = subject
+    var greetings: Long = 0
+        private set
+
+    fun greet(times: Long): String { /* ... */ }
+    suspend fun greetNow(times: Long): String = greet(times)
+}
+

02Write PythonPython 작성

+
from org.thisisthepy.python.multiplatform.demo.bindings import Greeter
+
+g = Greeter('Kotlin')    # constructs the Kotlin object
+g.greet(2)               # calls the Kotlin method
+g.subject = 'Python'     # property setter
+g.greetings = 99         # AttributeError: the Kotlin setter is private
+
+async def main():
+    return await g.greetNow(1)   # a Kotlin suspend fun, awaited
+

Constructors, methods, properties, companion members and object declarations all work. A property with a private set is read-only from Python. Annotation classes and generic declarations are not exposed.생성자, 메서드, 프로퍼티, companion 멤버, object 선언이 모두 동작한다. private set 인 프로퍼티는 Python 에서 읽기 전용이다. 애노테이션 클래스와 제네릭 선언은 노출되지 않는다.

+

03Suspend functionssuspend 함수

+

A suspend fun is awaitable. If it never suspends it completes immediately; if it does, it is resumed from a Kotlin thread, and cancelling the Python future cancels the coroutine. On wasm, which has no threads, only the synchronous surface is available.suspend fun 은 await 할 수 있다. 중단하지 않으면 바로 끝나고, 중단하면 Kotlin 스레드에서 재개되며, Python future 를 취소하면 코루틴도 취소된다. 스레드가 없는 wasm 에서는 동기 표면만 쓸 수 있다.

+

04Extension functions확장 함수

+

A Kotlin extension function appears in Python as a method on its receiver, so chains read naturally: Modifier.padding(16).size(24).Kotlin 확장 함수는 Python 에서 수신 객체의 메서드로 보인다. 그래서 체인이 자연스럽게 읽힌다: Modifier.padding(16).size(24).

+

Status: partial. Asserted through the adapter and the artifact walker; not yet for KSP-generated proxies.상태: 부분. 어댑터와 아티팩트 워커로는 검증되었고, KSP 생성 프록시는 아직이다.

+

05Native images네이티브 이미지

+

Because the table is generated, nothing needs reflection registration: the sample's upcalls run in a GraalVM native image built with Liberica NIK.테이블을 생성하므로 리플렉션 등록이 필요 없다. 샘플의 업콜은 Liberica NIK 로 만든 GraalVM 네이티브 이미지에서 동작한다.

+

Specification: U-1 to U-7. Tests: UpcallEntryTest, PythonProxyInstallTest, GeneratedDeclarationKindsTest, AsyncUpcallDeliveryTest.스펙: U-1 ~ U-7. 테스트: UpcallEntryTest, PythonProxyInstallTest, GeneratedDeclarationKindsTest, AsyncUpcallDeliveryTest.

+ +
+ + + diff --git a/docs/guide/guides.html b/docs/guide/guides.html new file mode 100644 index 000000000..886a07113 --- /dev/null +++ b/docs/guide/guides.html @@ -0,0 +1,54 @@ + + + + + +Guides · python-multiplatform + + + + + + + + + + +
+
Guides가이드
+

Task by task작업별로

+

Each guide follows one item of the specification and points to the tests that hold it in place.각 가이드는 스펙의 한 항목을 따라가며, 그것을 붙잡고 있는 테스트를 가리킨다.

+
+
+ + + diff --git a/docs/guide/index.html b/docs/guide/index.html new file mode 100644 index 000000000..579c48fd0 --- /dev/null +++ b/docs/guide/index.html @@ -0,0 +1,108 @@ + + + + + +Home · python-multiplatform + + + + + + + + + + + +
+
Kotlin Multiplatform × CPythonKotlin Multiplatform × 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 이다.

+ +
+
+
+
+ + Kotlin and CPython in one process: downcalls through the Stable ABI, upcalls through one generated table + + + Kotlin + + PyObject · PyList · PyDicttyped object model타입이 있는 객체 모델 + + EmbedAPI~330 expect fun · Stable ABI + + Generated function table생성된 함수 테이블KSP · Gradle artifact walkerKSP · Gradle 아티팩트 워커 + + CPython + + Interpreter + C extensions인터프리터 + C 확장the real CPython진짜 CPython + + Proxies: Greeter(...), Modifier프록시: Greeter(...), Modifierordinary Python평범한 Python + + downcall다운콜 + + upcall · 1 entry업콜 · 진입점 1개 + +
One process, two runtimes. Behind each platform's single entry point, every platform uses the same generated table.한 프로세스, 두 런타임. 플랫폼마다 진입점은 하나이고, 그 뒤에서는 모든 플랫폼이 같은 생성 테이블을 쓴다.
+
+

Philosophy철학

+
+
    +
  1. The real interpreter, not an imitation흉내가 아니라 진짜 인터프리터CPython itself is embedded, so C extensions and the whole Python ecosystem keep working.CPython 그 자체를 임베딩하므로 C 확장과 Python 생태계 전체가 그대로 동작한다.
  2. +
  3. 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') 으로 생성하며, 확장 함수는 수신 객체의 메서드다.
  4. +
  5. 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 에서만 통과하는 업콜은 완료가 아니다.
  6. +
  7. 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 패키지이며, 자기 저장소에 있다.
  8. +
  9. Every crossing is measured모든 경계 통과는 측정한다Each boundary mechanism ships with overhead tests; the cost table is rendered from real runs on every target.경계 메커니즘마다 오버헤드 테스트가 함께 있고, 비용 표는 모든 타깃의 실제 실행에서 렌더링된다.
  10. +
+
+

Read next다음 읽을거리

+
+ +
+

Contributing기여하기

+

The project runs on intent-based, spec-driven, test-driven development. The intent bounds the specification; the specification drives the tests; tests are written first and seen to fail before code is written.이 프로젝트는 의도 기반 스펙 주도 개발과 테스트 주도 개발로 운영한다. 의도가 스펙의 경계를 정하고, 스펙이 테스트를 이끌며, 테스트를 먼저 쓰고 실패하는 것을 확인한 뒤 코드를 쓴다.

+
    +
  1. Check that the change serves the project's intent (docs/INTENT.md on the develop branch).변경이 프로젝트의 의도(develop 브랜치의 docs/INTENT.md)에 맞는지 확인한다.
  2. +
  3. Describe the behaviour in the specification (docs/SPEC.md), marked planned.동작을 스펙(docs/SPEC.md)에 planned 로 적는다.
  4. +
  5. Write the test, run it, and include the red output in the pull request.테스트를 쓰고 돌려서, 빨간 출력을 풀 리퀘스트에 함께 올린다.
  6. +
  7. Implement; include the androidNative compile and each ksp-fixtures module, run separately, in your verification.구현한다. 검증에는 androidNative 컴파일과 ksp-fixtures 모듈 각각(따로 실행)을 포함한다.
  8. +
+

Work on a work/<topic> branch and open the pull request against develop.work/<topic> 브랜치에서 작업하고 develop 을 대상으로 풀 리퀘스트를 연다.

+
+
+ + + diff --git a/docs/guide/status.html b/docs/guide/status.html new file mode 100644 index 000000000..c171fcf1c --- /dev/null +++ b/docs/guide/status.html @@ -0,0 +1,79 @@ + + + + + +Status & FAQ · python-multiplatform + + + + + + + + + + +
+
Status상태
+

What works today지금 되는 것

+

Status is assigned from the tests in the repository, not from plans. Partial means partial.상태는 계획이 아니라 저장소의 테스트에서 정한다. 부분은 부분이다.

+
+
+
+ + + + + + + + + + + + + + + +
Area영역Status상태
Interpreter lifecycle, downcall surface, object model인터프리터 수명주기, 다운콜 표면, 객체 모델implemented구현
Reference release and cross-boundary cycle collection (desktop)참조 해제와 경계 순환 수거 (데스크톱)implemented구현
Upcalls: classes, properties, suspend, cancellation (desktop)업콜: 클래스, 프로퍼티, suspend, 취소 (데스크톱)implemented구현
Upcalls in a GraalVM native image (desktop, manual verification)GraalVM 네이티브 이미지 업콜 (데스크톱, 수동 검증)implemented구현
Binding prebuilt jars; Compose from Python (desktop)빌드된 jar 바인딩, Python 에서 Compose (데스크톱)implemented구현
Android bootstrap and thread attachAndroid 부트스트랩과 스레드 attachimplemented구현
iOS, Android native, wasm: upcall and lifetime coverageiOS, Android native, wasm: 업콜과 수명 검증partial부분
Extension functions as methods확장 함수를 메서드로partial부분
.pyi stubs; klib binding.pyi 스텁, klib 바인딩partial부분
Linux and Windows desktop runsLinux, Windows 데스크톱 실행partial부분
Free-threaded CPython 3.14t (desktop only, opt-in)free-threaded CPython 3.14t (desktop 한정, 옵트인)partial부분
Compose from Python on Android, iOS, wasmAndroid, iOS, wasm 에서 Python 으로 Composeplanned계획
+

The full, item-by-item contract with the test that backs each item is docs/SPEC.md on the develop branch; the work still left is listed in the roadmap.항목별 계약과 각 항목을 받치는 테스트는 develop 브랜치의 docs/SPEC.md 에, 남은 작업은 로드맵 에 있다.

+ +

FAQ

+

Is it published on Maven Central?Maven Central 에 배포되어 있나?

+

Not yet. Build from source until a release is announced.아직 아니다. 릴리스가 공지될 때까지 소스에서 빌드한다.

+

Which Python version?어떤 Python 버전인가?

+

CPython 3.14 today (pythonVersion in gradle.properties). Free-threaded builds can be selected with -PpythonFreeThreaded=true; this works on desktop only (236 tests, 0 failures); Android and iOS have no free-threaded prebuilts.현재 CPython 3.14 다 (gradle.properties 의 pythonVersion). free-threaded 빌드는 -PpythonFreeThreaded=true 로 고를 수 있고, desktop 에서만 동작하며(236 테스트 0 실패) Android/iOS 에는 free-threaded 프리빌트가 없다.

+

Do C extensions work?C 확장이 동작하나?

+

Yes, in principle: this is CPython itself, not a re-implementation. Each extension still has to be built for each target platform.원리적으로 그렇다. 재구현이 아니라 CPython 그 자체이기 때문이다. 다만 확장마다 타깃 플랫폼별로 빌드되어 있어야 한다.

+

Why not reflection?왜 리플렉션을 쓰지 않나?

+

Kotlin/Native has effectively none, and a GraalVM native image only contains what was known at build time. A generated table works in both.Kotlin/Native 에는 사실상 없고, GraalVM 네이티브 이미지에는 빌드 타임에 알려진 것만 들어간다. 생성된 테이블은 둘 다에서 동작한다.

+

Why is androidx not renamed to something Pythonic?왜 androidx 를 파이써닉한 이름으로 바꾸지 않나?

+

Because a Kotlin name in Python should mean the Kotlin thing. Pythonic packages are written on top, under their own names, in pythonx-compose.Python 에서 Kotlin 이름은 Kotlin 의 그것을 뜻해야 하기 때문이다. 파이써닉한 패키지는 그 위에, 자기 이름으로, pythonx-compose 에서 작성한다.

+ +
+ + + diff --git a/docs/investigations/android-unregistered-surface.md b/docs/investigations/android-unregistered-surface.md new file mode 100644 index 000000000..26ce36956 --- /dev/null +++ b/docs/investigations/android-unregistered-surface.md @@ -0,0 +1,59 @@ +# Android unregistered surface + +## Conclusion + +**The unregistered surface is empty.** Every `external fun` in `androidMain/.../bindings.kt` has a +`JNINativeMethod` entry in `artMain/cinterop/jni_onload.def`, except four that are unregistered on +purpose: + +| declaration | why it is not in the table | +|---|---| +| `ffiAllocUtf8`, `ffiFreeUtf8`, `ffiReadUtf8` | hand-written JNI exports (`artMain/.../JNIOnLoadExporter.kt`) with the correct `JNIEnv*, jclass` prologue | +| `echoCriticalNamed` | exists to be name-linked, so a benchmark can measure that path | + +Why it matters: an unregistered `external fun` does not fail cleanly. It falls back to the +`@CName` export in `nativeMain`, which has no `JNIEnv*`/`jclass` prologue, so the arguments arrive +shifted by two registers and the process dies on a truncated pointer, a `jstring` handed to a +Kotlin/Native `String` parameter, or a boxed `java.lang.Long`. + +**Status: acted on, and enforced by a test.** +`python-multiplatform/src/desktopTest/.../native/ffi/JniCallConventionClassificationTest.kt` +`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 +(strings first, then core object model, then exceptions, then the rest) is complete. + +## Numbers (all historical) + +| | at the time of this report | later | +|---|---|---| +| unregistered `external fun` | 304 of 375 | 178 of 365 (187 registered) after the string half | +| `jstring`-carrying declarations | 52 | 6, none on a live path | +| reported "reachable from `commonMain`" | 71 | **0** when the last 157 were registered | + +The ordering recommended (reachable, string-taking first) was right; the counts were not. By the +time the last 157 were registered, earlier passes had already taken the whole reachable surface, +so "71 reachable" had been consumed long before. Current counts are not recorded here because they +change with every binding; the test above re-derives them. The ROADMAP §2 pass recorded +363 of 367 registered, with the four exceptions above. + +## How to reproduce + +```bash +./gradlew :python-multiplatform:desktopTest --tests 'python.native.ffi.JniCallConventionClassificationTest' \ + --console=plain > .tmp/jni-classification.log 2>&1; echo "EXIT=$?" +``` + +(Desktop is enough: the test is a source-level parse, not a device run. Device-side registration is +exercised by `androidInstrumentedTest/.../native/ffi/JniWiringTest.kt`.) + +## What stays useful + +The call-graph traces (public `commonMain` API → `EmbedAPI.` → `bindings.`) are the record +of which API path reaches which C function; they are in the archived original, with the +per-function reachability lists: +[`docs/archive/investigations/android-unregistered-surface.md`](../archive/investigations/android-unregistered-surface.md). + +Related: [`jni-call-convention-audit.md`](jni-call-convention-audit.md) (which convention a +registered function may use). diff --git a/docs/investigations/cost-table.md b/docs/investigations/cost-table.md new file mode 100644 index 000000000..ed9b30d15 --- /dev/null +++ b/docs/investigations/cost-table.md @@ -0,0 +1,355 @@ +# The cost table + +Kotlin ↔ Python 경계의 비용을, **여섯 타깃 전부에 대해 한 번에** 뽑아 놓은 표. + +> **결론과 상태 (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`)는 이 문서의 마커 사이만 다시 쓴다 — 마커와 그 바깥의 "다시 뽑는 법" 절은 하네스가 +> 기대하는 형식이므로 손으로 고치지 않는다. 이 표는 **측정을 기록할 뿐 임계값을 단언하지 않는다**(SPEC X-1). +> 표는 2026-08-14 컷이므로 그 뒤의 커밋을 반영하지 않는다. 다시 뽑으려면 아래 "다시 뽑는 법". + +`docs/design/upcall.md` 와 `docs/design/downcall-design.md` 는 각자의 조사 과정에서 나온 표를 갖고 있고, +그 표들은 **조사가 끝난 시점의 기록**이다. 이 문서는 다르다 — 여기 있는 표는 사람이 옮겨 적은 것이 +아니라 `benchmarks/cost_table.py` 가 실행 결과에서 직접 렌더링한 것이고, 아래 "다시 뽑는 법" 한 +줄이면 통째로 갱신된다. + +이 분리가 필요한 이유는 이 저장소가 이미 두 번 당했기 때문이다. + +1. **조건 없는 숫자.** 워밍업 3,000 에서 잰 값들이 워밍업 표기 없이 표에 들어갔고, 나중에 + 그 값들이 전부 "호스트 JIT 이 얼마나 데워져 있었는가"의 함수였다는 것이 드러났다 + (`docs/design/downcall-design.md`, "The benchmark was measuring the benchmark"). +2. **부하 걸린 기계.** 같은 커밋이 672 ns 와 1076 ns 를 오갔고 bisect 한 회차를 통째로 버렸다. + +그래서 이 하네스는 두 가지를 강제한다. **숫자와 조건을 같은 산출물에 함께 기록하고, 바쁜 기계에서는 +아예 측정을 거부한다.** + +--- + +## 다시 뽑는 법 + +```bash +ANDROID_HOME=/Users/ibrew/Library/Android/sdk ./benchmarks/cost-table.sh --runs 3 +``` + +끝이다. 이 한 줄이 여섯 타깃을 **차례로** 돌리고, 결과를 `benchmarks/results/cost-table-<시각>.json` +과 같은 이름의 `.tsv` 로 남기고, 아래 마커 사이를 다시 렌더링한다. + +### 전제 조건 + +| 조건 | 없으면 | +|---|---| +| **기계가 조용할 것** — 1분 load average 가 4.0 미만 | 수집이 **거부된다.** `--allow-busy` 로 넘길 수 있으나, 그 경우 산출물과 표 머리에 오염 표시가 붙고 그 숫자는 인용 대상이 아니다 | +| `ANDROID_HOME` 이 platform-tools 가 있는 SDK 를 가리킬 것 | ART 두 타깃과 `androidNativeArm64` 가 **skipped 로 기록된다** (조용히 빠지지 않는다) | +| 에뮬레이터 `pmp_api26`, `pmp_api36` 이 떠 있을 것 | 해당 타깃이 skipped 로 기록되고, 띄우는 명령이 출력과 JSON 에 함께 남는다 | +| Xcode 라이선스 동의 | `iosSimulatorArm64` 가 실패로 기록된다 | +| 다른 에이전트·빌드가 돌지 않을 것 | load ceiling 에 걸린다 | + +에뮬레이터 두 대는 **동시에 쓸 수 없다.** 하네스는 `ANDROID_SERIAL` 로 한 대씩 고정해 직렬로 돌고, +`connectedDebugAndroidTest` 의 출력 디렉터리를 매 실행 전에 지워 다른 기기의 이전 결과가 이번 실행의 +것으로 읽히지 않게 한다. + +에뮬레이터를 띄우는 명령: + +```bash +$ANDROID_HOME/emulator/emulator -avd pmp_api36 -no-snapshot-save -no-boot-anim & +$ANDROID_HOME/emulator/emulator -avd pmp_api26 -no-snapshot-save -no-boot-anim & +``` + +### 부분 실행 + +```bash +./benchmarks/cost-table.sh --targets desktop,wasmJs # 기기 없이 되는 것만 +./benchmarks/cost-table.sh --targets artApi26 # 에뮬레이터가 하나만 붙었을 때 +./benchmarks/cost-table.sh render # 가장 최근 JSON 을 다시 렌더링만 +python3 benchmarks/cost_table.py render --check # 문서가 최신 JSON 과 어긋나면 exit≠0 +``` + +`--targets` 로 좁히는 것은 **타깃**을 좁히는 것이지 스위트를 좁히는 것이 아니다. 하네스는 어떤 +경우에도 `--tests` 를 쓰지 않는다 — 스위트를 좁히면 측정 영역 자체가 바뀌고, 이 저장소는 그것으로 +한 회차를 버렸다 (양 끝이 1300~1400 ns 로 올라갔다). + +### 나눠 뜬 것을 하나의 표로 합치기 + +에뮬레이터는 한 번에 한 대만 쓸 수 있으므로, 여섯 타깃이 한 번의 실행으로 다 채워지지 않는 경우가 +정상이다. 그럴 때는 각각 뜨고 렌더링할 때 합친다. + +```bash +./benchmarks/cost-table.sh collect --targets desktop,iosSimulatorArm64,wasmJs --out benchmarks/results/host.json +./benchmarks/cost-table.sh collect --targets artApi36,androidNativeArm64 --out benchmarks/results/a36.json +./benchmarks/cost-table.sh collect --targets artApi26 --out benchmarks/results/a26.json +python3 benchmarks/cost_table.py render --in benchmarks/results/host.json,benchmarks/results/a36.json,benchmarks/results/a26.json +``` + +**커밋이 다른 cut 을 합치는 것은 거부된다.** 서로 다른 트리에서 나온 행을 한 표에 놓는 것이 +`downcall-design.md` 가 한 절(`Closed: repository drift, not run-to-run noise`)을 통째로 써서 +추적한 바로 그 실패다. `--allow-mixed` 로 넘길 수 있으나, 그러면 표 머리에 커밋 불일치가 명시되고 +어느 타깃이 어느 cut 에서 왔는지가 함께 렌더링된다. + +--- + +## 여섯 타깃과 그 태스크 + +| 타깃 | Gradle 태스크 | 결과 위치 | 기기 | +|---|---|---|---| +| `desktop` | `:python-multiplatform:desktopTest` | `build/test-results/desktopTest/*.xml` 의 `` | 불필요 | +| `iosSimulatorArm64` | `:python-multiplatform:iosSimulatorArm64Test` | `build/test-results/iosSimulatorArm64Test/*.xml` | simctl 시뮬레이터 | +| `wasmJs` | `:python-multiplatform:wasmJsNodeTest` | `build/test-results/wasmJsNodeTest/*.xml` | 불필요 (Node) | +| `androidNativeArm64` | `:python-multiplatform:androidNativeArm64Test` | `build/test-results/androidNativeArm64Test/*.xml` | **에뮬레이터** (`pmp_api36`) | +| `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` 의 표가 +androidNative 를 API 레벨별로 나눠 기록하기 때문이고, `--targets` 로 이름을 대면 돈다. + +**ART 만 결과를 읽는 경로가 다르다.** AGP 가 쓰는 JUnit XML 에는 `` 이 없다 — +251개 testcase 에 0개였다. 대신 AGP 는 테스트별 `logcat-<클래스>-<메서드>.txt` 를 옆에 남기고, +벤치마크의 `println` 은 거기 `I System.out:` 줄로 들어간다. 하네스는 그 줄에서 접두사를 떼고 읽는다. + +--- + +## 무엇이 측정되고, 어디서 오는가 + +| 파일 | 소스셋 | 재는 것 | 파서가 읽는 형태 | +|---|---|---|---| +| `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 에는 들어가나 아래 표에는 쓰지 않는다) | +| `reflection/UpcallOverheadTest.kt` | `commonTest` | 이름 조회 vs 캐시된 핸들, 인자 배열 비용 | `--- Benchmark Report ---` 표 | +| `desktopTest/DesktopOverheadBenchmark.kt` | `desktopTest` | Panama 전환 비용 | desktop 에서만 | +| `wasmJsTest/WasmMarshallingOverheadTest.kt` | `wasmJsTest` | `internedUtf8` vs `scratchUtf8` vs malloc/copy/free | wasmJs 에서만 | +| `androidInstrumentedTest/JniOverheadBenchmark.kt` | `androidInstrumentedTest` | JNI 호출 규약별 전환 비용 | ART 에서만 | +| `androidInstrumentedTest/CompositionBenchmark.kt` | `androidInstrumentedTest` | 호출당 크로싱 vs 조합된 `artMain` 호출 1건 | ART 에서만 | +| `androidInstrumentedTest/UpcallOverheadTest.kt` | `androidInstrumentedTest` | Android 업콜의 attach 비용 | ART 에서만 | + +**다운콜은** `BenchmarkTest` 와 `UpcallBoundaryCostTest.measureDowncalls()` 에서, +**업콜은** `UpcallBoundaryCostTest` 와 `reflection/UpcallOverheadTest` 에서 나온다. +아래 표 두 개는 앞의 둘만 쓴다 — 여섯 타깃 전부에서 같은 모양으로 도는 것이 그 둘이기 때문이다. + +파서는 라벨을 하드코딩하지 않는다. 위 블록 형식(`--- 제목: 플랫폼 ---`, 조건 줄, 들여쓴 `라벨 값 ns`) +을 따르는 것은 전부 JSON·TSV 로 들어간다. 표에 어떤 라벨을 쓸지는 렌더러가 고른다. + +### 워밍업은 타깃의 성질이 아니라 **테스트 클래스**의 성질이다 + +`Benchmark.printReport` 로 출력하는 클래스가 여럿이고, 각자 워밍업이 다르다 — +`BenchmarkTest` 는 100,000, `reflection/UpcallOverheadTest` 는 `Benchmark.run` 의 기본값 1,000, +`GeneratedProxyCostTest` 는 5,000. 그래서 하네스는 모든 행에 **출력한 테스트 클래스**를 함께 +기록하고, 렌더러는 `BenchmarkTest` 행만 그 표에 넣는다. 나머지는 "출력한 테스트" 열을 달아 별도 +표로 나간다. 워밍업이 다른 행을 한 워밍업 캡션 아래 한 열에 놓는 것이 이 표를 한 번 무효화한 +바로 그 오류이므로, 그 섞임은 구조적으로 막아 두었다. + +### 파서가 우회하고 있는 출력 결함 하나 + +`UpcallBoundaryCostTest.report()` 는 라벨을 54칸으로 패딩하는데 다섯 라벨이 그보다 길다. 그래서 +값이 라벨에 붙어 출력된다: + +``` + PyObject_CallObject on a Python def (downcall, same shape)137.06 ns + ...Python3.withPython { } with nothing in it, for scale53.37 ns +``` + +공백 구분자를 요구하는 파서는 **다운콜 표를 이루는 다섯 행을 통째로 놓친다.** 하네스는 값을 뒤쪽 +단위(`ns`)에 앵커해 읽으므로 영향받지 않는다. 다만 사람이 읽는 출력으로서는 결함이고, +`commonTest` 파일을 고치는 일이라 여기서는 손대지 않고 기록만 해 둔다 — 고칠 때는 `padEnd(54)` 를 +가장 긴 라벨(58자)보다 크게 올리면 된다. + +--- + +## 얼마나 걸리는가 + +조용한 기계 기준의 근거 있는 추정. **호스트 두 타깃만 실제로 측정했고**, 나머지는 근거를 밝힌 +추정이다. + +| 타깃 | 1회 벽시계 | 근거 | +|---|---|---| +| `desktop` | ~50초 | 측정 (load 7.7, Gradle 캐시 warm). 조용하면 더 짧다 | +| `wasmJs` | ~25초 | 측정 (load 8.6, 같은 조건) | +| `iosSimulatorArm64` | 수 분 | 미측정. 시뮬레이터 부팅 + Kotlin/Native 테스트 링크가 지배적이고, 스위트 자체는 `downcall-design.md` 가 19.38 초로 기록 | +| `androidNativeArm64` | 수 분 | 미측정. 링크 + 60 MB 표준 라이브러리 push (`--sync` 라 2회차부터는 비교만) + 기기 실행 | +| `artApi26` / `artApi36` | 각 수 분 | 미측정. APK 조립·설치 + 251개 계측 테스트 (기록상 스위트 자체는 38~40초) | + +캐시가 warm 하면 **여섯 타깃 1회가 대략 15~25분**, `--runs 3` 이면 링크·설치가 재사용되므로 +선형보다는 덜 늘어 **40분~1시간** 정도로 본다. 콜드 캐시(새 worktree, Kotlin/Native 링크부터)면 +여기에 링크 시간이 더 붙는다. **이 추정은 측정 두 개와 문서에 기록된 스위트 실행 시간에서 나온 +것이고, 기기 세 타깃은 이 작업에서 한 번도 돌리지 않았다.** + +--- + +## 산출물 + +| 파일 | 내용 | +|---|---| +| `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 에서 렌더링된 표 | + +표에 남는 측정 조건: **커밋 해시**와 그 제목, 브랜치, **워킹 트리가 더러운지**, 수집 시각, 호스트 +CPU·코어 수·OS, **시작/종료 load average 와 ceiling**, 타깃당 실행 횟수, `UpcallBoundaryCostTest` +가 스스로 출력한 **반복 횟수와 워밍업 횟수**, 소스에서 읽은 `BenchmarkTest` 의 `WARMUP` / +`WARMUP_BULK`, **기기·시뮬레이터 식별자**(AVD 이름, 시리얼, API 레벨, ABI / 부팅된 시뮬레이터), +플랫폼이 스스로 보고한 이름, 스위트 통과·실패·스킵 수, 벽시계 시간, 그리고 태스크 종료 코드. + +`BenchmarkTest` 의 워밍업만 소스에서 읽는다 — 그 테스트는 자기 워밍업을 출력하지 않기 때문이다. +(`UpcallBoundaryCostTest` 는 출력한다.) 워밍업 없는 숫자가 이 표를 한 번 무효화했으므로 추정하지 +않고, 읽지 못하면 그 사실을 기록한다. + +--- + + + +_Generated by `benchmarks/cost_table.py` from `cut-hostless.json, cut-api36.json, cut-api26.json`._ + +### Conditions + +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 +- **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 +- **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 +- **`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) +- **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: + +| cut | commit | captured | load at start | contaminated | targets | +|---|---|---|---|---|---| +| `benchmarks/results/cut-hostless.json` | `958c0082b294` | 2026-08-14T16:31:30+09:00 | [1.97, 2.29, 5.92] | no | `desktop`, `iosSimulatorArm64`, `wasmJs`, `androidNativeArm64` | +| `benchmarks/results/cut-api36.json` | `958c0082b294` | 2026-08-14T16:43:04+09:00 | [1.42, 2.39, 4.24] | no | `artApi36` | +| `benchmarks/results/cut-api26.json` | `958c0082b294` | 2026-08-14T16:53:51+09:00 | [3.69, 2.75, 3.62] | no | `artApi26` | + +| 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 | +| `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 | + +### One upcall, per target + +`UpcallBoundaryCostTest` (`commonTest`), all baselines measured in the same run as the upcall they price. + +| target | upcall | downcall, same shape | trampoline alone | upcall / downcall | upcall / trampoline | +|---|---|---|---|---|---| +| `desktop` | 542.84–576.45 ns | 260.27–265.81 ns | 170.44–247.42 ns | 2.04–2.20x | 5.54–6.46x | +| `iosSimulatorArm64` | 2253.20–2312.97 ns | 1585.46–1655.40 ns | 2794.42–2843.16 ns | 1.39–1.42x | 1.14–1.15x | +| `wasmJs` | 302.41–310.72 ns | 96.53–100.95 ns | 149.50–164.88 ns | 3.07–3.14x | 2.39–2.46x | +| `androidNativeArm64` | 3348.75–3443.23 ns | 2194.26–2365.61 ns | 3957.01–4069.40 ns | 1.45–1.54x | 1.19–1.20x | +| `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: + +| target | empty Python loop | pure-Python callee | GIL-held trampoline | +|---|---|---|---| +| `desktop` | 8.67–8.68 ns | 25.77–26.09 ns | 85.52–97.95 ns | +| `iosSimulatorArm64` | 9.84–10.17 ns | 36.80–38.32 ns | 1948.58–2020.43 ns | +| `wasmJs` | 14.74–15.34 ns | 47.89–49.22 ns | 123.15–129.83 ns | +| `androidNativeArm64` | 17.22–17.31 ns | 48.85–58.45 ns | 2790.19–2857.59 ns | +| `artApi26` | 20.01–20.08 ns | 56.10–65.66 ns | 1073.40–1142.08 ns | +| `artApi36` | 17.22–18.14 ns | 58.40–65.30 ns | 873.48–911.95 ns | + +### One downcall, per target + +The first three columns are `UpcallBoundaryCostTest`'s own comparison basis read on its own; the fourth is `BenchmarkTest`'s 8-character string row from the same suite execution. + +| target | empty `withPython` scope | `Py_IncRef` + `Py_DecRef` | `PyObject_CallObject` | `PyUnicode_FromString`, 8 chars | +|---|---|---|---|---| +| `desktop` | 51.78–52.60 ns | 250.48–257.99 ns | 260.27–265.81 ns | 185.13–191.00 ns | +| `iosSimulatorArm64` | 701.18–723.85 ns | 1488.64–1543.92 ns | 1585.46–1655.40 ns | 3338.51–3490.55 ns | +| `wasmJs` | 24.66–25.15 ns | 45.23–46.98 ns | 96.53–100.95 ns | 101.55–103.22 ns | +| `androidNativeArm64` | 998.73–1051.08 ns | 2058.84–2167.44 ns | 2194.26–2365.61 ns | 4419.55–4706.72 ns | +| `artApi26` | 506.19–540.11 ns | 1137.97–1189.74 ns | 1253.41–1295.79 ns | 1404.00–1485.24 ns | +| `artApi36` | 283.90–286.34 ns | 597.74–608.46 ns | 804.27–838.60 ns | 1013.04–1197.93 ns | + +### `BenchmarkTest`, every row + +The whole of `overhead/BenchmarkTest`, one column per target. Iteration counts are the test's own; the warmup is the `WARMUP` above for every row except the ≥4096-char string ones, which use `WARMUP_BULK`. + +| row | iterations | `desktop` | `iosSimulatorArm64` | `wasmJs` | `androidNativeArm64` | `artApi26` | `artApi36` | +|---|---|---|---|---|---|---|---| +| `PyObject_GetAttrString` | 10,000 | 190.21–197.55 ns | 3359.97–3367.64 ns | 187.70–193.10 ns | 4414.72–4649.53 ns | 1351.16–1484.84 ns | 920.67–1114.50 ns | +| `PyLong_FromLongLong` | 100,000 | 124.86–127.77 ns | 1528.38–1549.84 ns | 57.50–59.17 ns | 2118.88–2286.43 ns | 1154.50–1230.39 ns | 720.19–738.93 ns | +| `PyLong_AsLongLong` | 100,000 | 58.11–59.11 ns | 763.39–771.76 ns | 26.96–27.08 ns | 1044.72–1209.32 ns | 572.33–653.66 ns | 300.14–308.04 ns | +| `Py_IncRef/Py_DecRef` | 1,000,000 | 109.81–112.59 ns | 1525.59–1562.09 ns | 45.81–48.68 ns | 2080.61–2221.67 ns | 1152.69–1270.82 ns | 599.46–614.50 ns | +| `PyUnicode_FromString (8 chars)` | 10,000 | 185.13–191.00 ns | 3338.51–3490.55 ns | 101.55–103.22 ns | 4419.55–4706.72 ns | 1404.00–1485.24 ns | 1013.04–1197.93 ns | +| `PyUnicode_AsUTF8 (8 chars)` | 10,000 | 80.29–115.54 ns | 976.86–1007.93 ns | 57.00–58.53 ns | 1226.12–1306.80 ns | 3967.62–4040.60 ns | 3380.46–3603.98 ns | +| `PyUnicode_FromString (256 chars)` | 10,000 | 252.13–263.88 ns | 30954.59–32067.15 ns | 753.78–759.26 ns | 40303.97–40737.47 ns | 2484.13–2514.75 ns | 4555.52–4683.17 ns | +| `PyUnicode_AsUTF8 (256 chars)` | 10,000 | 238.20–273.97 ns | 4215.35–4312.22 ns | 433.79–438.88 ns | 4931.79–5004.19 ns | 49560.37–50387.81 ns | 43690.80–45067.76 ns | +| `PyUnicode_FromString (8192 chars)` | 10,000 | 1867.14–2152.41 ns | 889250.68–919985.55 ns | 30560.16–30780.56 ns | 1169590.51–1182340.92 ns | 34593.37–36591.68 ns | 116198.67–119098.99 ns | +| `PyUnicode_AsUTF8 (8192 chars)` | 10,000 | 5308.67–5450.99 ns | 107813.46–109747.17 ns | 11283.89–11486.64 ns | 122217.03–123729.16 ns | 1475619.82–1485238.00 ns | 1304492.40–1327069.03 ns | +| `NativePointer.toRawValue()` | 1,000,000 | 2.27–2.31 ns | 50.58–70.80 ns | 14.38–14.98 ns | 68.96–70.72 ns | 3.70–3.80 ns | 5.98–6.27 ns | +| `NativePointer.toAddressValue()` | 1,000,000 | 2.28–2.32 ns | 56.84–57.14 ns | 14.45–14.92 ns | 83.42–84.13 ns | 16.57–18.37 ns | 21.37–21.93 ns | +| `AddressValue.toNativePointer()` | 1,000,000 | 2.27–2.35 ns | 65.10–66.20 ns | 6.34–6.75 ns | 95.53–95.75 ns | 19.98–20.20 ns | 26.57–27.37 ns | +| `Manual IncRef/DecRef` | 100,000 | 109.62–112.07 ns | 1516.04–1538.18 ns | 44.24–44.91 ns | 2063.26–2166.75 ns | 1147.20–1321.38 ns | 593.30–603.22 ns | +| `PyObject wrapper creation` | 100,000 | 199.74–201.47 ns | 1927.48–1954.60 ns | 259.71–278.76 ns | 2626.97–2703.14 ns | 1691.66–1745.09 ns | 1188.08–1225.27 ns | + +### Other benchmark rows, by the test that printed them + +These also come through `Benchmark.printReport`, and they are **not comparable with the table above or with each other**: each of these classes chooses its own warmup (`Benchmark.run`'s default is 1 000; `GeneratedProxyCostTest` uses 5 000), and some run on one target only. They are listed with their origin so a figure cannot be lifted out of here as if it shared the conditions above. + +| printed by | row | iterations | `desktop` | `iosSimulatorArm64` | `wasmJs` | `androidNativeArm64` | `artApi26` | `artApi36` | +|---|---|---|---|---|---|---|---|---| +| `UpcallOverheadTest` | `HandleTable.resolve(handle)` | 1,000,000 | 8.06–9.29 ns | 111.53–114.80 ns | 7.73–8.16 ns | 155.69–157.77 ns | 15.52–18.37 ns | 35.07–35.99 ns | +| `UpcallOverheadTest` | `HandleTable.register + release` | 500,000 | 29.53–32.95 ns | 617.84–636.01 ns | 57.70–60.23 ns | 842.59–849.92 ns | 72.57–74.31 ns | 132.04–133.37 ns | +| `UpcallOverheadTest` | `UpcallTable.resolve(name) [200 entries]` | 1,000,000 | 9.63–9.96 ns | 140.43–140.68 ns | 34.78–36.62 ns | 235.79–242.40 ns | 46.42–46.84 ns | 292.52–295.97 ns | +| `UpcallOverheadTest` | `UpcallTable.invoke(handle, args)` | 1,000,000 | 12.56–15.97 ns | 235.43–236.55 ns | 41.63–44.03 ns | 303.76–310.15 ns | 45.97–46.82 ns | 78.46–80.12 ns | +| `UpcallOverheadTest` | `UpcallTable.invokeByName(name, args)` | 1,000,000 | 14.10–14.64 ns | 298.49–299.27 ns | 49.09–51.37 ns | 430.23–440.54 ns | 79.61–81.72 ns | 374.13–376.68 ns | +| `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 | — | — | — | + + + +## The React Native comparison's Android column is unquotable, and not for the reason assumed + +The cross-runtime comparison this table is read against +(`benchmarks/rn-benchmark/COMPARISON.md`) has an Android column that cannot +be quoted. Two solo runs on a quiet machine, one at the documented warmup and one +at four times it, both ended with the benchmark refusing its own numbers: + + boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup 100000 is not justified by this run. + boundary (Hermes+JSI+JNI+ART): NEVER SETTLED. Warmup 400000 is not justified by this run. + +Two explanations are ruled out by that pair rather than argued away. Machine load +is not it: the runs were alone, at load averages of 3.10 and 2.97, where the +earlier failing run had ten to twelve. Warmup length is not it either: quadrupling +it changed nothing, and the constant it started from has its own provenance -- the +reference project's ART sweep put the plateau at ninety to a hundred thousand +calls. + +What is left is visible in the sweep itself: adjacent repetitions differ by nearly +a factor of two (851.88 ns at the thirty-fifth, 1625.86 ns at the thirty-seventh). +That is the shape of scheduling jitter under an emulator, not of a runtime still +warming up -- and if it is, the convergence criterion the benchmark applies (forty +repetitions inside five per cent) is one an emulator may never satisfy no matter +how long the warmup. + +So the next step is a physical device, or a deliberate decision about what +convergence means on an emulator. Not a longer warmup. The numbers stay out of +this table until one of those happens, and the benchmark enforces that itself by +exiting non-zero and marking the whole report unquotable. diff --git a/docs/investigations/gc-scheduling-investigation.md b/docs/investigations/gc-scheduling-investigation.md new file mode 100644 index 000000000..31b9b373a --- /dev/null +++ b/docs/investigations/gc-scheduling-investigation.md @@ -0,0 +1,259 @@ +# Why a pure C API embedder never collects cycles, and never merges a refcount + +Conditions for every number below unless stated: `desktopTest`, macOS arm64 (Apple M1), CPython +3.14.7 (`gradle.properties: pythonVersion=3.14.7`), both the default (GIL) build and the opt-in +free-threaded build (`-PpythonFreeThreaded=true`, 3.14t; desktop only, default is `false`). CPython +file:line citations were checked against the v3.14.7 tarball (fetched outside the repo, not +vendored). Printed by `GCSchedulingMeasurementTest`, `EvalCheckpointTest`, `FreeThreadedGCGateTest`, +`CycleCollectionTest` (all under `python-multiplatform/src/{desktopTest,commonTest}/.../ref/`). + +## 0. Summary + +Conclusions (current): + +- **The cyclic collector never runs for an embedder that executes no Python frame.** Allocation + only sets a bit on the eval breaker; the only reader is `_Py_HandlePending`, called from the eval + loop. True on both builds. (§1) +- **Free-threaded, a completed `Py_DecRef` can leave the object alive** (biased reference counting: + a non-owner thread's final decrement is queued to the owner, drained only by the eval loop). (§2) +- **`CycleCollectionTest`'s free-threaded failure was a different thing:** the probe read + `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 + `_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) +- **Supported way to collect cycles in an embedder that runs no bytecode, identical on both + builds: `PyGC_Collect()`**, cost proportional to the heap (§5). `drainPendingReleases()` is the + cheap checkpoint that merges refcounts (and collects on the GIL build). + +**Status: acted on.** +- `Python3.drainPendingReleases()` and `Python3.autoDrainInterval` (default 32 on free-threaded + builds, 0 otherwise): `python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/Python3.kt` + (~lines 737-802; checkpoint function `__pmp_eval_checkpoint__`); rules in + `python-multiplatform/src/commonMain/README.md`. +- Tests: `commonTest/.../ref/EvalCheckpointTest.kt`, `desktopTest/.../ref/GCSchedulingMeasurementTest.kt`, + `FreeThreadedGCGateTest.kt`, `CycleCollectionTest.kt`. SPEC M-3, T-2. +- Free-threaded default behaviour (no bound on reclamation at default thresholds) is a documented + consequence, not fixed. + +## 1. `_Py_ScheduleGC` only sets a bit + +Allocation-driven collection has not been a direct call since 3.12. + +| build | function | file:line (v3.14.7) | +|---|---|---| +| GIL | `_Py_ScheduleGC` | `Python/gc.c:1846` | +| free-threaded | `_Py_ScheduleGC` | `Python/gc_free_threading.c:2795` | + +The bit's only reader is `_Py_HandlePending` (`Python/ceval_gil.c:1357`; runs `_Py_RunGC` at +`:1397-1399`), reached from `_CHECK_PERIODIC` uops that open every Python frame (`RESUME`) and +close every call instruction. No public entry point. So allocation schedules a collection that only +the evaluation loop will perform; `gc.collect()`/`PyGC_Collect` is the only thing that has ever +collected anything in this library's tests. + +`Py_MakePendingCalls` is **not** a substitute: it forwards to `_PyEval_MakePendingCalls` +(`ceval_gil.c:1034`), which never looks at the GC bit nor the merge bit. Checked by +`EvalCheckpointTest.testDrainPendingReleasesReclaimsWhatTheCleanerGaveBack` (calls it, asserts 0 +return and that the refcount did not move). + +## 2. Free-threading: biased reference counting, and the queue only the eval loop drains + +On a free-threaded build `Py_DECREF` branches on whether the thread owns the object (`ob_tid`): +owner decrements `ob_ref_local` and may `tp_dealloc` at once; a non-owner decrements +`ob_ref_shared` atomically, and if that takes it to zero the object is queued to its owner by +`_Py_brc_queue_object` (`Objects/object.c:411`). The queue is drained by +`_Py_brc_merge_refcounts`, whose only caller is `_Py_HandlePending` (`ceval_gil.c:1388`, behind +`_PY_EVAL_EXPLICIT_MERGE_BIT`). After a collection, merged survivors keep `ob_tid == 0` +(`gc_restore_tid`, `gc_free_threading.c:325/:330`), so every later decrement takes the shared path. + +Measurement (ROADMAP §9): 1000 wrappers released by the JVM cleaner, 2003 `Py_DecRef` calls made, +`sys.getrefcount` frozen at 1002 through 50 forced JVM GCs, 500 further C API round trips and two +seconds of wall clock; one trivial bytecode frame then released all thousand at once. + +## 3. `CycleCollectionTest`: two defects + +Two successive diagnoses were wrong in opposite directions; a direct header dump settled it. + +**3a. The probe read the wrong bytes.** `ob_refcnt` is at offset 0 only on the GIL build. +Free-threaded `struct _object` is `ob_tid` +0 (8 B), `ob_flags` +8, `ob_mutex` +10, `ob_gc_bits` ++11, `ob_ref_local` +12 (4 B), `ob_ref_shared` +16 (8 B, shifted 2, low 2 bits flags), `ob_type` ++24; `Py_REFCNT = ob_ref_local + (ob_ref_shared >> 2)`. The failure printed an identical ten-digit +number (`6171668704`) before and after 100 instances: that was `ob_tid`. + +**3b. Heap types are deferred-reference-counted.** Header of the proxy type, free-threaded: + +| | `ob_tid` | `ob_gc_bits` | `ob_ref_local` | `ob_ref_shared` | +|---|---|---|---|---| +| fresh type | `0x16d9b70e0` | `0x41` | 2 | `0x3ffffffffffffffd` | +| 100 instances alive | same | `0x41` | 2 | `0x3ffffffffffffffd` | +| after `drainPendingReleases()` | same | `0x41` | 2 | `0x3ffffffffffffffd` | +| after `PyGC_Collect()` | same | `0x41` | 2 | `0x400000000000018d` | +| after 100 `Py_DecRef` → 100 `tp_dealloc` | `0` | `0x41` | 0 | `0x4000000000000007` | + +`0x41` = tracked | deferred. `ob_ref_shared` = `2^60 - 1` sentinel plus `_Py_REF_MAYBE_WEAKREF`. +Creating instances changes nothing (`_Py_INCREF_TYPE` is a no-op for a deferred type on its owning +thread); a checkpoint changes nothing (no queued decrement exists); a collection materialises the +references (`ob_ref_shared` rose by exactly `100 << 2`) and `tp_dealloc` gives back exactly 100. +So the invariant ("each live instance holds one reference to its type") holds on both builds; +free-threaded it is observable only after a collection. `CycleCollectionTest` takes a +`PyGC_Collect()` on either side of its instantiation loop, free-threaded only. + +`abi3t` is not a blocker: the probe's header-layout knowledge is a property of the probe (the one +place in this repo reading inside a `PyObject`), not of the invariant; `ProxyTypeFactory` writes +only into memory from `PyObject_GetTypeData`. + +## 4. What this repo does about §1 and §2 + +`Python3.drainPendingReleases()` reaches a checkpoint by calling a cached, compiled, zero-argument +Python function (body `pass`) through `PyObject_CallNoArgs`: one `RESUME` → one `_CHECK_PERIODIC` → +one `_Py_HandlePending` (BRC merge, QSBR sweep, scheduled collection if any). Not +`PyRun_SimpleString("pass")` (its `PyErr_Print()` clears the error indicator) and not `exec("pass")` +(recompiles each time, 23× the cached call). + +`withGIL` takes one automatically at its outermost entry behind two gates: at most one per +`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`. +`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. + +## 5. Measurements + +`EvalCheckpointTest.testCheckpointCostAgainstTheAlternatives`, free-threaded desktop, macOS arm64. +The test asserts the *ordering*, not the numbers. + +| | ns/op | | +|---|---:|---| +| `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 | +| `withGIL { PyGC_Collect() }` | 224 625.00 | 742× the checkpoint | + +At `autoDrainInterval = 32` the automatic checkpoint amortises to ~4 ns on a ~170 ns outermost +scope. `CycleCollectionTest`'s two collections cost ~0.45 ms, free-threaded only. + +## 6. What is still true, and what to watch + +- The scheduled-GC half of §1 affects both builds. Nothing in this repo depends on + allocation-driven collection, and `autoDrainInterval` defaults to 0 on the GIL build, so by + default no generational collection runs on its own. A choice, not an oversight; reversing it is + one assignment. +- `EvalCheckpointTest` asserts `Py_MakePendingCalls` does *not* merge; it fails if CPython changes. +- The header layout in §3a is read in exactly one place, guarded by an assertion that has already + fired twice. +- `ProxyTypeFactory`'s hand-written `tp_dealloc` releases the type through the deferred-unaware + `Py_DecRef`, while the matching increment went through deferred-aware `_Py_INCREF_TYPE` and did + not happen; balanced by the collector, not by the two calls (measured in §3b). + +## 7. GC accumulation and re-entrancy (GIL build) + +- 10,000 cyclic groups built purely through the C API with `autoDrainInterval = 0`: 20,000+ + objects accumulate indefinitely (no bytecode runs, the bit is never read). +- Same workload with `autoDrainInterval = 32`: checkpoints let `_Py_RunGC` run; ~1,970 of ~20,000 + remain. **Specific to the GIL build** (free-threaded: §8). +- `__del__` calling back into Kotlin during `PyGC_Collect()` or a checkpoint + (`GCSchedulingMeasurementTest.testReentrancyDuringCheckpoint`): safe on the GIL build, no + deadlock, no loop, the upcall runs. + +## 8. Free-threaded: the checkpoint fires and reclaims nothing + +Workload: `GCSchedulingMeasurementTest.buildAndDropCycles`, 10,000 unreachable `list` cycles +(20,000 objects) built through the C API. + +**8a. The probe held the evidence.** Measuring with `gc.get_objects()` unclosed leaked a list that +strongly references every tracked object, rooting the garbage being measured; that produced the +earlier "free-threaded does not reclaim" conclusion. With temporaries released, an explicit +`gc.collect()` reclaims the residue essentially completely on both builds (60+ repetitions, +residue −13..+2 of ~20,000). The residue is ordinary collectable garbage; what differs is what +makes the collector *run*. Do not restore the inline `gc.get_objects()` chain. + +**8b. Rejected: per-call `PyGILState_Ensure/Release` destroys the scheduled bit.** 2,000 fixed +explicit drains, only the enclosing scope varied, free-threaded, residue of 20,000: no scope 20,000 +×5; per round 20,000 ×5; per 100 rounds 20,000 ×10; one scope for the whole workload bimodal within +one JVM (11,562 / 1,280 / 1,280, then 20,000 ×7). Widening the scope changes nothing. With +`autoDrainInterval = 32`: 1,875 / 312–313 / 3 / **0** checkpoints for the four scope shapes (one +outermost scope decrements the countdown once, so a long batch in one `withPython` gets *fewer* +automatic checkpoints), residue 20,000 throughout. + +**8c. The contrast.** On the GIL build every shape is stable and near-complete: residue 1,920–1,972 +for per-call/explicit-drain shapes, 1,556–1,616 per round at interval 32; **3 checkpoints at +interval 32 per-100-rounds still reclaim ~90%** (residue 1,600–3,200). Three checkpoints reclaim +~90% on the GIL build; two thousand reclaim nothing free-threaded. That gap, not scope shape, is +the finding. + +### 8d. Answered: the checkpoint is fine, the collector asks a second question + +Source-confirmed asymmetry (v3.14.7): + +| | GIL (`gc.c`) | free-threaded (`gc_free_threading.c`) | +|---|---|---| +| schedules on | `generations[0].count > threshold` only (`gc.c:1866`) | `gc_should_collect` (`:2153`) | +| re-checked when the checkpoint runs it | `gc_select_generation`, gen 0 on count alone (`:1258`) | `gc_should_collect` **again** (`:2328`) | +| process-memory gate | none | `gc_should_collect_mem_usage` (`:2080`) | +| `long_lived_total / 4` | oldest generation only (`:1300`) | every gen-0 decision (`:2131`) | + +`gc_should_collect_mem_usage` returns true if `deferred > threshold * 40` (`:2089`) or +`(footprint − last_mem) > max(last_mem/10, 128)` (`:2100`); otherwise it zeroes `young.count` into +`deferred_count` (`:2109-2113`) and returns false. `footprint` is `get_process_mem_usage()`, on +macOS `task_info(TASK_VM_INFO).phys_footprint` (`:2010`): the **whole process**, i.e. the JVM. +`last_mem` is written only after a collection (`:2301`). Twenty thousand small Python lists cannot +move a JVM's footprint by `last_mem/10`, and the escape (`deferred_count > 40 × threshold0` = 80,000 +at default) is not reached by ~20,000 allocations. **This is also the latch:** early in a JVM's life +the footprint is climbing and the gate opens; once a collection records the settled footprint, the +gate is shut one-way. Scope shape is irrelevant, consistent with §8b. + +Experiments (all in `FreeThreadedGCGateTest`; residue of 20,000 and collections run): + +| 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 | +| `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 | + +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 +allocations flushes the per-thread buffer (`gc_get_count_impl`, `Modules/gcmodule.c:215`; +`LOCAL_ALLOC_COUNT_THRESHOLD` = 512), so the collector is never asked and the trace looks linear; +sample every 2,000. + +Not confirmed: the scaling prediction (10,000 / 30,000 / 60,000 cycles at default thresholds gave +1 / 2 / 2 collections; the 10,000 arm should have given 0 but ran first in its JVM while the +footprint was still climbing). Untested, not confirmed. + +What changed in code/tests: `FreeThreadedGCGateTest` holds free-threading to the same "reclaims +more than half" bound as the GIL build once the gate is open, and asserts the matched no-op on the +GIL build so a CPython change to either gate fails it. `measureCyclicGarbageWithAutoDrain` asserts +no bound free-threaded in the default configuration (outcome depends on JVM footprint history). + +Advice: an embedder can open the gate (`gc.set_threshold(t0, 0, 0)`; small `threshold0` lowers the +`40 × threshold0` bar), but both trade throughput and tune CPython internals the Stable ABI does not +promise. The supported way is `PyGC_Collect()` (§4). + +## How to reproduce + +```bash +# GIL build +./gradlew :python-multiplatform:desktopTest --tests 'python.multiplatform.ref.GCSchedulingMeasurementTest' \ + --tests 'python.multiplatform.ref.CycleCollectionTest' --console=plain > .tmp/gc-gil.log 2>&1; echo "EXIT=$?" +# free-threaded build (3.14t, desktop only) +./gradlew :python-multiplatform:desktopTest -PpythonFreeThreaded=true \ + --tests 'python.multiplatform.ref.FreeThreadedGCGateTest' --tests 'python.multiplatform.ref.EvalCheckpointTest' \ + --console=plain > .tmp/gc-ft.log 2>&1; echo "EXIT=$?" +``` + +(Package names assumed from the directory layout; adjust to the test files' `package` line.) Run on +a quiet machine, per-module and unfiltered when quoting numbers; the footprint-gated results depend +on JVM history, so run the whole suite for a cut. + +The full original, with the stepwise corrections, is +[`docs/archive/investigations/gc-scheduling-investigation.md`](../archive/investigations/gc-scheduling-investigation.md). diff --git a/docs/investigations/gil-parking-investigation.md b/docs/investigations/gil-parking-investigation.md new file mode 100644 index 000000000..20f30ee1b --- /dev/null +++ b/docs/investigations/gil-parking-investigation.md @@ -0,0 +1,82 @@ +# Enabling `PyEval_SaveThread()` in `Python3.initialize()` + +Question: why did parking the initialising thread's state (so other threads can attach) appear to +crash `desktopTest`? + +## Conclusion + +**The investigation's own diagnosis was incomplete, and parking is on.** What this document +established from CPython 3.14.0 source (read-only, no code run) holds, but it did not find the +cause, and the cause turned out not to be in the runtime: + +- `PyGILState_Ensure` on the initialising thread after `PyEval_SaveThread()` does **not** create a + second `PyThreadState`. `PyEval_SaveThread` detaches the state and releases the GIL but leaves the + GILState TSS slot bound (CPython's own comment in `pystate.c`: "We do not unbind the gilstate + tstate here. It will still be used in `PyGILState_Ensure()`"). `Ensure` finds the same state, + `has_gil` is false, so it calls `PyEval_RestoreThread` on it; `gilstate_counter` goes 1 → 2 and + `Release` brings it back and re-parks. Park/re-enter on one thread is the supported idiom + (`Py_BEGIN/END_ALLOW_THREADS` does it). +- `PyGILState_Release` is never handed an invalid state in this flow. +- The thread is the same: Gradle runs the whole suite on one `"Test worker"` thread, which is also + where `Python3.initialize()` runs. + +**The actual cause (ROADMAP §1, "Closed"):** several tests wrapped a pointer they had only been +*lent* with `borrowed = false`, so two wrappers owned one pointer and both decremented it. That +double-free corrupted CPython's free lists and surfaced later in an unrelated test. With parking +off, the cleaner thread blocked forever on the GIL, so the extra decrements queued and never ran, +which is why enabling parking appeared to *cause* the crash. The `SIGSEGV` in +`_TAIL_CALL_DICT_MERGE` under `PyImport_ImportModule("sys")` was a victim of the corruption, not +the fault site. Two earlier attempts read the evidence as "parking is broken" and reverted. + +The two candidates this document left open (stale per-thread C-stack recursion limits calibrated +once at first attach; Python-level import bookkeeping on a cache hit) were never confirmed and, +given the cause above, are not needed to explain anything. The stack-limit staleness is a real +property of reusing one `PyThreadState` (`_Py_InitializeRecursionLimits` runs only while +`c_stack_hard_limit == 0`) but no failure has been attributed to it. + +**Status: acted on, parking enabled unconditionally.** +`python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/Python3.kt`: +`initialize()` calls `PyEval_SaveThread()` after `Py_Initialize()` (guarded only by +`mainThreadState == null`); `finalize()` reclaims it with `PyEval_RestoreThread`. Verified by +reading the file at `5db4168c`. + +Discrepancy to resolve elsewhere: `docs/SPEC.md` C-4 says "Releasing the GIL after initialisation +is not enabled." That contradicts the code above and ROADMAP §1. This document does not edit SPEC. + +Related rule that survives: every C API call must be inside `withGIL{}`/`withPython{}`. Free-threaded +builds do not remove it (a thread must still be attached before touching any object, `Py_IncRef` +included). `Python3.kt` documents this at the `PyEval_SaveThread()` call site. + +## Numbers + +None. This was a source-reading investigation. The only quantitative record is the ROADMAP §1 +test counts when parking was enabled: desktop 164, iOS 157, zero failures (a historical count; the +suites have grown since). + +## How to reproduce / guard + +The bisection experiment proposed here (park, re-enter on the same thread with bare re-entry → pure +C call → `PyImport_ImportModule("sys")` → `PySys_GetObject` + `PyDict_GetItemString`) exists as +`python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/GilParkingTest.kt` +(`parkThenReenterSameThreadIsolatesTheFailure`). It asserts only that nothing crashes (SPEC C-4). +Run alone on a quiet tree: + +```bash +./gradlew :python-multiplatform:desktopTest --tests 'python.multiplatform.ffi.GilParkingTest' \ + --console=plain > .tmp/gilparking.log 2>&1; echo "EXIT=$?" +``` + +Tests nearest the real cause (double release / ownership; named, not re-run here): `commonTest/.../ref/DoubleReleaseTest.kt`, +`OwnershipLeakTest.kt`, `GCLeakTest.kt`. + +## Open + +- Which CPython version introduced the attach/detach vocabulary and the C-stack-address recursion + guard was not verified (it reads as the PEP 703 rewrite). +- iOS: `GCLeakTest`'s earlier simulator failure was not GIL-related (cleaners ran and reached + `Py_DecRef`: 101001 of 101002). ROADMAP §1 reports the iOS suite green (157) once parking was + enabled; this document does not explain the earlier iOS symptom. + +The full original, with the CPython source excerpts, the same-thread argument and the experiment +sketch, is +[`docs/archive/investigations/gil-parking-investigation.md`](../archive/investigations/gil-parking-investigation.md). diff --git a/docs/investigations/jni-call-convention-audit.md b/docs/investigations/jni-call-convention-audit.md new file mode 100644 index 000000000..2c2e6bde6 --- /dev/null +++ b/docs/investigations/jni-call-convention-audit.md @@ -0,0 +1,135 @@ +# JNI calling convention audit + +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 +be Kotlin. + +## Conclusion + +1. **No re-entrant function is live under a GC-blocking convention.** The audit's headline + ("six re-entrant functions registered `@CriticalNative`, demote them") was a false alarm: it + read a block-commented region of dead pre-migration code in `EmbedAPI.android.kt` as live, and + it read the registration table as the configuration. `jni_onload.def` registers all three + conventions per function (bare name = `@CriticalNative`, `F` = `@FastNative`, `N` = ordinary); + the convention that runs is chosen at the call site, and the six already call the `N` variant. +2. **The proposed promotion to `@CriticalNative` is rejected.** It is a loss on API 34+ (table + below), is not a relabel (needs a new raw-symbol table entry, declaration and call-site + branch), and three of its eight targets are not leaves. +3. **One change was made instead:** `PyList_GetItemRaw` got a `@FastNative` twin + (`PyList_GetItemRawF`) and a `preferFastNative` branch; it had been the only declaration that + ignored the device axis, on the per-element path of bulk list iteration. +4. **The classification is now derived, not written.** The hand-written table in the original + audit (71 rows) went stale on the next commit and its "Unresolved entries: none" claim was + wrong (header prototypes can condemn a function but never clear one: `void Py_Initialize(void)` + and `int PyRun_SimpleString(const char*)` mention no `PyObject *` and both run arbitrary + Python). A test re-derives it on every desktop build. + +**Status: acted on.** +- Classification and its guards: `python-multiplatform/src/desktopTest/.../native/ffi/JniCallConventionClassificationTest.kt` + (`noLiveCallSiteReachesAGcBlockingBindingOutsideThePromotedSet`, + `everyGcBlockingDeclarationIsEitherPromotedOrQuarantined`, `quarantinedDeclarationsHaveNoCallSiteAnywhere`, + `criticalNativeRegistrationsTakeNoJniEnvAndTheOtherTwoDo`, `theSignatureRuleDoesNotClaimToProveLeafness`). +- Decision procedure for a new call site: `androidMain/README.md`, "Choosing a convention for a new + call site" (five steps, enforced by the test above). +- `PyList_GetItemRawF`: `androidMain/.../bindings.kt`; branch in `EmbedAPI.android.kt` + (`preferFastNative`); device check `androidInstrumentedTest/.../JniWiringTest.kt` + `bothListGetItemConventionsAgree`. SPEC C-3 is the contract. +- The `PyList_GetItemRawF` change was compile-verified only when written; whether + `bothListGetItemConventionsAgree` has been run on a device since is not recorded here. + +## The eight live GC-blocking call sites + +All eight are leaves on the success path; all can allocate a GC-tracked exception on the failure +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*. + +| call site | success path | failure path | +|---|---|---| +| `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 | +| `PyList_Size` | reads `ob_size` | `PyErr_BadInternalCall` | +| `PyList_GetItem` | indexes item array | `IndexError` | + +## Numbers + +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): + +| API | ordinary → `@FastNative` (ns) | ordinary → `@CriticalNative` (ns) | +|---|---|---| +| 26 | −6.64 | −43.47 | +| 30 | −41.33 | −73.33 | +| 31 | −21.68 | −45.99 | +| 33 | −5.79 | −7.86 | +| 34 | −5.84 | **+16.54** | +| 36 | −14.77 | **+25.73** | + +`@CriticalNative` is a loss on API 34 and 36. `PyList_GetItemRaw` motivation: ROADMAP §5 measured +`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. + +## 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 | +| `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`, +`PyGILState_GetThisThreadState`, `PyEval_SaveThread`; only the three refcount operations are hot +enough for tens of nanoseconds to accumulate. + +## Rules of thumb for a new function + +1. Any decref is re-entrant (the count can hit zero → `__del__`). +2. Allocating a GC-tracked container (`PyTuple_New`, `PyDict_New`, instances) can cross the + collection threshold and run `__del__` on unreachable cycles. +3. Anything that blocks (`PyGILState_Ensure`, `PyEval_RestoreThread`) stays ordinary: a thread + waiting for a lock while marked `@CriticalNative` keeps the JVM from a safepoint, and deadlocks + if the GIL holder triggers a JVM GC. +4. Dict lookups are re-entrant (`__eq__`/`__hash__` on keys). +5. Non-GC allocation (`PyLong_FromLongLong`, `PyUnicode_FromString`) is a leaf on success. +6. Pure increments (`Py_IncRef`, `Py_NewRef`) are leaves. + +## Registrations added after the audit + +The audit read 71 registrations; the table later held 187 (116 added in `9abc8edd` and +`aea09585`) and 366 when the resolution was written. All 116 are registered `N`-suffixed with a +plain `@JvmStatic external fun` and no `@CriticalNative`/`@FastNative` twin, so no call site can +reach a GC-blocking variant of them. Of the 42 string-carrying ones, 31 are re-entrant (run module +or arbitrary code, a Python-level hook, a codec lookup, drop a reference, or never return) and 11 +are leaves on success (`PyBytes_FromStringN`, `PyBytes_AsStringN`, `PyByteArray_AsStringN`, +`PyUnicode_EqualToUTF8N`, `PyUnicode_CompareWithASCIIStringN`, `PyUnicode_InternFromStringN`, +`PySys_GetObjectN`, `PyImport_GetMagicTagN`, `PyEval_GetFuncNameN`, `PyEval_GetFuncDescN`, +`Py_EnterRecursiveCallN`); none should be promoted (cold error/introspection surface, whole win +under 40 ns on a call that happens once). Later unregistrations (CPython 3.15 removals, per ROADMAP +§9): `PyImport_ImportModuleNoBlockN`, `PySys_ResetWarnOptionsN`; `PyWeakref_GetObjectN` became +`PyWeakref_GetRefN`. + +## How to reproduce + +Classification (desktop, source-level, no device): + +```bash +./gradlew :python-multiplatform:desktopTest --tests 'python.native.ffi.JniCallConventionClassificationTest' \ + --console=plain > .tmp/jni-classification.log 2>&1; echo "EXIT=$?" +``` + +Per-convention costs: `androidInstrumentedTest/.../native/ffi/JniOverheadBenchmark.kt` on +`pmp_api26` and `pmp_api36` (harness: `./benchmarks/cost-table.sh`, see +[`cost-table.md`](cost-table.md)). + +The full original, with the 71-row table, the false-alarm reasoning and the 42-function breakdown, +is [`docs/archive/investigations/jni-call-convention-audit.md`](../archive/investigations/jni-call-convention-audit.md). diff --git a/docs/locale/README_ko.md b/docs/locale/README_ko.md new file mode 100644 index 000000000..dff48d279 --- /dev/null +++ b/docs/locale/README_ko.md @@ -0,0 +1,230 @@ +[English](../../README.md) | 한국어 + +
+ +# 🐍 python-multiplatform + +**Kotlin Multiplatform 안의 진짜 CPython — Kotlin 이 Python 을, Python 이 Kotlin 을, 모든 타깃에서 부른다.** + +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) +[![Kotlin Multiplatform](https://img.shields.io/badge/Kotlin-Multiplatform-7F52FF.svg?logo=kotlin&logoColor=white)](https://kotlinlang.org/docs/multiplatform.html) +[![CPython](https://img.shields.io/badge/CPython-3.14-3776AB.svg?logo=python&logoColor=white)](https://www.python.org/) +[![Platforms](https://img.shields.io/badge/platforms-JVM%20%7C%20Android%20%7C%20iOS%20%7C%20Android%20native%20%7C%20wasm%20(exp.)-lightgrey.svg)](#-플랫폼) + +[가이드](https://thisisthepy.github.io/python-multiplatform/) · +[설계 문서](../design/) · +[로드맵](../roadmap/ROADMAP.md) + +
+ +--- + +## 💡 왜 필요한가 + +라이브러리는 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 확장 함수 — 모두 빌드 타임에 생성한 테이블을 + 거치므로, 리플렉션이 없는 곳(Kotlin/Native, GraalVM 네이티브 이미지)에서도 동작한다. + +재구현이 아니라 CPython 그 자체이므로 C 확장도 그대로 동작한다. + +## ✨ 기능 + +- 🔌 **다운콜** — CPython Stable ABI(약 330개 함수)를 `expect` 하나로 선언하고 플랫폼마다 `actual` 을 + 둔다. 데스크톱은 Panama `invokeExact`, Android 는 `RegisterNatives` JNI, Native 는 cinterop. +- 🧩 **객체 모델** — `PyObject`, `PyType`, `PyException`, 기본 타입, Kotlin 컬렉션 인터페이스를 구현하는 + `PyList` / `PyDict` / `PySet` / `PyTuple`, 모듈, 호출 가능 객체, 변환 계층. +- 🚀 **업콜** — KSP 프로세서가 함수 테이블을 생성한다. Python 이 Kotlin 클래스를 생성하고, 메서드를 + 부르고, 프로퍼티를 읽고 쓰고, `suspend` 함수를 취소까지 포함해 `await` 한다. +- 📦 **빌드된 라이브러리 바인딩** — Gradle 플러그인이 jar(Compose 포함)를 훑어 바인딩과 `.pyi` 스텁을 + 생성한다. 기본 인자 생략과 값 클래스를 지원한다. +- ♻️ **수동 메모리 관리 없음** — 래퍼는 수거될 때 Python 참조를 놓는다. 경계를 가로지르는 참조 순환은 + Python 의 순환 GC 가 수거한다. +- 🧪 **측정됨** — 모든 경계 메커니즘에 오버헤드 테스트가 붙어 있고, 비용 표는 실제 실행에서 렌더링된다 + ([비용 표](../investigations/cost-table.md)). + +## 🚀 빠른 시작 + +> **아직 배포되지 않았다.** Maven Central 이나 JitPack 에 릴리스가 없다. 아래처럼 소스에서 빌드한다. +> 릴리스가 나오면 좌표를 공지한다. + +```bash +git clone https://github.com/thisisthepy/python-multiplatform +cd python-multiplatform +./gradlew :sample:run # 데스크톱 데모 앱 +``` + +### Kotlin → Python + +```kotlin +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.types.basic.PyInt +import python.multiplatform.ffi.types.collections.PyList + +fun main() { + Python3.initialize() + + // 진짜 Python int 로 이루어진 진짜 Python list 를 __main__ 에 게시한다. + val numbers = PyList.fromList(listOf(2L, 3L, 5L, 7L, 11L).map { PyInt.from(it) }) + Python3.import("__main__").setAttr("kotlin_numbers", numbers) + + val globals = Python3.import("__main__").dict + val result: PyObject = Python3.eval("sum(kotlin_numbers) * 2", 258 /* Py_eval_input */, globals, globals) + println("${result.Type.name}: $result") // int: 56 + + Python3.exec("print('hello from python')") + // close() 가 어디에도 없다. 각 래퍼는 수거될 때 자기 참조를 놓는다. +} +``` + +### Python → Kotlin + +`public` Kotlin 선언은 모두 노출된다 (`@PythonInternal` 로 제외). + +```kotlin +package org.thisisthepy.python.multiplatform.demo.bindings + +class Greeter(subject: String) { + var subject: String = subject + var greetings: Long = 0 + private set + + fun greet(times: Long): String { /* ... */ } + suspend fun greetNow(times: Long): String = greet(times) +} +``` + +```python +from org.thisisthepy.python.multiplatform.demo.bindings import Greeter + +g = Greeter('Kotlin') # Kotlin 객체를 생성한다 +g.greet(2) # Kotlin 메서드를 부른다 +g.subject = 'Python' # 프로퍼티 setter +g.greetings = 99 # AttributeError: Kotlin setter 가 private 이다 + +async def main(): + return await g.greetNow(1) # Kotlin suspend fun 을 await 한다 +``` + +두 예시 모두 [`sample/`](../../sample/) 앱에서 가져왔다. Kotlin 패키지 이름이 그대로 Python 모듈 이름이다. + +### 플랫폼별 준비 + +
+데스크톱 — PYTHONHOME 에 stdlib 가 있도록 Gradle 플러그인을 적용한다 + +```kotlin +plugins { + kotlin("jvm") + application + id("io.github.thisisthepy.python.multiplatform.bindings") version "" +} +``` + +`stagePythonHome` 이 맞는 CPython 빌드를 내려받아 릴리스의 `SHA256SUMS` 로 검증하고, 기기 전체가 쓰는 +캐시에 풀고, `run` 과 `test` 에 `PYTHONHOME` 을 설정한다. 직접 설정한 `PYTHONHOME` 은 건드리지 않는다. +`pythonBindings { stagePythonHome.set(false) }` 로 끌 수 있다. `createDistributable`(또는 `package*` +태스크)로 패키징한 Compose Desktop 앱은 스테이징된 stdlib 와 `libpython` 을 리소스에 담아 `PYTHONHOME` +없이 시작한다(`pythonBindings { packagePythonHome.set(false) }` 로 끈다). 그 밖의 패키징은 +`-Dpython.multiplatform.home=...` 으로 prefix 를 지정한다. `docs/platforms/desktop-packaged-app.md` 참고. +
+ +
+Android — PythonBootstrap.initialize 를 호출한다 + +```kotlin +import python.multiplatform.env.PythonBootstrap + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + PythonBootstrap.initialize(this) // stdlib 를 한 번 풀고 CPython 을 시작한다 + } +} +``` + +stdlib 는 APK 에 들어 있고, 첫 실행에 앱 전용 저장소로 풀린다 (API 26–36 에서 237–480 ms 측정). +이후에는 스탬프 확인으로 건너뛴다 (4–28 ms). 여러 번 불러도 안전하다. +
+ +## 🧭 구조 한눈에 보기 + +```mermaid +flowchart LR + subgraph Kotlin + K[Kotlin 코드] --> OM[객체 모델
PyObject · PyList · PyDict …] + OM --> FFI["EmbedAPI (expect)
Stable ABI 함수 약 330개"] + T[생성된 함수 테이블
KSP · Gradle 아티팩트 워커] + end + subgraph CPython + P[Python 코드] --> PX[생성된 프록시
Greeter · Modifier …] + end + FFI -- "다운콜
Panama · JNI · cinterop · wasm" --> CPython + PX -- "업콜
플랫폼마다 진입점 하나" --> T + T --> K +``` + +플랫폼마다 진입점은 하나이고, 그 뒤에서는 **모든 플랫폼이 같은 생성 테이블을 쓴다** — 플랫폼별 +Python → Kotlin 바인딩 코드가 없다. 자세한 내용: [설계 문서](../design/). + +## 🌍 플랫폼 + +| 플랫폼 | 다운콜 | 업콜 | 상태 | +|---|---|---|---| +| 데스크톱 JVM (macOS) | Panama FFM | FFM 업콜 스텁 | ✅ 전체 스위트, GraalVM 네이티브 이미지 검증 | +| 데스크톱 JVM (Linux, Windows) | Panama FFM | FFM 업콜 스텁 | 🟡 배선만, 실행한 적 없음 | +| Android | JNI (`RegisterNatives`) | JNI | ✅ 기기 테스트 | +| iOS | cinterop | `@CName` | 🟡 시뮬레이터에서 공통 스위트 | +| Android native | cinterop | `@CName` | 🟡 기기에서 공통 스위트 | +| wasmJs (브라우저, Node) | Emscripten CPython | `@WasmExport` | 🧪 실험적 | + +## 📊 상태 + +정직한 요약이다 — 남은 작업은 [로드맵](../roadmap/ROADMAP.md) 에 있다. + +| 영역 | 상태 | +|---|---| +| 인터프리터 수명주기, 객체 모델, 다운콜 표면 | ✅ 구현 | +| 객체 수명과 경계를 넘는 순환 수거 | ✅ 데스크톱 · 🟡 그 외 타깃 | +| 업콜 (클래스, 프로퍼티, `suspend`, 취소) | ✅ 데스크톱 · 🟡 그 외 타깃 | +| 빌드된 jar 바인딩, Python 에서 Compose | ✅ 데스크톱 · ⏳ Android / iOS / wasm | +| Kotlin/Native klib 바인딩 | 🟡 스캐너만 | +| `.pyi` 스텁 생성 | 🟡 부분 | +| free-threaded CPython (3.14t, desktop 한정, `-PpythonFreeThreaded=true` 옵트인) | 🟡 부분 | + +## 🧱 생태계 + +| 저장소 | 역할 | +|---|---| +| **python-multiplatform** | 언어 경계 (이 저장소) | +| [pythonx-compose](https://github.com/thisisthepy/pythonx-compose) | 이 바인더 위에 세운, Compose 를 파이써닉하게 쓰는 패키지 | +| [toolchain](https://github.com/thisisthepy/toolchain) | Python 앱 빌드를 위한 Gradle 어휘 | +| [pypackpack](https://github.com/thisisthepy/pypackpack) | Python 취득, 의존성 해석, 번들링 | +| [torchnative](https://github.com/thisisthepy/torchnative) | 기기 위의 진짜 PyTorch 생태계 | +| [Gemstone](https://github.com/LogitAI/Gemstone) | python-multiplatform 채택을 계획 중인 독립 프로젝트 | + +## 📖 문서 + +- 🌐 **[가이드](https://thisisthepy.github.io/python-multiplatform/)** (English / 한국어) — 소스는 [`docs/guide/`](../guide/) +- 🏗 [설계 문서](../design/) — 구조, 다운콜, 업콜, 수명, 스레딩, 노출 정책 +- 📱 [플랫폼 문서](../platforms/) — Android FFM, wasm, CPython 취득, GraalVM 검증 +- 🔬 [조사 기록](../investigations/) — 측정과 감사 +- 🗺 [로드맵](../roadmap/ROADMAP.md) +- 🇺🇸 [영어 README](../../README.md) + +## 🤝 기여 + +이 프로젝트는 의도 기반 스펙 주도 개발과 테스트 주도 개발로 운영한다. 의도가 스펙의 경계를 정하고, +스펙이 테스트를 이끌며, 테스트는 코드보다 먼저 빨간 상태로 작성한다. 풀 리퀘스트를 열기 전에 +[가이드의 기여 절](https://thisisthepy.github.io/python-multiplatform/#contributing) 을 읽어 달라. +iOS 타깃 빌드에는 Xcode 가 있는 macOS 가 필요하다. + +## 📄 라이선스 + +[MIT](../../LICENSE) © 2024 thisisthepy diff --git a/docs/platforms/desktop-packaged-app.md b/docs/platforms/desktop-packaged-app.md new file mode 100644 index 000000000..ca24c6889 --- /dev/null +++ b/docs/platforms/desktop-packaged-app.md @@ -0,0 +1,132 @@ +# Desktop: the standard library in a packaged application + +SPEC L-9, issue #60, ROADMAP §15.4. + +During development `stagePythonHome` downloads a python-build-standalone prefix once per machine and +the plugin sets `PYTHONHOME` on every `JavaExec` and `Test` task (SPEC L-4). A packaged application +is started by a launcher, not by Gradle, and nothing put `PYTHONHOME` in its environment, so +`Py_Initialize()` aborted with `Failed to import encodings module`. This page records how a packaged +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 — +depends on `stagePythonHome` and copies part of the staged prefix into `python-multiplatform-home/`: + +| Host | Copied (relative to the prefix) | +|---|---| +| macOS, Linux | `lib/python/**`, `lib/libpython.*` (`t` for a free-threaded build) | +| Windows | `Lib/**`, `DLLs/**`, `*.dll` | + +`**/__pycache__/**`, `include/`, `bin/`, `lib/pkgconfig` and Tcl/Tk are not copied. For 3.14.7 +macos-aarch64 that is 1,195 files and 39.6 MB (19.4 MB of it `libpython3.14.dylib`) of a 68.9 MB +prefix, measured on the staged prefix of this machine. + +In the app image the directory is `$APPDIR/resources/python-multiplatform-home`; on macOS +`.app/Contents/app/resources/python-multiplatform-home`, on Linux +`/lib/app/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. +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. + Compose sets `-Dcompose.application.resources.dir=$APPDIR/resources` on every launcher it builds. + +`Python3.initialize()` calls `applyPackagedPythonHome()` right after the `PYTHONHOME` pre-flight +check (SPEC L-3). On desktop it checks the resolved prefix with the same `PythonHomeCheck` +(throwing a catchable `IllegalStateException` that names the path and the property it came from) +and passes it to `Py_SetPythonHome` as a `wchar_t` string. Before that, when the bindings load, +`manager.loadLibPython` loads `libpython` from the same prefix instead of extracting the classpath +copy into the working directory. + +## Why this design + +**Into the app image, not a jar resource extracted at startup.** The consumer's `python/` payload is +a jar resource that `ClasspathPayload` extracts to a temp directory with a digest stamp. The same +mechanism for the stdlib was considered and not taken: + +- The stdlib is 1,195 files / 39.6 MB uncompressed. Extracted from a jar it is shipped twice (once + compressed in the app, once extracted per user), and the first launch pays for writing all of it. + Put into the app image, it is on disk at install time: no extraction ever, so there is no stamp to + check on each launch and no cold-start cost at all. +- The payload's cache lives under `java.io.tmpdir`, which operating systems clean up on their own + schedule. A stamp that survives a partial clean-up which removed part of `encodings/` would be an + 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 + 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; + the cost `PythonHomeStaging`'s KDoc measured against shipping four platforms' stdlibs in the + *library* jar does not arise. + +**Into Compose's resources task, not `nativeDistributions.appResourcesRootDir`.** That property is a +single directory the consumer may already use for their own files; setting it from the plugin would +replace theirs. Adding a `from` to the `Sync` that assembles the resources composes with whatever +the consumer configured, and matching the task by name keeps the plugin free of a compile-time +dependency on the Compose Gradle plugin (as the wasm wiring matches `wasmJsProcessResources`). + +**`Py_SetPythonHome`, not an environment variable.** The launcher accepts `--java-options` and no +environment, and a JVM cannot set its own (`desktopMain/README.md`). `Py_SetPythonHome` is part of +the Stable ABI (deprecated since 3.11 in favour of `PyConfig.home`, whose struct layout the Stable +ABI does not promise) and writes CPython's path configuration directly, so `Py_Initialize()` uses it +whenever `PYTHONHOME` is unset. + +## Other packagers + +Anything that is not Compose's `createDistributable` (plain `jpackage`, Conveyor, an uber jar) gets +no copy from the plugin. Copy the same subset of the staged prefix (`stagePythonHome`'s output, +`~/.gradle/python-multiplatform/python-home/+//python`) next to the app +and pass `-Dpython.multiplatform.home=`; jpackage expands `$APPDIR` inside `--java-options`. + +## Limits + +- No `.pyc` is shipped. CPython compiles what it imports on first use and writes `__pycache__` into + the app image where it can; where it cannot (a read-only install), it compiles on every launch. +- `pythonBindings { packagePythonHome.set(false) }` turns the copy off. `stagePythonHome.set(false)` + does not: the packaged prefix must be the one the library was built against regardless of what a + developer's own `PYTHONHOME` points at, so the task is registered (lazily) either way. +- The library needs `--enable-preview` on JDK 21 (`java.lang.foreign` is a preview API there) and + `--enable-native-access=ALL-UNNAMED`. A packaged app gets JVM options only from + `compose.desktop.application { jvmArgs(...) }`; the sample's `build.gradle.kts` sets them for + `JavaExec` tasks only. Observed on 2026-10-03 (issue #77): the packaged sample on its bundled + 21.0.12 runtime, with neither flag, initialises CPython and runs every step; the only trace is + the JDK's `WARNING: A restricted method in java.lang.foreign.Linker has been called`. The backend + reaches `java.lang.foreign` reflectively (`Panama.kt`), so no class file of ours is a preview + class file. Not a promise for later JDKs, where the restricted-method warning becomes an error. +- The bundled runtime is `jlink`ed and holds only the modules Compose was told about -- the sample's + `runtime/Contents/Home/release` lists `java.base java.datatransfer java.xml java.prefs + java.desktop java.logging jdk.crypto.ec`. The library therefore uses nothing outside `java.base` + (SPEC L-10). It used `sun.misc.Unsafe` (module `jdk.unsupported`) to build the proxy heap type, + which in the packaged app failed every proxy install with `ExceptionInInitializerError: null` and + every Kotlin-namespace import after it with `No module named 'org'` (issue #77). + +## How to check it + +Run on the build host, from the repository root, with no `PYTHONHOME` in the launched app's +environment: + +```bash +./gradlew :sample:createDistributable --console=plain > .tmp/dist.log 2>&1; echo "EXIT=$?" + +# The prefix is in the image (macOS paths; Linux: sample/build/compose/binaries/main/app//lib/app/resources) +APP=sample/build/compose/binaries/main/app/org.thisisthepy.python.multiplatform.demo.app +ls "$APP/Contents/app/resources/python-multiplatform-home/lib" # libpython3.14.dylib python3.14 +test -f "$APP/Contents/app/resources/python-multiplatform-home/lib/python3.14/encodings/__init__.py" && echo STDLIB_OK +find "$APP/Contents/app/resources/python-multiplatform-home" -name __pycache__ | wc -l # 0 + +# Launch with PYTHONHOME removed; the sample prints its runtime line and runs asyncio before the window +env -u PYTHONHOME -u PYTHON_MULTIPLATFORM_LIBPYTHON \ + "$APP/Contents/MacOS/org.thisisthepy.python.multiplatform.demo" > .tmp/packaged-run.log 2>&1 & +``` + +Expected in `.tmp/packaged-run.log`: `runtime : 3.14.7 ...`, `proxies : installed: ...` (not +`ExceptionInInitializerError`), the `Greeter` lines, and the `await` lines (which import `asyncio`), +with no `Fatal Python error` and no `No module named`. The window stays open; close it or kill the process. +Running from `/` as the working directory (`cd / && env -u PYTHONHOME "$OLDPWD/$APP/..."`) also +checks that nothing is extracted into the working directory. diff --git a/docs/platforms/graal-native-image-verification.md b/docs/platforms/graal-native-image-verification.md new file mode 100644 index 000000000..12ccbb28c --- /dev/null +++ b/docs/platforms/graal-native-image-verification.md @@ -0,0 +1,223 @@ +# GraalVM Native Image Upcall Verification + +`AGENTS.md` states the condition this document answers: *"JVM 에서만 통과하는 업콜은 완료가 아니다."* +The §7 design chose a build-time generated table precisely because runtime reflection is impossible +under GraalVM's closed-world assumption, and that choice is only confirmed by building the image. + +This file is both the **procedure** (how to reproduce) and the **record** of the last run. + +Status: a manual verification (SPEC U-6). The image itself is not run by any automated test; what +guards it automatically is `ReachabilityMetadataTest.kt` (SPEC C-2), which checks that every +`FunctionDescriptor` the desktop backend links is declared in the generated reachability metadata. +The record below is dated 2026-08-17 and has not been re-run since this document was last reviewed +(the "Worktree" path in it predates the rule that worktrees live under `.worktrees/`). + +--- + +## Procedure + +### Toolchain + +Liberica Native Image Kit, already unpacked on the external volume: + +``` +/Volumes/macMini/toolchains/bellsoft-liberica-vm-full-openjdk25-25.0.4 +``` + +``` +$ .../Contents/Home/bin/native-image --version +native-image 25.0.4 2026-07-21 +GraalVM Runtime Environment Liberica-NIK-25.0.4-1 (build 25.0.4+10-LTS) +Substrate VM Liberica-NIK-25.0.4-1 (build 25.0.4+10-LTS, serial gc) +``` + +It is deliberately **not on `PATH`**, and `GRAALVM_HOME` is **unset**. Nothing needs them: the build +resolves the launcher through Gradle's toolchain service +(`javaToolchains.launcherFor { languageVersion = 25 }` in `sample/build.gradle.kts`) and derives +`bin/native-image` from the resolved installation path. The only requirement is that Gradle be told +where the installation lives. + +### Why `JAVA_HOME` must stay on JDK 21 + +Gradle itself runs on **JDK 21** (`/Users/ibrew/Library/Java/JavaVirtualMachines/jdk-21.0.12+8/Contents/Home`); +only `native-image` runs on 25. Pointing `JAVA_HOME` at JDK 25 makes the Gradle daemon's Kotlin +build-script parser fail in `JavaVersion.parse("25.0.4")`. So JDK 25 is supplied as a *toolchain +candidate*, not as the daemon's JVM: + +```bash +JAVA_HOME=/Users/ibrew/Library/Java/JavaVirtualMachines/jdk-21.0.12+8/Contents/Home \ +./gradlew --console=plain \ + -Porg.gradle.java.installations.paths=/Volumes/macMini/toolchains/bellsoft-liberica-vm-full-openjdk25-25.0.4 +``` + +### The three tasks + +| Task | What it does | +|---|---| +| `:sample:runNativeImageUpcallDemo` | Runs `NativeImageMain.kt` on the **plain JVM**. The control. | +| `:sample:nativeCompile` | Builds `upcall-native-demo` with `native-image --no-fallback`. | +| `:sample:runNativeUpcallDemo` | Runs the binary and **asserts on the `UPCALL_OK` / `PROXY_OK` markers**, not on the exit code. | + +`:sample` does not use the `org.graalvm.buildtools.native` plugin's lifecycle tasks. The plugin only +registers `nativeCompile`/`nativeRun` when the `java` plugin's `sourceSets.main` exists, which a +Kotlin Multiplatform `jvm()` target never creates; the build therefore invokes `native-image` +directly through an `Exec` task and keeps the plugin only for toolchain resolution. This is a gap in +the plugin's KMP support, not a native-image limitation. + +--- + +## Last verification run + +- **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 + 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 + +``` +:sample:runNativeImageUpcallDemo EXIT=0 BUILD SUCCESSFUL in 42s +``` + +### Step 2 — Native image build + +``` +:sample:nativeCompile EXIT=0 BUILD SUCCESSFUL in 46s +``` + +``` +[2/8] Performing analysis... + 5,427 types, 6,490 fields, and 25,554 methods found reachable + 1,822 types, 51 fields, and 1,308 methods registered for reflection + 28 downcalls and 5 upcalls registered for foreign access +Finished generating 'upcall-native-demo' in 43.5s. +``` + +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` +exists to prevent, since an undeclared descriptor builds clean and dies at the first call with +`MissingForeignRegistrationError`. + +### Step 3 — Native image execution + +``` +:sample:runNativeUpcallDemo EXIT=0 BUILD SUCCESSFUL in 3s +``` + +Full output of the binary: + +``` +=== GraalVM native-image upcall verification === +INFO: Python initialized successfully! +KOTLIN: table = 41 entries, 4 classes, from io_github_thisisthepy_sample +KOTLIN: resolve stub @ 0x103d30000 +KOTLIN: invoke stub @ 0x103d30040 +KOTLIN: args stub @ 0x103d30080 +PYTHON: resolved handle = 4294967324 +PYTHON: invoke result = 7 +PYTHON: resolved args handle = 4294967325 +PYTHON: invoke_args result = 'presses x3 = 21' +PYTHON: @PythonInternal entry resolves to -1 +PYTHON: UPCALL_OK +KOTLIN: upcall verification finished +=== generated proxy verification === +KOTLIN: installed: 587 lines of generated Python, 1 proxy classes +Greeter('Kotlin').greet(2) -> hello Kotlin! hello Kotlin! +g.subject -> Kotlin +g.subject = 'Python'; greet -> hello Python! +g.greetings -> 3 (2 + 1, counted in Kotlin) +g.greetings = 99 -> AttributeError (private set held) +Greeter.PUNCTUATION -> ! +Greeter.built -> 1 +Greeter.built = 100 -> 100 +Greeter.forget() -> 100, then built=0 +Greeter.PUNCTUATION = '?' -> AttributeError (companion val is read-only) +Greeter('x').built -> AttributeError (companion is class-only, as in Kotlin) +await g.greetNow(1) -> hello fast path! +asyncio Futures created -> 0 (fast path: the loop was never involved) +await g.greetLater(2) -> hello slow path! hello slow path! +asyncio Futures created -> 1 (slow path: settled from a Kotlin thread) +raised -> None +completer thread -> clean +PYTHON: PROXY_OK +``` + +### 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 +earlier revision of this document did) conflates "changed since then" with "differs from the JVM". + +Diffing the two 32-line output blocks: + +``` +3,5c3,5 +< KOTLIN: resolve stub @ 0x119607f40 (JVM) +< KOTLIN: invoke stub @ 0x11964e9c0 +< KOTLIN: args stub @ 0x11964e640 +--- +> KOTLIN: resolve stub @ 0x103d30000 (native image) +> KOTLIN: invoke stub @ 0x103d30040 +> KOTLIN: args stub @ 0x103d30080 +``` + +**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, +`invoke result = 7`, `invoke_args result = 'presses x3 = 21'`, the `-1` for `@PythonInternal`, all +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 + +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**: + +```bash +env -u JAVA_HOME PYTHONHOME=.../extracted/3.14.7/macos-aarch64/python \ + ./sample/build/native/nativeCompile/upcall-native-demo +# EXIT=0, both UPCALL_OK and PROXY_OK present +``` + +And it links no JVM at all: + +``` +$ otool -L upcall-native-demo + /usr/lib/libSystem.B.dylib + /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation + /usr/lib/libz.1.dylib + /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation + /usr/lib/libobjc.A.dylib +``` + +No `libjvm`, no `libjli`. `libpython` is `dlopen`ed at run time. The build passes `--no-fallback`, so +a fallback image (which would have been a JVM launcher in disguise) would have failed the build +rather than been produced silently. + +--- + +## Result + +| Question | Answer | +|---|---| +| Native image builds | **YES** (`EXIT=0`, 25.1 MB Mach-O arm64) | +| Python -> Kotlin upcall works inside the image | **YES** (`UPCALL_OK`; `invoke result = 7` produced by this process, not a constant) | +| Argument-carrying trampoline works | **YES** (`invoke_args result = 'presses x3 = 21'`) | +| 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 | + +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 new file mode 100644 index 000000000..daf78fd55 --- /dev/null +++ b/docs/platforms/ios-app-bundle.md @@ -0,0 +1,181 @@ +# iOS: the standard library and the payload inside the app bundle + +SPEC L-11, issue #59, ROADMAP §13.1. + +`Python.xcframework` carries the interpreter and headers and no standard library, and an installed +app is launched with no `PYTHONHOME`. Until now only the simulator *test* binary had a stdlib +(`extractIosSimulatorStdlib` + `SIMCTL_CHILD_PYTHONHOME`), and that recipe does not carry over to an +app: a `PYTHONHOME` on this workspace's external volume parks the sandboxed app in `open()` at 0% CPU +(`python-multiplatform/src/iosMain/README.md`). This page records how an app now carries its own +prefix and payload, why, and how to check it. + +## What happens + +**Gradle** (`python-multiplatform/build.gradle.kts`, rules in the plugin's `IosPythonHomeLayout`). +One task per slice, `stageIosPythonHome__` (`iphoneos_arm64`, `iphonesimulator_arm64`, +`iphonesimulator_x86_64`), writes `python-multiplatform/build/python-ios-home/-/`: + +| Path | From `Python.xcframework/` | +|---|---| +| `home/lib/python/` | `lib/python/` (pure Python, every slice) merged with `/lib-/python/` (`lib-dynload/*.so`, `_sysconfigdata__ios_*`) | +| `dylib-Info-template.plist` | `build/iOS-dylib-Info-template.plist` (input to the Xcode phase; not shipped) | + +Not copied: `__pycache__`, `libpython*.dylib`, and the top-level packages `test` (36 MB and 1,641 of +the 2,407 files of the 3.14 stdlib), `idlelib`, `tkinter`, `turtledemo` (no `_tkinter` on iOS) and +`ensurepip` (pip cannot run in an app). `stageIosPythonHomeForXcode` reads `EFFECTIVE_PLATFORM_NAME` +and `ARCHS` from the environment Xcode gives a Run Script phase, stages only that slice and prints: + +``` +PYTHON_HOME_DIR=/python-multiplatform/build/python-ios-home/iphonesimulator-arm64/home +PYTHON_DYLIB_INFO_TEMPLATE=/python-multiplatform/build/python-ios-home/iphonesimulator-arm64/dylib-Info-template.plist +``` + +A universal `ARCHS` (`arm64 x86_64`) is refused with the fix (`ONLY_ACTIVE_ARCH=YES` or `ARCHS=arm64`): +one prefix holds one `lib-dynload`. Upstream's `utils.sh` has the same limit without saying so. + +**Xcode** (`tools/xcode/install-python.sh`, a Run Script phase after Copy Bundle Resources and before +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 + 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 + 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. +4. Every `.so` under both directories becomes `Frameworks/.framework/` + with an `Info.plist` from the template, a `.fwork` placeholder where the `.so` was (its + content is the framework binary's path relative to the app), and a `.origin` back-reference; + each framework is signed with `EXPANDED_CODE_SIGN_IDENTITY` when there is one. + +**Run time** (`python-multiplatform/src/iosMain/.../env/PackagedPythonHome.ios.kt`). +`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 + scheme variable keep working as before. +2. `/python-multiplatform-home`, only if that directory exists. + +A bundled prefix is checked with `PythonHomeCheck` (an `IllegalStateException` naming the path and +"the app bundle's resource directory"), converted with `Py_DecodeLocale` (UTF-8 on Apple platforms) +and passed to `Py_SetPythonHome`. `PythonPayload` then finds `/python` and puts it on +`sys.path`, as before. + +## Why this design + +**Upstream's framework rule, not loose `.so` files.** iOS does not load a binary that is not inside a +framework on a device, and App Store validation rejects one. CPython 3.13+ on iOS has +`importlib.machinery.AppleFrameworkLoader`: for `foo.cpython-314-iphoneos.fwork` it reads the file, +joins it to `dirname(sys.executable)` and loads that binary. The script is upstream's +`Python.xcframework/build/utils.sh` `install_dylib`, with two changes: the template comes from the +Gradle staging (so the phase needs no path into the archive), and signing is skipped when Xcode +exports no identity instead of failing. The simulator would load a loose `.so`; the device would not, +so the simulator run uses the device's layout. + +**`python-multiplatform-home/`, not upstream's `python/`.** Upstream's testbed sets +`config.home = /python`. Here `python/` is the consumer's payload root +(`PythonPayload.PAYLOAD_ROOT`, L-6), and the payload copy is `rsync --delete`: one directory for +both would let each copy delete the other. The name is the one desktop already uses (L-9). + +**Staged per slice by Gradle, copied by a thin script.** The stdlib merge (shared tree + one +`lib-` tree) and the exclusions are pure functions with unit tests +(`GP/IosPythonHomeLayoutTest.kt`); the script only copies, wraps and signs. The selection lives in the +Gradle plugin because the library build script already imports the plugin's public layout objects +(`CPythonIncludeLayout`) and a build script cannot be unit-tested. + +**`Py_SetPythonHome`, not `setenv`.** Same reasoning as desktop: it sets CPython's path +configuration for this process only, and it is checked by the same `PythonHomeCheck` immediately +before. `PyConfig.home` is the non-deprecated route, but the library carries no `PyConfig` layout. + +## Wiring an app + +The sample's project is `iosApp/iosApp.xcodeproj` (product `PythonDemo.app`, bundle id +`org.thisisthepy.python.multiplatform.demo`). Its phase: + +```sh +set -e +cd "$SRCROOT/.." +export PYTHON_PAYLOAD_DIR="$SRCROOT/../sample/python/src/main" +/bin/bash tools/xcode/install-python.sh +``` + +It replaced two phases: "Install Target Specific Python Standard Library", which rsynced the slice's +`lib/` — only `libpython3.14.dylib` — into `/lib/`, and "Prepare Python Binary Modules", which +did the framework wrapping from a template copied in as a resource. `iosApp/iosApp/dylib-Info-template.plist` +is no longer copied into the app; the file itself is still in the project. + +`sample/src/iosMain/app.xcodeproj` is not wired: it still references `../../dist/toolchain/...` and +a `ComposeApp` framework that this build does not produce, and has never been built here. + +A consumer outside this repository cannot call `:python-multiplatform:stageIosPythonHomeForXcode`: +the iOS archive download lives in this repository's build script, not in the published plugin. That +is the next step if consumers are to get this without copying the sample. + +## Checking it (completion criterion of #59) + +The sample app on the simulator imports a stdlib module, a stdlib extension module and a payload +module from the installed bundle, launched with **no** `SIMCTL_CHILD_*` variable. All commands from +the repository root; Gradle needs JDK 21 (`JAVA_HOME`), and Xcode's phases inherit it. + +```bash +export JAVA_HOME=/Users/ibrew/Library/Java/JavaVirtualMachines/jdk-21.0.12+8/Contents/Home +df -h / # the installed app (~60 MB) goes into CoreSimulator on the internal disk + +# 0. Stage once by hand and look at it +EFFECTIVE_PLATFORM_NAME=-iphonesimulator ARCHS=arm64 \ + ./gradlew -q :python-multiplatform:stageIosPythonHomeForXcode > .tmp/stage.out 2>&1; echo "EXIT=$?" +cat .tmp/stage.out # two lines: PYTHON_HOME_DIR=..., PYTHON_DYLIB_INFO_TEMPLATE=... +ls python-multiplatform/build/python-ios-home/iphonesimulator-arm64/home/lib/python3.14/lib-dynload | head +test ! -e python-multiplatform/build/python-ios-home/iphonesimulator-arm64/home/lib/python3.14/test && echo "test/ excluded" +# must fail, naming ONLY_ACTIVE_ARCH: +EFFECTIVE_PLATFORM_NAME=-iphonesimulator ARCHS="arm64 x86_64" \ + ./gradlew -q :python-multiplatform:stageIosPythonHomeForXcode > .tmp/stage-universal.out 2>&1; echo "EXIT=$?" + +# 1. Frameworks the Xcode project links against +./gradlew :sample:prepareIosFrameworks --console=plain > .tmp/prepare.log 2>&1; echo "EXIT=$?" + +# 2. Build the app (flags from the last verified run, ROADMAP history 2026-08-13: actool cannot +# compile the app icon against this machine's simulator runtimes, and -target avoids +# -destination's eligibility check) +(cd iosApp && xcodebuild -project iosApp.xcodeproj -target iosApp -configuration Debug \ + -sdk iphonesimulator ARCHS=arm64 ONLY_ACTIVE_ARCH=YES SYMROOT="$PWD/build" \ + ASSETCATALOG_COMPILER_APPICON_NAME="" \ + CODE_SIGN_IDENTITY="-" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="" build) \ + > .tmp/xcodebuild.log 2>&1; echo "EXIT=$?" +grep -E "Installing the Python|Wrapped|error:" .tmp/xcodebuild.log + +APP=iosApp/build/Debug-iphonesimulator/PythonDemo.app +ls "$APP/python-multiplatform-home/lib/python3.14/os.py" "$APP/python/example_py/__init__.py" +ls "$APP/python-multiplatform-home/lib/python3.14/lib-dynload" | grep -c '\.fwork$' # 67 for 3.14 +find "$APP/python-multiplatform-home" "$APP/python" -name '*.so' | wc -l # 0 +ls -d "$APP/Frameworks/_json.framework" "$APP/Frameworks/Python.framework" +codesign --verify --deep --strict "$APP" && echo SIGNED_OK + +# 3. Install and launch with no SIMCTL_CHILD_* variable +UDID=$(xcrun simctl list devices booted | grep -oE '[0-9A-F-]{36}' | head -n 1); echo "$UDID" +env | grep '^SIMCTL_CHILD_' && echo "unset these first" # must print nothing +xcrun simctl uninstall "$UDID" org.thisisthepy.python.multiplatform.demo +xcrun simctl install "$UDID" "$APP" +xcrun simctl launch --console-pty --terminate-running-process "$UDID" \ + org.thisisthepy.python.multiplatform.demo > .tmp/launch.log 2>&1 & +LAUNCH=$! +# wait for the DEMO block (Monitor/until-loop; no sleep in this shell), then: +kill "$LAUNCH" +grep '^DEMO' .tmp/launch.log +``` + +Pass: `DEMO 1 runtime` names `3.14.x · sys.platform=ios`, and `DEMO 8 bundle` is a `tuple:` whose +five entries are the payload's greeting as JSON (`"Hello from usage-example's Python payload 0.1.0"`), +`_json.__file__` ending `PythonDemo.app/python-multiplatform-home/lib/python3.14/lib-dynload/_json.cpython-314-iphonesimulator.fwork`, +`example_py.__file__` ending `PythonDemo.app/python/example_py/__init__.py`, `sys.prefix` ending +`PythonDemo.app/python-multiplatform-home`, and `sys.executable` ending `PythonDemo.app/PythonDemo`. + +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` | +| `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 new file mode 100644 index 000000000..daa234cc2 --- /dev/null +++ b/docs/platforms/python-version-acquisition.md @@ -0,0 +1,147 @@ +# CPython Version Acquisition Research + +## 1. Desktop Platforms (macOS, Linux, Windows) + +The project currently uses `python-build-standalone` (maintained by Astral). For Python 3.14 and 3.15, this project continues to publish pre-built self-contained binaries. + +* **Release Tags:** Releases are tagged by date (e.g., `20260807`). +* **Asset Naming Scheme:** The asset naming follows the pattern: `cpython-+--.`. + * Targets include: `aarch64-apple-darwin`, `x86_64-apple-darwin`, `x86_64-unknown-linux-gnu`, `x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc`. + * Flavours include: `install_only`, `install_only_stripped`, and `full` (which also includes debug symbols, pgo/lto variants). The archives are available in `.tar.gz` and `.tar.zst` formats. +* **Flavour acquired: `install_only_stripped`** (issue #86). The plain `install_only` linux `libpython3.14.so.1.0` + carries debug info (251,884,016 bytes); the `install_only_stripped` one is 33,106,704 bytes with no `.debug_*` + section (it keeps `.symtab`, so `file` still says "not stripped"). The stripped flavour exists for every desktop + target and both GIL and free-threaded builds, for 3.14.7 and 3.15.0rc1 in release `20260807`, so no platform falls + back. The archives are pinned in `python-checksums.properties` and agree with the release's `SHA256SUMS`; the + desktop jar's build check (`DesktopJarLibrariesTest`) fails if a linux library in it has `.debug_info`. A host + `strip` (absent or ELF-blind on macOS) is not used. +* **URL Pattern:** `https://github.com/astral-sh/python-build-standalone/releases/download//` + +## 2. Free-threaded Variants + +`python-build-standalone` publishes free-threaded builds for Python 3.14 and 3.15. +* **Naming:** The string `freethreaded` is injected into the asset name before the flavour, e.g., `cpython-3.14.7+20260807-aarch64-apple-darwin-freethreaded-install_only.tar.gz`. +* **ABI and Libraries:** The ABI suffix is `t`, meaning the shared libraries will be named `libpython3.14t.dylib` or `libpython3.14t.so`. +* **Availability:** They are available for all major desktop targets (macOS, Linux, Windows). + +## 3. Android and iOS Support + +* **Android:** Prebuilt artifacts for Android are provided natively by python.org. These archives are plain NDK cross-compilations (e.g., CPython's own build tree). They can be found at `https://www.python.org/ftp/python//python---linux-android.tar.gz`. +* **iOS (3.14):** pinned to BeeWare `3.14-b11` (Python 3.14.7). `3.14-b10`, pinned earlier, shipped 3.14.6 while every other target shipped 3.14.7 (issue #47); b11 is the first 3.14 build with 3.14.7 (release 2026-09-04; its `VERSIONS` file and all four `patchlevel.h` say 3.14.7). The build now checks `PY_VERSION` in the extracted headers against `pythonVersion` for every target and fails if they differ. python.org's own iOS XCframework starts at 3.15 (section 5), so there is no alternative 3.14 source. +* **iOS:** Prebuilt artifacts for iOS are provided by the BeeWare project via their `Python-Apple-support` repository, which are CPython's own iOS build layouts (e.g. `prefix: iOS/Frameworks/...`). They are located at `https://github.com/beeware/Python-Apple-support/releases`. The kivy toolchains are **not** the source of these artifacts. +* **python-build-standalone Status:** `python-build-standalone` has moved from indygreg to Astral (`https://github.com/astral-sh/python-build-standalone`). It **does not** publish prebuilt binaries for `android` or `ios`. +* **Prebuilt-cpython Status:** The `python/prebuilt-cpython` repository is currently a planning repository containing no artifacts, so it is not a source. +* **Conclusion:** We acquire Android artifacts directly from python.org, and iOS artifacts from BeeWare's Python-Apple-support, eliminating the need for kivy toolchain dependency. + +## 4. Stable ABI and free-threaded builds + +* **The constraint:** the ~330 bindings target the CPython Stable ABI, and CPython's Limited API / + `abi3` is not offered by free-threaded builds in 3.13 and 3.14 (3.15 adds `abi3t`, PEP 803). +* **Why it does not block free-threading here:** the bindings are symbols resolved dynamically at run + time and `Py_LIMITED_API` is not defined, so no compile-time ABI contract applies. 3.14t works on + desktop with `-PpythonFreeThreaded=true` (the whole desktop suite: 236 tests, 0 failures, at the time + of ROADMAP §9; SPEC T-2). An earlier version of this section concluded the opposite ("fails or crashes + due to opaque structural changes", "wait for 3.15t") and was wrong; see + [`../design/threading-and-abi.md`](../design/threading-and-abi.md) and + [`../archive/threading-and-abi-3.15t-gate.md`](../archive/threading-and-abi-3.15t-gate.md). +* **Scope:** free-threaded prebuilts exist only for the desktop targets (section 2); Android (python.org) + and iOS (Python-Apple-support) publish none, so the flag is desktop-only and defaults to `false` + (`gradle.properties`). The interpreter version is `pythonVersion` in `gradle.properties`. + +## 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. +* **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. +* **The verification is known to fail when it should**, which is the only thing that makes it worth having. Two negative controls were run against the real 3.14.7 Android bundle: flipping one base64 character in `messageSignature.signature` gave `KeylessVerificationException: Artifact signature was not valid`, and pointing the identity map at the 3.13 release manager gave `No provided certificate identities matched values in certificate`. Both failed the build with a non-zero exit. + +## 6. WebAssembly + +No distributor ships a `python.wasm` that exports `wasmExports` and `wasmMemory` to JavaScript, which the +`wasmJs` target needs. CPython is therefore **built**, not downloaded: `tools/wasm/build-cpython.sh` builds +CPython 3.14.2 for `wasm32-emscripten` (Emscripten 5.0.3, matched to the `pyemscripten_2026_0` platform of +PEP 783) under the git-ignored `.caches/`, and `python-multiplatform-wasm-runtime` is the published zip of +the result. This is deliberately a different patch release from the native `pythonVersion`; see +[`wasm-design.md`](wasm-design.md). + +## 7. Include directories as a build output + +Compiling a C extension against the embedded CPython needs that CPython's headers, not the host's. +`python-multiplatform` exposes them as a project extension named `cpythonIncludeDirectories` +(`python.multiplatform.gradle.CPythonIncludeDirectories`, in the Gradle plugin module). + +* **Key:** `(target, flavour)`. Targets: `macos-aarch64`, `macos-x86_64`, `linux-x86_64`, + `windows-x86_64`, `android-aarch64`, `android-x86_64`, `ios-arm64`, `ios-arm64_x86_64-simulator` + (the iOS names are XCFramework slices). Flavour: `CPythonFlavour.GIL` (default) or `FREE_THREADED` + (desktop only). +* **Value:** `Provider` of the directory that directly contains `Python.h`, i.e. the `-I` + directory: `/[-freethreaded]/python/include/python3.14[t]` on desktop (Windows has no + `python3.14` level), `/android-*/prefix/include/python3.14`, + `/ios/Python.xcframework//include/python3.14`. +* **Task dependency:** the provider carries the matching `downloadPython_*` task; using it as a task + input makes Gradle download and extract first. +* **Only the configured flavour is extracted.** `-PpythonFreeThreaded=true` selects the free-threaded + desktop tree; asking for the other flavour throws a `GradleException` naming that property + (`isAvailable` tests it without throwing). + +```kotlin +// consumer build.gradle.kts +evaluationDependsOn(":python-multiplatform") +val includes = project(":python-multiplatform").extensions.getByType() +tasks.register("compileExt") { + val inc = includes.includeDir("macos-aarch64") // or ("linux-x86_64", CPythonFlavour.FREE_THREADED) + inputs.dir(inc) + commandLine("cc", "-I${inc.get().asFile}", "-c", "ext.c") +} +``` + +### Link libraries + +The same extension exposes the library to link against (issue #56): +`libraryDir(target, flavour)` (`Provider`, the `-L` directory) and +`libraryFile(target, flavour)` (`Provider`), both carrying the `downloadPython_*` task. + +| Target | Link library (under `/`) | +|---|---| +| `android-aarch64`, `android-x86_64` | `/prefix/lib/libpython3.14.so` | +| `windows-x86_64` | `windows-x86_64/python/libs/python314.lib` (`python3.lib` is the stable-ABI one) | +| `windows-x86_64` free-threaded | `windows-x86_64-freethreaded/python/libs/python314t.lib` | +| macOS, Linux, iOS | none -- `isLinkRequired(target)` is false; `libraryDir`/`libraryFile` throw `GradleException` | + +Android has no free-threaded build, so that combination throws. `isLinkAvailable(target, flavour)` +tests without throwing; the other flavour fails naming `-PpythonFreeThreaded`, as for `includeDir`. + +## Published version + +A consumer that builds against `python-multiplatform` must know which CPython it embeds (issue #61). +The answer is published three ways, all derived from `pythonVersion` / `pythonFreeThreaded` in +`gradle.properties`. + +* **Extension** `pythonMultiplatform` (`python.multiplatform.gradle.EmbeddedPythonVersion`): + `pythonVersion` (`3.14.7`), `majorMinor` (`3.14`), `freeThreaded`. +* **Gradle attributes** on every consumable `*Elements` configuration (so they land in the published + module metadata): `org.thisisthepy.python.version` and `org.thisisthepy.python.free-threaded` + (both `String`; the latter is `"true"`/`"false"`). +* **Resource** `META-INF/python-multiplatform/python.properties` (`pythonVersion=`, `freeThreaded=`), + inside the desktop jar and the Android AAR's `classes.jar`. + +```kotlin +// composite build (includeBuild): read the extension +evaluationDependsOn(":python-multiplatform") +val embedded = project(":python-multiplatform").extensions.getByType() +println(embedded.majorMinor) + +// published artefact: read the resource from the classpath +val text = Thread.currentThread().contextClassLoader + .getResourceAsStream(EmbeddedPythonVersion.RESOURCE_PATH)!!.use { it.readBytes().decodeToString() } +val embedded = EmbeddedPythonVersion.parse(text) + +// published artefact: require a matching variant through the attributes +configurations.named("desktopRuntimeClasspath") { + attributes.attribute(Attribute.of("org.thisisthepy.python.version", String::class.java), "3.14.7") +} +``` diff --git a/docs/platforms/wasm-design.md b/docs/platforms/wasm-design.md new file mode 100644 index 000000000..414eb1b67 --- /dev/null +++ b/docs/platforms/wasm-design.md @@ -0,0 +1,132 @@ +# WebAssembly (`wasmJs`) design + +How CPython runs under Kotlin/Wasm in this library, why it has this shape, and what was measured to +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). + +**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 +(`wasmJsNodeTest`, the whole suite) and on a browser (`wasmJsBrowserTest`, a filtered subset). `:sample` +has a browser wasm target. `wasmWasi` is not a target (when last assessed it was blocked by Kotlin/Wasm tooling, see the table below). + +## Decisions + +| Decision | Choice | Why | +|---|---|---| +| How Kotlin reaches CPython | Raw Stable ABI symbols as `@WasmImport`s against the Emscripten module's own wasm exports — **no JS in the call path** | Preserves the exact object model (`NativePointer`, `PyObject`) of every other target; a high-level Pyodide-style JS API (`pyodide.runPython`, `PyProxy`) would replace the whole object model with `JsAny` wrappers and covers almost none of the ~330 C functions | +| Memory | **One shared linear memory.** Kotlin imports Emscripten's `Module.wasmMemory` as its `intrinsics.memory` | Needs Kotlin ≥ 2.4.20-Beta2, whose `wasmJs` modules **import** `intrinsics.memory` (`min=0, max=none`). Releases up to 2.4.10 exported an unbounded memory, which Emscripten cannot import — the earlier plan to patch the `.wasm` memory section (and a YouTrack issue) is obsolete ([`../archive/wasm-youtrack-issue.md`](../archive/wasm-youtrack-issue.md)) | +| Where CPython comes from | **Our own Emscripten build**, `tools/wasm/build-cpython.sh`: CPython **3.14.2**, Emscripten **5.0.3**, matched to the `pyemscripten_2026_0` platform of PEP 783 | No distributor ships a `python.wasm` exporting `wasmExports,wasmMemory`. The version is deliberately not the 3.14.x pinned for native targets (`pythonVersion` in `gradle.properties`); see `docs/design/ecosystem.md` | +| C shim / composition | **None** | The hops that composition amortised are gone; measured gain ≤ 2.9 ns per operation (below). It also removes a second build pipeline | +| `EXPORTED_FUNCTIONS` list | **Not needed** | `-sMAIN_MODULE` is `LINKABLE`, so the whole Stable ABI is exported (all 310 C symbols `EmbedAPI.kt` declared at the time were found in the build's 8287 exports). The build adds `wasmExports,wasmMemory` to `-sEXPORTED_RUNTIME_METHODS`, which is not ABI-sensitive | +| Upcalls (Python → Kotlin) | `@WasmExport` trampolines placed in CPython's `__indirect_function_table` with `WebAssembly.Table.set`; the table index is the C function pointer. Routing is by data (a handle in `self`/`closure`), through the generated table, as on every platform | A funcref is a funcref: `call_indirect` reaches the Kotlin export directly. Measured 3.1 ns vs 10.9 ns for the `addFunction` + JS-closure route | +| Reclamation | `registerCleaner` over a JS `FinalizationRegistry` + `WeakRef`, reached through `toJsReference()` | The Kotlin/Wasm stdlib has no finalisation hook, but the host does; `toJsReference()` does not pin the object (200 objects, `alive 0 / 200`) | +| `Py_ssize_t` | 32-bit; handled at the boundary | The only ABI divergence of wasm32 (SPEC C-6, `WasmPySsizeTBoundaryTest`) | +| `wasmWasi` | Not a target | Kotlin/Wasm cannot link a C module there and WASI has no JS host to glue them; CPython-on-WASI has no `dlopen`, so users could not add compiled packages, which defeats the purpose | + +### Why the architecture is what it is + +- Kotlin/Wasm is **WasmGC**: a Kotlin object cannot live in linear memory, so CPython can never hold + a Kotlin object pointer. The answer is the same handle table as everywhere + ([`object-lifetime.md`](../design/object-lifetime.md)). Kotlin reads CPython's memory with + `Pointer(addr)` (plain `i32.load`/`i32.store`). +- Kotlin never uses its own linear memory (0 pages across strings, collections, exceptions, a 10 MiB + `ByteArray`, a 100k-object graph, the `ArrayBuffer` bridge and coroutines), so handing the memory to + Emscripten costs nothing. The one exception is `withScopedMemoryAllocator`, which allocates from + address 0 on top of Emscripten's static data: **it must never be called** (CPython allocates, + Kotlin dereferences). +- A wasm memory *import* survives CPython growing the memory underneath Kotlin; a JS `TypedArray` + view would detach. +- Kotlin/Wasm has no `call_indirect`, so *calling* a C function pointer (`tp_traverse`'s `visitproc`, + `tp_free`) goes through two small functions in `cpython.mjs` (`pmpCallVisit`, `pmpCallFree`). Both are + 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 — + `@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` / + `WebAssembly.Suspending`; with JSPI live the first blocking syscall (`select.poll().poll(0)`, which + `selectors.py` does at import) fails with `SuspendError` and surfaces as `RuntimeError: unreachable`. + See the wasmJsMain README for the cost of this global mutation. + +## Measurements + +All from the experiment and the tests that replaced it (Node 24 / Emscripten 5.0.3 unless stated; +current figures: run `WasmCrossingOverheadTest`, `WasmUpcallRouteOverheadTest`, +`WasmMarshallingOverheadTest`; `docs/investigations/cost-table.md` has the cross-platform table). + +| Question | Result | +|---|---| +| Direct `@WasmImport` vs JS trampoline, 10 M calls of a trivial C function | **5.0 ns** vs 13.6–16.9 ns | +| One real crossing on CPython (`PyErr_Occurred`) | 2.9 ns | +| 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) | +| 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 | + +Corrections that came out of measuring (they changed the design): + +- Shared memory does **not** make string marshalling "disappear". Kotlin/Wasm strings already are JS + strings, and what shared memory removes is the intermediate WasmGC `ByteArray` allocation (82.7 ns + → 30.6 ns per short argument), not a copy. +- Interning is worth much more for long names than short ones (a cache hit is flat in length, an encode + is linear). The marshalling rules for the other platforms are in + [`marshalling-design.md`](../design/marshalling-design.md); wasm uses the same two primitives + (`Wasm.internedUtf8`, `Wasm.scratchUtf8`, four scratch slots). +- CPython's own wasm trampoline for `PyMethodDef` / getset calls never installs on this toolchain pair + (its `EM_JS` initialiser needs `wasmTable`/`wasmMemory` before they exist and the `LinkError` is + swallowed by a bare `catch`). Every C extension pays the same JS frame, so the cost is symmetric. + `WasmCallTrampolineTest` pins this and goes red the day upstream fixes it. Prefer type slots installed + through `PyType_FromSpec`, which CPython invokes by plain `call_indirect`. + +## The interpreter build and the platform tag + +`tools/wasm/build-cpython.sh` builds CPython 3.14.2 with CPython's own `Tools/wasm/emscripten` driver +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) | +| `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` | + +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 +exception exercises the unwinding ABI), and fails with `tag import requires a WebAssembly.Tag` on the +stock build. This is one wheel and one code path; it does not show that every compiled extension +loads. `WasmCompiledWheelTest` runs it (`tools/wasm/build-cpython.sh wheels` fetches the pinned wheels); +`WasmInterpreterAbiTest` checks the claims, with `tools/wasm/build-cpython.sh stock` as the negative +control. Emscripten is pinned to 5.0.3 because that is what the platform specifies, and installing it +replaces `~/emsdk/upstream` in place (a global setting). + +Platform notes that still hold: PEP 776 makes `wasm32-emscripten` Tier 3 from CPython 3.14 and supports +only static linking of the interpreter (CPython cannot be dynamically loaded into another module); the +build yields a `.mjs` + `.wasm` pair, so a JS layer exists for instantiation but not for calls. Pure-Python +wheels work unchanged; compiled third-party extensions load if published for the PEP 783 tag. + +## Browser + +Node and a browser differ in the filesystem and three build steps, all supplied by the library +(`stageWasmBrowserRuntime`): `cpython.mjs` in the consumer's webpack context, `python.mjs`/`python.wasm` +beside the page, the standard library as `python3..zip` installed into MEMFS (no NODEFS in a +browser; no `thisProgram`, so `sys.prefix` stays `/`), and the two substitutions on the compile-sync output. +Details and the postcondition that guards the entry-module ordering are in the wasmJsMain README. + +## Not verified / open + +- Whether a Kotlin exception thrown inside an upcall unwinds safely through CPython's frames: every + trampoline in the experiment returned normally or returned `NULL`. +- Whether compiled wheels other than pydantic-core load. +- Compose (`@Composable` from Python) on wasm is `planned` (SPEC B-6, N-2); `suspend` upcalls are + `planned` on wasm because there are no threads (SPEC U-5). +- Absolute wasmJs nanoseconds depend on suite scope and host lifetime (a filtered run is 55–74% + slower in pure-Python rows); quote ratios, or full-suite figures labelled as such + ([`downcall-design.md`](../design/downcall-design.md), "Warmup"). +- Test count: the wasmJsMain README records 403 tests, 0 failures, 0 skipped on `wasmJsNodeTest` and says + to re-run rather than trust the number; I did not re-run it. diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md new file mode 100644 index 000000000..1e4a2a30b --- /dev/null +++ b/docs/roadmap/ROADMAP.md @@ -0,0 +1,394 @@ +# ROADMAP + +**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 — + 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 + 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", + "§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), + 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 + Gradle**. A cited test is one that exists and asserts the thing; whether it passes in this revision + was not observed here. + +--- + +## 1. Release the GIL after initialisation + +**Closed.** `Python3.initialize()` parks its thread state with `PyEval_SaveThread()` unconditionally +(`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 + 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 + names the unguarded call (SPEC C-4). Note: SPEC C-4's sentence "Releasing the GIL after + initialisation is not enabled" contradicts the code above. + +## 2. Finish the Android JNI surface + +**Closed.** 363 of 367 `external fun` are registered through `RegisterNatives`; the four exceptions +are deliberate (`ffiAllocUtf8`/`ffiFreeUtf8`/`ffiReadUtf8`, `echoCriticalNamed`). Evidence: +`PM/androidInstrumentedTest/kotlin/python/native/ffi/JniWiringTest.kt`, +`PM/desktopTest/kotlin/python/native/ffi/JniCallConventionClassificationTest.kt`, and the +`commonTest` suite running on device (`connectedDebugAndroidTest`, see SPEC §0). + +## 3. Classify the remaining functions leaf vs re-entrant + +**Closed.** The classification is derived on every desktop build by +`JniCallConventionClassificationTest`; the procedure is in `PM/androidMain/README.md` and +`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 + `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 + them. + +## 4. Automatic reference release + +Closed for the ordinary case: wrappers release on GC on every target +(`PM/commonTest/.../ref/GCLeakTest.kt`, `OwnershipLeakTest.kt`, `PM/jvmTest/.../ref/PhantomCleanerRegistryTest.kt`, +`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 + 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 + `!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` +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 + 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. + +## 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.** + 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 + understood (SPEC X-1). + +## 7. Python → Kotlin binder (upcalls) + +Closed in its core: build-time table, KSP generator, one entry point per platform, generated proxies, +`tp_traverse` handle slot on all five targets, GraalVM native image (SPEC U-1…U-6, M-3, M-4; +`ksp-fixtures/app/src/desktopTest/`, `PM/commonTest/.../reflection/UpcallTableTest.kt`, +`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 + (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 + 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 + 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 + `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 + 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 + 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 + `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 + 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).** + `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. + +## 7b. Finish `PyValue` + +**Closed** for the eager and lazy paths (`PM/commonTest/.../conversion/ConversionTest.kt`, +`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 + (`PM/commonMain/kotlin/python/multiplatform/ffi/types/collections/CollectionSupport.kt`, + `TODO(open design question)`), with what to measure first. + +## 8. `jvmMain` unification + +**Closed, nothing further recommended.** Of 314 common declarations only 3 have identical +Android/desktop bodies and only `toRawValue` was movable (now in +`PM/jvmMain/kotlin/python/native/ffi/EmbedAPI.jvm.kt`); the rest differ in real wiring (per-convention +JNI callee names). Re-opening it means redesigning Android's convention selection, not moving code. + +## 9. Free-threading + +3.14t runs the whole desktop suite with `-PpythonFreeThreaded=true` (opt-in; `gradle.properties` +default `pythonFreeThreaded=false`): 236 tests, 0 failures, 1 skipped when last recorded, the same +count as the GIL build (table at `4ed98143:docs/roadmap/ROADMAP.md`:1013; SPEC T-2). Deferred deallocation is +drained by the eval-loop checkpoint (`PM/commonTest/.../ref/EvalCheckpointTest.kt`, +`docs/investigations/gc-scheduling-investigation.md`); `FreeThreadedGCGateTest` guards the GC probe. +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 + `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 + 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. + +## 10. WASM + +The target runs the shared suite under Node and a browser subset under Chromium; lifetimes, upcalls, +cycles, interning and `Py_ssize_t` are closed (`PM/wasmJsTest/`: `WasmFinalizationTest`, +`WasmCycleCollectionTest`, `WasmPySsizeTBoundaryTest`, `WasmBrowserRuntimeTest`, +`WasmSelectorsImportTest`); the plugin stages the runtime for consumers +(`GP/main/kotlin/python/multiplatform/gradle/WasmBrowserRuntimeStaging.kt`, +`GenerateWasmProxyExportsTask.kt`). The interpreter is built by `tools/wasm/build-cpython.sh` +(`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 + `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), + 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 + 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 + execution evidence is the in-repo `:sample` and `wasmJsBrowserTest`. An external consumer + (`consumer-plugin-android`) was verified to *build* `wasmJsBrowserDistribution` with the runtime + staged by the plugin; it was never run in a browser, and no production bundler other than the + Kotlin Gradle webpack setup was tried (also `docs/design/ecosystem.md` §5 "Unverified"). +- **10.5 The wasm CPython build lacks lzma, zstd and OpenSSL — open.** Listed as "NOT DONE" in + `tools/wasm/build-cpython.sh`'s header; ABI-sensitive per the Pyodide flag list, though they did not + gate the one compiled wheel tested (`WasmCompiledWheelTest`). +- **10.6 The sample's wasm upcall line (`presses x3 = 0`) was never cross-checked against desktop — + unverified, minor.** + +## 11. Build wiring + +**Closed.** Android staging tasks are wired by `configureEach` and `dependsOn`, no +configuration-time copies or `whenTaskAdded` remain (`python-multiplatform/build.gradle.kts`). Old +§11b (Android runs `commonTest`) is **closed**: `androidInstrumentedTest` depends on `commonTest`. + +## 12. Smaller known items + +Closed from the old list: Sigstore verification (`-PverifyPythonSignatures=true`), the Android +pre-release URL, `PyList.subList` (a live `PySubList`), the `TODO` triage, `runMain`/`runApp` +(`PM/commonTest/.../ffi/RunMainTest.kt`, `RunAppTest.kt`), Android `TMPDIR` +(`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()` + (`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 + 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` + 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 + 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 + assertions belong in CI. +- **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. + +## 13. The sample, and the AGP version that shapes it + +The sample runs on desktop, Android (both emulators), the iOS simulator and in a browser; the bindings +plugin applies to Android modules (AGP 8.10.1); `ksp-fixtures/android` exists; the generated +`installGeneratedUpcallTable` `actual` reaches intermediate source sets; the per-platform upcall +trampolines exist. GraalVM native image upcalls are verified by hand +(`docs/platforms/graal-native-image-verification.md`, `PM/desktopTest/.../ReachabilityMetadataTest.kt`). + +- **13.1 The iOS app bundle carries no Python standard library — implemented, simulator proof + 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 + `.fwork` placeholders) replacing `iosApp/`'s two broken phases, and `IosPythonHome` + + `Py_SetPythonHome` at run time. Left: the installed-app run on the simulator with no + `SIMCTL_CHILD_*` (the page's "Checking it"); a device run; and a way for consumers outside this + repository to stage the stdlib (the iOS archive download is in this build script, not the plugin). +- **13.2 Native-image upcall verification as an automated test — open.** SPEC U-6/N-5: the procedure + 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 + 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 + 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 + `lib/python3.13`, and `PM/iosMain/README.md` says the stdlib comes from BeeWare without the + ≥ 3.15 python.org qualification. + +## 14. Current state, gathered + +**Removed.** It was a dated audit snapshot. Test counts are re-counted per AGENTS.md §15, behaviour +status lives in SPEC.md, and its open-item list (§14b) is redistributed: item 1 → 9.1, item 2 → 12.4 +(CI now runs), item 3 → 12.3, item 4 → 12.5, item 5 → 4.1, item 11 → 13.1; items 6–10 were closed. + +## 15. Can a consumer outside this repo actually use the library? + +Yes, by Maven coordinates, on desktop and Android: all three components publish with one task +(`publishAllToMavenLocal`, root `build.gradle.kts`), the KSP processor publishes, the plugin's default +processor coordinates are correct, the Android AAR publishes (23.42 MB, test suite and headers +stripped), the root metadata jar is 382 KB, `PythonBootstrap` stages the Android stdlib +(`PM/androidInstrumentedTest/.../env/PythonBootstrapTest.kt`), and `stagePythonHome` stages the +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 + 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 + `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 + 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 + 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 + `compose.application.resources.dir` (or `-Dpython.multiplatform.home`) and hands it to CPython with + `Py_SetPythonHome`. A child-JVM test with `PYTHONHOME` removed covers the run-time half + (`PM/desktopTest/.../env/PackagedPythonHomeLaunchTest.kt`); the packaged sample run is + `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 + taken).** 790 files / 11.91 MB are byte-identical between `arm64-v8a` and `x86_64`; de-duplicating + saves 3.16 MB compressed but changes the published asset layout `/lib/python` that + `PythonBootstrap` and consumers read. + +## 16. Bindings from resolved artefacts (the artefact walker) + +The walker binds jar declarations — statics, top-level functions, extensions, constructors, value +classes, defaults, callbacks, `@Composable`s — by generating Kotlin source (SPEC B-1, B-3, B-4, B-6; +`GP/test/.../artifact/ArtifactScannerTest.kt`, `ksp-fixtures/artifact/`, `ksp-fixtures/compose/`), +reads Kotlin metadata (`GP/main/.../artifact/KotlinMetadata.kt`), walks klibs through an isolated +worker (`KlibScanner.kt`, `KlibScanWorkAction.kt`), and takes several targets (`artifactTargets`, +`GP/test/.../ArtifactTargetsWiringTest.kt`). Consumers bootstrap `_pm_resolve`/`_pm_invoke` through +`UpcallBootstrap` (`PM/commonTest/.../ffi/upcall/UpcallBootstrapTest.kt`). Old §16a–§16d are +description, §16e/§16f's open remainder is below. + +- **16.1 klib declarations bound at run time — partial.** SPEC B-2/N-3. `KlibScannerTest` reads real + klibs and declines with reasons; `ksp-fixtures/klib-artifact` walks + `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 + `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 + 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 + `.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 + 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.** + SPEC B-5; pinned red-by-design by `M3ProofRenderTest.aValueReturningFunctionSlotDoesNotYetAcceptAPythonCallable`. +- **16.6 `.pyi` stubs for handle-returning functions are not wrapped — open.** SPEC B-7. +- **16.7 Compose through Python on Android, iOS and wasm — open.** SPEC B-6/N-2: the render proofs + (`ksp-fixtures/compose/src/desktopTest/`) are desktop only. +- **16.8 Two material3 components not reachable from Python — open.** The dynamic `ColorScheme` + 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` + renders into a popup layer `ImageComposeScene` does not capture, so it is unverified rather than + known broken (`M3ProofRenderTest.popupLayersAreNotCapturedByImageComposeScene`). + +## 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, +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).** + 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 + wasm runtime staging (`WasmBrowserRuntimeStaging.kt`). +- **17.3 Where the generic `pythonx` adapter machinery belongs — decision.** It lives in + `PM/commonMain/kotlin/python/multiplatform/ffi/pythonx/`, while INTENT §2.3 puts `pythonx` in + pythonx-compose (SPEC "Outside intent" item 4). It no longer synthesises a `pythonx` module + (it installs `python_multiplatform.binding`); whether it stays here as a service is unstated. +- **17.4 The committed CPython 3.13 trees — decision.** `python-multiplatform/src/nativeInterop/cinterop/lib` + (15 555 tracked files, Android/desktop/iOS) and `binary/` (3.13.0 archives) are read only by the + `-PpythonVersion=3.13.0` desktop branch; the Android 3.13 stdlib trees by nothing. INTENT §3 says the + interpreter is not vendored. Removing them is a repository-size decision (`ecosystem.md` §5). diff --git a/gradle.properties b/gradle.properties index d2dd6e552..a0b3f7c09 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,3 +21,16 @@ kotlin.native.binary.sourceInfoType=libbacktrace #Compose org.jetbrains.compose.experimental.jscanvas.enabled=true org.jetbrains.compose.experimental.uikit.enabled=true + +# Python Build +pythonVersion=3.14.7 +pythonFreeThreaded=false +pythonBuildStandaloneRelease=20260807 +pythonAppleSupportBuild=b11 + +# GraalVM native-image (sample's nativeCompile task). Liberica NIK is not in a location Gradle's +# toolchain auto-detection scans, so it is registered explicitly here. Adding a path only makes a +# JDK discoverable for tasks that ask for it by JavaLanguageVersion(25); it does not change the +# toolchain any existing task resolves to. See AGENTS.md §19 for why tooling lives +# under /Volumes/macMini rather than the internal disk. +org.gradle.java.installations.paths=/Volumes/macMini/toolchains/bellsoft-liberica-vm-full-openjdk25-25.0.4/Contents/Home diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eb6ffca54..3aaa559ca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,22 +1,29 @@ [versions] -agp = "8.5.2" -android-compileSdk = "34" -android-minSdk = "24" +agp = "8.10.1" +android-compileSdk = "36" +android-minSdk = "26" android-targetSdk = "34" -androidx-activityCompose = "1.9.3" +androidx-activityCompose = "1.12.4" androidx-appcompat = "1.7.0" androidx-constraintlayout = "2.1.4" androidx-core-ktx = "1.13.1" androidx-espresso-core = "3.6.1" -androidx-lifecycle = "2.8.0" +androidx-lifecycle = "2.10.0" androidx-material = "1.12.0" androidx-test-junit = "1.2.1" -compose-plugin = "1.6.11" +compose-plugin = "1.11.1" +# JetBrains stopped publishing material-icons with Compose after 1.7.3 (Maven Central: newest is 1.7.3); +# it is the version Compose Multiplatform 1.11.x points to, resolved against 1.11.1's ui/ui-graphics. +compose-material-icons = "1.7.3" +graalvm-buildtools = "0.10.6" junit = "4.13.2" -kotlin = "2.0.20" +kotlin = "2.4.20-Beta2" +kotlinx-browser = "0.5.0" kotlinx-coroutines = "1.8.1" +ksp = "2.3.11" [libraries] +compose-material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "compose-material-icons" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -30,6 +37,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-browser = { group = "org.jetbrains.kotlinx", name = "kotlinx-browser", version.ref = "kotlinx-browser" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -37,3 +45,6 @@ android-library = { id = "com.android.library", version.ref = "agp" } jetpack-compose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +graalvm-native = { id = "org.graalvm.buildtools.native", version.ref = "graalvm-buildtools" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wasm-yarn.lock b/gradle/wasm-yarn.lock new file mode 100644 index 000000000..5f4567d64 --- /dev/null +++ b/gradle/wasm-yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@js-joda/core@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" + integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0e5..e2847c820 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 796319f54..7f0dbc4af 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; 8B3F1A512CC49F1D00B85330 /* Python.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B3F1A502CC49F1D00B85330 /* Python.xcframework */; }; - 8B3F1A5B2CC4A49700B85330 /* dylib-Info-template.plist in Resources */ = {isa = PBXBuildFile; fileRef = 8B3F1A5A2CC4A49700B85330 /* dylib-Info-template.plist */; }; 8B3F1A5C2CC574B200B85330 /* Python.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8B3F1A502CC49F1D00B85330 /* Python.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -120,8 +119,7 @@ 7555FF77242A565900829871 /* Sources */, B92378962B6B1156000C7307 /* Frameworks */, 7555FF79242A565900829871 /* Resources */, - 8B3F1A532CC49F9C00B85330 /* Install Target Specific Python Standard Library */, - 8B3F1A552CC4A00F00B85330 /* Prepare Python Binary Modules */, + 8B3F1A532CC49F9C00B85330 /* Install Python Standard Library and Payload */, 8B3F1A4F2CC498EB00B85330 /* Embed Frameworks */, ); buildRules = ( @@ -178,14 +176,13 @@ files = ( 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, - 8B3F1A5B2CC4A49700B85330 /* dylib-Info-template.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 8B3F1A532CC49F9C00B85330 /* Install Target Specific Python Standard Library */ = { + 8B3F1A532CC49F9C00B85330 /* Install Python Standard Library and Payload */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -194,32 +191,14 @@ ); inputPaths = ( ); - name = "Install Target Specific Python Standard Library"; + name = "Install Python Standard Library and Payload"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n\nmkdir -p \"$CODESIGNING_FOLDER_PATH/lib\"\nif [ \"$EFFECTIVE_PLATFORM_NAME\" = \"-iphonesimulator\" ]; then\n echo \"Installing Python modules for iOS Simulator\"\n rsync -au --delete \"$PROJECT_DIR/../sample/build/xcode-frameworks/Python.xcframework/ios-arm64_x86_64-simulator/lib/\" \"$CODESIGNING_FOLDER_PATH/lib/\" \nelse\n echo \"Installing Python modules for iOS Device\"\n rsync -au --delete \"$PROJECT_DIR/../sample/build/xcode-frameworks/Python.xcframework/ios-arm64/lib/\" \"$CODESIGNING_FOLDER_PATH/lib/\" \nfi\n"; - }; - 8B3F1A552CC4A00F00B85330 /* Prepare Python Binary Modules */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Prepare Python Binary Modules"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "set -e\n\ninstall_dylib () {\n INSTALL_BASE=$1\n FULL_EXT=$2\n\n # The name of the extension file\n EXT=$(basename \"$FULL_EXT\")\n # The location of the extension file, relative to the bundle\n RELATIVE_EXT=${FULL_EXT#$CODESIGNING_FOLDER_PATH/} \n # The path to the extension file, relative to the install base\n PYTHON_EXT=${RELATIVE_EXT/$INSTALL_BASE/}\n # The full dotted name of the extension module, constructed from the file path.\n FULL_MODULE_NAME=$(echo $PYTHON_EXT | cut -d \".\" -f 1 | tr \"/\" \".\"); \n # A bundle identifier; not actually used, but required by Xcode framework packaging\n FRAMEWORK_BUNDLE_ID=$(echo $PRODUCT_BUNDLE_IDENTIFIER.$FULL_MODULE_NAME | tr \"_\" \"-\")\n # The name of the framework folder.\n FRAMEWORK_FOLDER=\"Frameworks/$FULL_MODULE_NAME.framework\"\n\n # If the framework folder doesn't exist, create it.\n if [ ! -d \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER\" ]; then\n echo \"Creating framework for $RELATIVE_EXT\" \n mkdir -p \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER\"\n cp \"$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist\"\n plutil -replace CFBundleExecutable -string \"$FULL_MODULE_NAME\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist\"\n plutil -replace CFBundleIdentifier -string \"$FRAMEWORK_BUNDLE_ID\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/Info.plist\"\n fi\n \n echo \"Installing binary for $FRAMEWORK_FOLDER/$FULL_MODULE_NAME\" \n mv \"$FULL_EXT\" \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME\"\n # Create a placeholder .fwork file where the .so was\n echo \"$FRAMEWORK_FOLDER/$FULL_MODULE_NAME\" > ${FULL_EXT%.so}.fwork\n # Create a back reference to the .so file location in the framework\n echo \"${RELATIVE_EXT%.so}.fwork\" > \"$CODESIGNING_FOLDER_PATH/$FRAMEWORK_FOLDER/$FULL_MODULE_NAME.origin\" \n}\n\nPYTHON_VER=$(ls -1 \"$CODESIGNING_FOLDER_PATH/lib\")\necho \"Install Python $PYTHON_VER standard library extension modules...\"\nfind \"$CODESIGNING_FOLDER_PATH/lib/$PYTHON_VER/lib-dynload\" -name \"*.so\" | while read FULL_EXT; do\n install_dylib lib/$PYTHON_VER/lib-dynload/ \"$FULL_EXT\"\ndone\n\n# Clean up dylib template \nrm -f \"$CODESIGNING_FOLDER_PATH/dylib-Info-template.plist\"\necho \"Signing frameworks as $EXPANDED_CODE_SIGN_IDENTITY_NAME ($EXPANDED_CODE_SIGN_IDENTITY)...\"\nfind \"$CODESIGNING_FOLDER_PATH/Frameworks\" -name \"*.framework\" -exec /usr/bin/codesign --force --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" ${OTHER_CODE_SIGN_FLAGS:-} -o runtime --timestamp=none --preserve-metadata=identifier,entitlements,flags --generate-entitlement-der \"{}\" \\; \n"; + shellScript = "set -e\n# SPEC L-10 / issue #59: the CPython stdlib and this app's python/ payload into the .app, with\n# extension modules wrapped as frameworks. See docs/platforms/ios-app-bundle.md.\ncd \"$SRCROOT/..\"\nexport PYTHON_PAYLOAD_DIR=\"$SRCROOT/../sample/python/src/main\"\n/bin/bash tools/xcode/install-python.sh\n"; }; F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */ = { isa = PBXShellScriptBuildPhase; diff --git a/ksp-fixtures/android/build.gradle.kts b/ksp-fixtures/android/build.gradle.kts new file mode 100644 index 000000000..b1f642744 --- /dev/null +++ b/ksp-fixtures/android/build.gradle.kts @@ -0,0 +1,87 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/** + * The fixture that carries an Android plugin. + * + * ROADMAP §13: `id("io.github.thisisthepy.python.multiplatform.bindings")` applies + * `com.google.devtools.ksp`, and KSP declares `MINIMUM_SUPPORTED_AGP_VERSION`. Below that AGP the + * plugin dies at configuration time with + * + * java.lang.NoSuchMethodError: 'void com.android.build.api.variant + * .AndroidComponentsExtension.addKspConfigurations(boolean)' + * + * in every module carrying an Android plugin. Neither `:ksp-fixtures:library` nor + * `:ksp-fixtures:app` applies one, so both stayed green while every Android consumer of the + * plugin was broken. **This module exists to fail in that situation**: if the AGP/KSP pair ever + * drifts back apart, `gradlew :ksp-fixtures:android:testDebugUnitTest` stops configuring at all. + * + * It paid for itself on its first run by failing for a *second* reason: AGP names its KSP + * configurations with the build type last (`kspAndroidTestDebug`), so the plugin's + * `endsWith("Test")` filter put the processor on the unit-test compilation, which emitted a + * duplicate fragment that shadowed `main`'s. See `PythonBindingsPlugin.isBindingKspConfiguration`. + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + id("io.github.thisisthepy.python.multiplatform.bindings") +} + +kotlin { + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + sourceSets { + // On `commonMain` rather than `androidMain`: the `@InstallsUpcallTable expect fun` lives + // in `src/commonMain` and needs the annotation on its own compile classpath. An `expect` + // has to sit in a source set the target's source set depends on, which is the whole + // reason the seam exists. + val commonMain by getting { + dependencies { + implementation(projects.pythonMultiplatform) + } + } + val androidUnitTest by getting { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlin.test.junit) + } + } + val androidInstrumentedTest by getting { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlin.test.junit) + implementation(libs.androidx.test.junit) + implementation("androidx.test:runner:1.6.2") + } + } + } +} + +android { + namespace = "fixture.android" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +pythonBindings { + // `com.android.library` is not `com.android.application`, so the aggregating role is not + // inferred -- stated for the same reason `:ksp-fixtures:app` states it. Aggregating is what + // puts a `FunctionTable` in front of the test below; a `library` role would only emit a + // fragment, and the test would be asserting on the generator rather than on the table. + role.set("app") + processor.set(projects.pythonMultiplatformKsp) +} diff --git a/ksp-fixtures/android/src/androidInstrumentedTest/kotlin/fixture/android/GeneratedAndroidTableTest.kt b/ksp-fixtures/android/src/androidInstrumentedTest/kotlin/fixture/android/GeneratedAndroidTableTest.kt new file mode 100644 index 000000000..813f13200 --- /dev/null +++ b/ksp-fixtures/android/src/androidInstrumentedTest/kotlin/fixture/android/GeneratedAndroidTableTest.kt @@ -0,0 +1,125 @@ +package fixture.android + +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.ClassLookup +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * On-device instrumented execution of ksp-fixtures:android upcall table tests. + */ +class GeneratedAndroidTableTest { + + @BeforeTest + fun install() { + UpcallTable.install(FunctionTable.fragments) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + @Test + fun theAndroidModulesFragmentIsInTheAggregatedTable() { + assertEquals(setOf("io_github_thisisthepy_ksp_fixtures_android"), UpcallTable.moduleNames) + assertTrue(UpcallTable.resolve("fixture.android.androidDouble").isValid) + } + + @Test + fun topLevelFunctionsInvokeAndReturnCorrectly() { + assertEquals(20L, UpcallTable.invoke(UpcallTable.resolve("fixture.android.androidDouble"), arrayOf(10L))) + assertEquals( + "Hello from Android, world!", + UpcallTable.invoke(UpcallTable.resolve("fixture.android.androidGreet"), arrayOf("world")), + ) + assertEquals(Unit, UpcallTable.invoke(UpcallTable.resolve("fixture.android.androidNoArgs"), arrayOf())) + } + + @Test + fun narrowerIntParametersAndReturnsRoundTripThroughTheBoundaryLongRepresentation() { + val scale = UpcallTable.resolve("fixture.android.androidScale") + val result = UpcallTable.invoke(scale, arrayOf(6L, 7L)) + assertEquals(42L, result) + assertTrue(result is Long, "an Int-returning function must still cross as the boundary's Long") + } + + @Test + fun pythonInternalExcludesTheAnnotatedFunctionFromTheTable() { + assertFalse(UpcallTable.resolve("fixture.android.androidHidden").isValid) + } + + @Test + fun aGeneratedClassRoundTripsConstructMethodGetterAndSetterThroughTheHandleTable() { + val counter = UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter."), arrayOf(10L))!! + val ref = HandleTable.register(counter) + + assertEquals( + 15L, + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.increment"), arrayOf(HandleTable.require(ref), 5L)), + ) + assertEquals( + "n=15", + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.label"), arrayOf(HandleTable.require(ref), "n=")), + ) + assertEquals( + 15L, + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.count"), arrayOf(HandleTable.require(ref))), + ) + + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.count="), arrayOf(HandleTable.require(ref), 99L)) + assertEquals(99L, (counter as AndroidCounter).count) + + assertTrue(HandleTable.release(ref)) + assertNull(HandleTable.resolve(ref)) + } + + @Test + fun everyGeneratedClassMemberNameResolvesInTheCallableTable() { + val counter = assertNotNull(ClassLookup.find("fixture.android.AndroidCounter")) + for (member in counter.memberNames) { + assertTrue(UpcallTable.resolve(member).isValid, "unresolved generated member: $member") + } + } + + @Test + fun aVarWithAPrivateSetterIsExposedReadOnlyHereToo() { + val counter = UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter."), arrayOf(3L))!! + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.label"), arrayOf(counter, "n=")) + + assertEquals( + "n=3", + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.lastLabel"), arrayOf(counter)), + ) + assertFalse(UpcallTable.resolve("fixture.android.AndroidCounter.lastLabel=").isValid) + assertFalse( + ClassLookup.require("fixture.android.AndroidCounter").memberNames + .contains("fixture.android.AndroidCounter.lastLabel="), + ) + } + + @Test + fun commonCodeInstallsTheGeneratedTableThroughTheSeam() { + UpcallTable.clear() + assertEquals(0, UpcallTable.callableCount) + + val count = installAndCountEntries() + + assertTrue(count > 0) + assertEquals(setOf("io_github_thisisthepy_ksp_fixtures_android"), UpcallTable.moduleNames) + assertTrue(UpcallTable.resolve("fixture.android.androidDouble").isValid) + assertFalse( + UpcallTable.resolve("fixture.android.installGeneratedUpcallTable").isValid, + "the generated actual carries @PythonInternal and must not be offered to Python", + ) + } +} diff --git a/ksp-fixtures/android/src/androidMain/kotlin/fixture/android/AndroidFunctions.kt b/ksp-fixtures/android/src/androidMain/kotlin/fixture/android/AndroidFunctions.kt new file mode 100644 index 000000000..a4fc0d6f4 --- /dev/null +++ b/ksp-fixtures/android/src/androidMain/kotlin/fixture/android/AndroidFunctions.kt @@ -0,0 +1,43 @@ +package fixture.android + +import python.multiplatform.reflection.PythonInternal + +/** + * The Python-facing surface of the Android fixture. + * + * Deliberately the same shapes `ksp-fixtures/library` uses on desktop -- a top-level function, a + * narrowing `Int` signature, a `Unit` return, a class with a method and a mutable property, and + * one `@PythonInternal` exclusion. The point is not to re-test the generator's policy (the + * desktop fixture does that against a far bigger surface) but to prove the *same* generator + * output is produced and runs when KSP is driven from a module carrying an Android plugin. + */ +fun androidDouble(x: Long): Long = x * 2 + +fun androidGreet(name: String): String = "Hello from Android, $name!" + +fun androidScale(value: Int, by: Int): Int = value * by + +fun androidNoArgs() { + // exercises the Unit-return path +} + +@PythonInternal +fun androidHidden(): String = "must not appear in the generated table" + +class AndroidCounter(var count: Long = 0) { + /** ROADMAP §13's second defect, on the AGP side of the generator: `isMutable` is true and the + * setter is not something generated code may name. `ksp-fixtures/library` pins all three + * restricted visibilities; this one only checks the same decision is made over here. */ + var lastLabel: String = "" + private set + + fun increment(by: Long): Long { + count += by + return count + } + + fun label(prefix: String): String { + lastLabel = "$prefix$count" + return lastLabel + } +} diff --git a/ksp-fixtures/android/src/androidUnitTest/kotlin/fixture/android/GeneratedAndroidTableTest.kt b/ksp-fixtures/android/src/androidUnitTest/kotlin/fixture/android/GeneratedAndroidTableTest.kt new file mode 100644 index 000000000..26c1f121c --- /dev/null +++ b/ksp-fixtures/android/src/androidUnitTest/kotlin/fixture/android/GeneratedAndroidTableTest.kt @@ -0,0 +1,142 @@ +package fixture.android + +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.ClassLookup +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * ROADMAP §13's regression test. + * + * The failure this guards against is a *configuration-time* one -- KSP refusing to apply to a + * module carrying an Android plugin -- so the strongest thing this file does is exist in a module + * that applies both. Every assertion below is therefore downstream of the real check: if the + * AGP/KSP version pair drifts apart again, this class never compiles, let alone runs. + * + * Beyond that it proves the generated table is the same table on this side: names resolve, + * arguments narrow and widen across the boundary, `@PythonInternal` still excludes, and a class + * round-trips construct/method/getter/setter through the handle table. It runs as a plain JVM + * unit test (`testDebugUnitTest`) -- nothing here touches CPython, so no device is needed. + */ +class GeneratedAndroidTableTest { + + @BeforeTest + fun install() { + UpcallTable.install(FunctionTable.fragments) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + @Test + fun theAndroidModulesFragmentIsInTheAggregatedTable() { + // The name the gradle plugin derives from this module's group and path. Its presence is + // what says KSP ran here at all -- the state ROADMAP §13 describes has no fragment + // because the build never got past configuration. + assertEquals(setOf("io_github_thisisthepy_ksp_fixtures_android"), UpcallTable.moduleNames) + assertTrue(UpcallTable.resolve("fixture.android.androidDouble").isValid) + } + + @Test + fun topLevelFunctionsInvokeAndReturnCorrectly() { + assertEquals(20L, UpcallTable.invoke(UpcallTable.resolve("fixture.android.androidDouble"), arrayOf(10L))) + assertEquals( + "Hello from Android, world!", + UpcallTable.invoke(UpcallTable.resolve("fixture.android.androidGreet"), arrayOf("world")), + ) + assertEquals(Unit, UpcallTable.invoke(UpcallTable.resolve("fixture.android.androidNoArgs"), arrayOf())) + } + + @Test + fun narrowerIntParametersAndReturnsRoundTripThroughTheBoundaryLongRepresentation() { + val scale = UpcallTable.resolve("fixture.android.androidScale") + val result = UpcallTable.invoke(scale, arrayOf(6L, 7L)) + assertEquals(42L, result) + assertTrue(result is Long, "an Int-returning function must still cross as the boundary's Long") + } + + @Test + fun pythonInternalExcludesTheAnnotatedFunctionFromTheTable() { + assertFalse(UpcallTable.resolve("fixture.android.androidHidden").isValid) + } + + @Test + fun aGeneratedClassRoundTripsConstructMethodGetterAndSetterThroughTheHandleTable() { + val counter = UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter."), arrayOf(10L))!! + val ref = HandleTable.register(counter) + + assertEquals( + 15L, + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.increment"), arrayOf(HandleTable.require(ref), 5L)), + ) + assertEquals( + "n=15", + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.label"), arrayOf(HandleTable.require(ref), "n=")), + ) + assertEquals( + 15L, + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.count"), arrayOf(HandleTable.require(ref))), + ) + + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.count="), arrayOf(HandleTable.require(ref), 99L)) + assertEquals(99L, (counter as AndroidCounter).count) + + assertTrue(HandleTable.release(ref)) + assertNull(HandleTable.resolve(ref)) + } + + @Test + fun everyGeneratedClassMemberNameResolvesInTheCallableTable() { + val counter = assertNotNull(ClassLookup.find("fixture.android.AndroidCounter")) + for (member in counter.memberNames) { + assertTrue(UpcallTable.resolve(member).isValid, "unresolved generated member: $member") + } + } + + @Test + fun aVarWithAPrivateSetterIsExposedReadOnlyHereToo() { + val counter = UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter."), arrayOf(3L))!! + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.label"), arrayOf(counter, "n=")) + + assertEquals( + "n=3", + UpcallTable.invoke(UpcallTable.resolve("fixture.android.AndroidCounter.lastLabel"), arrayOf(counter)), + ) + assertFalse(UpcallTable.resolve("fixture.android.AndroidCounter.lastLabel=").isValid) + assertFalse( + ClassLookup.require("fixture.android.AndroidCounter").memberNames + .contains("fixture.android.AndroidCounter.lastLabel="), + ) + } + + @Test + fun commonCodeInstallsTheGeneratedTableThroughTheSeam() { + // ROADMAP §13's first defect, checked on the side where the ROADMAP recorded the + // *exception*: `androidMain` can name `FunctionTable` directly. `installAndCountEntries` + // is in `commonMain`, which cannot -- and KSP runs once per variant here, so this is the + // path where a generated `actual` could go missing or land twice. + UpcallTable.clear() + assertEquals(0, UpcallTable.callableCount) + + val count = installAndCountEntries() + + assertTrue(count > 0) + assertEquals(setOf("io_github_thisisthepy_ksp_fixtures_android"), UpcallTable.moduleNames) + assertTrue(UpcallTable.resolve("fixture.android.androidDouble").isValid) + assertFalse( + UpcallTable.resolve("fixture.android.installGeneratedUpcallTable").isValid, + "the generated actual carries @PythonInternal and must not be offered to Python", + ) + } +} diff --git a/ksp-fixtures/android/src/commonMain/kotlin/fixture/android/TableInstall.kt b/ksp-fixtures/android/src/commonMain/kotlin/fixture/android/TableInstall.kt new file mode 100644 index 000000000..ac3370ec7 --- /dev/null +++ b/ksp-fixtures/android/src/commonMain/kotlin/fixture/android/TableInstall.kt @@ -0,0 +1,25 @@ +package fixture.android + +import python.multiplatform.reflection.InstallsUpcallTable +import python.multiplatform.reflection.UpcallTable + +/** + * The seam under AGP. + * + * ROADMAP §13 recorded that `androidMain` is the one source set that *can* name `FunctionTable`, + * because it is the Android target's own source set rather than an intermediate one. That makes + * it the case most likely to behave differently: KSP runs once per *variant* here, so the + * `actual` is generated into the debug and release compilations separately, and each one carries + * exactly one. + * + * Nothing shared code writes should have to know any of that, which is what this file asserts by + * being identical in shape to `ksp-fixtures/app`'s. + */ +@InstallsUpcallTable +expect fun installGeneratedUpcallTable() + +/** Common code reaching the generated table without naming it. */ +fun installAndCountEntries(): Int { + installGeneratedUpcallTable() + return UpcallTable.callableCount +} diff --git a/ksp-fixtures/app/build.gradle.kts b/ksp-fixtures/app/build.gradle.kts new file mode 100644 index 000000000..8f136bb14 --- /dev/null +++ b/ksp-fixtures/app/build.gradle.kts @@ -0,0 +1,175 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + id("io.github.thisisthepy.python.multiplatform.bindings") +} + +kotlin { + jvm("desktop") { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + androidNativeArm64 { + // Mirrors :python-multiplatform's own androidNativeArm64 test linker config + // (build.gradle.kts) -- this module's test binary needs the same CPython symbols + // resolved at final link time, since python-multiplatform's klib only declares them via + // cinterop and does not itself embed a static libpython. This is a throwaway measurement + // wiring: only linkDebugTestAndroidNativeArm64 needs it (docs/design/upcall.md + // §11.1's tree-shaking measurement), not the ordinary JVM-only fixture test path. + val downloadDir = project(":python-multiplatform").layout.buildDirectory.dir("python-standalone").get().asFile + val pyVersion = project.findProperty("pythonVersion")?.toString() ?: project.rootProject.version.toString() + val libVersion = pyVersion.split('.').subList(0, 2).joinToString(".") + // Version-keyed, matching python-multiplatform's `extractedDir`. + val targetExtractDir = "$downloadDir/extracted/$pyVersion/android-aarch64/prefix" + binaries.getTest(org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.DEBUG).linkerOpts.addAll( + listOf("-L$targetExtractDir/lib/", "-lpython$libVersion", "-Wl,--allow-shlib-undefined"), + ) + } + // The *second* Native leaf, and it is here for one reason: two of them make the default + // hierarchy give this module an `androidNativeMain` intermediate source set. That is the + // `iosMain` shape from ROADMAP §13 -- a source set the generating compilations depend on, + // which therefore can neither name `FunctionTable` nor host an `actual` for it -- reproduced + // without needing Xcode. Compile-only: no test binary is linked for it. + androidNativeX64() + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.pythonMultiplatform) + implementation(projects.kspFixtures.library) + } + } + val desktopTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + } +} + +pythonBindings { + // This module applies neither `application` nor `com.android.application`, so the role that + // makes it aggregate has to be stated. Everything else is inferred. + role.set("app") + processor.set(projects.pythonMultiplatformKsp) +} + +// --------------------------------------------------------------------------------------------- +// TypedPython: compiled code calling Kotlin through the binder (issue #45, `TypedPythonKotlinCallTest`) +// --------------------------------------------------------------------------------------------- +// +// `buildTypedPythonBridge` compiles `src/desktopTest/typedpython/tp_kotlin_bridge.py` with the +// TypedPython pipeline (`python-multiplatform-ksp/src/main/python/typedpython`) into an extension +// for the CPython this fixture *embeds*, not the host's: +// +// - headers: `python-multiplatform`'s `cpythonIncludeDirectories` provider +// (`docs/platforms/python-version-acquisition.md` §7), for this host's macOS target and the +// flavour `-PpythonFreeThreaded` selects. The provider carries the `downloadPython_*` task. +// - extension suffix: `typedpython/build_bridge.py` reads `EXT_SUFFIX` from the `_sysconfigdata_*.py` +// of that same extracted tree and cross-checks `Py_GIL_DISABLED` against its `pyconfig.h`. +// +// The pipeline itself runs on a *host* Python that has Pyrefly 1.3.2 (the gate needs it): +// `-PtypedpythonPython=`, else `python-multiplatform-ksp/.venv/bin/python` when that +// exists. Without one -- or on a host other than macOS, where the JVM does not load libpython with +// global symbols and a `dynamic_lookup` extension could not resolve the C API -- the task is skipped +// and the test reports itself skipped with the reason. When the task does run, any failure fails the +// build: a gate error, a function left interpreted, a C compiler error. +// +// The provider is reached by name and reflection, not `getByType()`: this +// project and `:python-multiplatform` each apply the bindings plugin in their own `plugins {}` block, +// so each build script may see its own copy of the plugin's classes, and a typed lookup across the +// two can fail with "extension of type ... does not exist" even though it is registered. +evaluationDependsOn(":python-multiplatform") + +val typedPythonHost: File? = (findProperty("typedpythonPython") as String?)?.let { File(it) } + ?: rootDir.resolve("python-multiplatform-ksp/.venv/bin/python").takeIf { it.isFile } + +val typedPythonMacTarget: String? = + if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) null + else when (System.getProperty("os.arch")) { + "aarch64", "arm64" -> "macos-aarch64" + "x86_64", "amd64" -> "macos-x86_64" + else -> null + } + +val typedPythonSkipReason: String? = when { + typedPythonMacTarget == null -> + "host ${System.getProperty("os.name")}/${System.getProperty("os.arch")} is not a macOS desktop target" + typedPythonHost == null -> + "no host Python with pyrefly 1.3.2: pass -PtypedpythonPython= or create " + + "python-multiplatform-ksp/.venv (pyproject.toml there pins pyrefly)" + typedPythonHost?.isFile != true -> "-PtypedpythonPython=$typedPythonHost does not exist" + else -> null +} + +@Suppress("UNCHECKED_CAST") +val typedPythonIncludeDir: Provider? = typedPythonMacTarget?.let { target -> + val includes = project(":python-multiplatform").extensions.getByName("cpythonIncludeDirectories") + val flavourClass = includes.javaClass.classLoader.loadClass("python.multiplatform.gradle.CPythonFlavour") + val freeThreaded = (findProperty("pythonFreeThreaded") as String?)?.toBoolean() ?: false + val flavour = flavourClass.enumConstants.single { (it as Enum<*>).name == if (freeThreaded) "FREE_THREADED" else "GIL" } + includes.javaClass.getMethod("includeDir", String::class.java, flavourClass) + .invoke(includes, target, flavour) as Provider +} + +val typedPythonBridgeDir = layout.buildDirectory.dir("typedpython/bridge") + +val buildTypedPythonBridge by tasks.registering(Exec::class) { + group = "verification" + description = "Compiles tp_kotlin_bridge.py with TypedPython against the embedded CPython's headers (#45)." + + val compilerDir = rootDir.resolve("python-multiplatform-ksp/src/main/python") + val sourceDir = file("src/desktopTest/typedpython") + val script = file("typedpython/build_bridge.py") + val outDir = typedPythonBridgeDir + val tmpDir = layout.buildDirectory.dir("typedpython/tmp") + val include = typedPythonIncludeDir + val skip = typedPythonSkipReason + + onlyIf("TypedPython bridge: ${skip ?: "enabled"}") { skip == null } + inputs.files(fileTree(compilerDir) { exclude("**/__pycache__/**") }).withPropertyName("compiler") + inputs.files(fileTree(sourceDir) { exclude("**/__pycache__/**") }).withPropertyName("sources") + inputs.file(script).withPropertyName("script") + if (include != null && skip == null) inputs.dir(include).withPropertyName("cpythonInclude") + inputs.property("host", typedPythonHost?.absolutePath ?: "") + outputs.dir(outDir) + + executable = typedPythonHost?.absolutePath ?: "python3" + // Pyrefly and the gate write temporary files; keep them inside this build directory (AGENTS.md §2). + environment("TMPDIR", tmpDir.get().asFile.absolutePath) + environment("PYTHONDONTWRITEBYTECODE", "1") + argumentProviders.add(CommandLineArgumentProvider { + listOf( + script.absolutePath, + compilerDir.absolutePath, + File(sourceDir, "tp_kotlin_bridge.py").absolutePath, + include!!.get().asFile.absolutePath, + outDir.get().asFile.absolutePath, + ) + }) + doFirst { tmpDir.get().asFile.mkdirs() } +} + +tasks.named("desktopTest") { + dependsOn(buildTypedPythonBridge) + // The tests import tp_kotlin_bridge.py from src/; without this the interpreter leaves a + // __pycache__ in the source tree. + environment("PYTHONDONTWRITEBYTECODE", "1") + val skipReason = typedPythonSkipReason + doFirst { + if (skipReason != null) logger.warn("TypedPython bridge tests will be SKIPPED: $skipReason") + } + if (typedPythonSkipReason == null) { + inputs.dir(typedPythonBridgeDir).withPropertyName("typedPythonBridge") + systemProperty( + "typedpython.bridge.properties", + typedPythonBridgeDir.get().file("bridge.properties").asFile.absolutePath, + ) + } else { + systemProperty("typedpython.bridge.skip", typedPythonSkipReason) + } +} diff --git a/ksp-fixtures/app/src/androidNativeArm64Test/kotlin/fixture/app/NativeSmokeTest.kt b/ksp-fixtures/app/src/androidNativeArm64Test/kotlin/fixture/app/NativeSmokeTest.kt new file mode 100644 index 000000000..a8e8a7e9a --- /dev/null +++ b/ksp-fixtures/app/src/androidNativeArm64Test/kotlin/fixture/app/NativeSmokeTest.kt @@ -0,0 +1,21 @@ +package fixture.app + +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Compile+link-only smoke test on a real Kotlin/Native target (docs/design/upcall.md §2.9's + * "Verify .klib discovery" open question). Not run here -- androidNativeArm64 targets a + * device/emulator this workspace does not execute tests against -- but linking this binary is + * what `docs/design/upcall.md` §2.6's tree-shaking measurement diffs the size of. + */ +class NativeSmokeTest { + @Test + fun theAggregatedTableInstallsOnNative() { + UpcallTable.install(FunctionTable.fragments) + assertTrue(UpcallTable.resolve("fixture.library.add").isValid) + UpcallTable.clear() + } +} diff --git a/ksp-fixtures/app/src/androidNativeMain/kotlin/fixture/app/NativeInstall.kt b/ksp-fixtures/app/src/androidNativeMain/kotlin/fixture/app/NativeInstall.kt new file mode 100644 index 000000000..85adc2b61 --- /dev/null +++ b/ksp-fixtures/app/src/androidNativeMain/kotlin/fixture/app/NativeInstall.kt @@ -0,0 +1,15 @@ +package fixture.app + +/** + * The half of ROADMAP §13's first defect that `commonMain` alone does not reproduce. + * + * `androidNativeMain` is an **intermediate** source set: `androidNativeArm64Main` and + * `androidNativeX64Main` depend on it, and KSP generates into those two, not into this one. So + * this file can neither name `python.multiplatform.generated.FunctionTable` nor host the `actual` + * that does -- exactly the position `sample`'s `iosMain` is in, which is where the defect was + * found. Before the seam, the only way out was one hand-written `actual` per leaf. + * + * That it compiles for both leaves is the whole assertion; there is no test binary here. + * Verified with `:ksp-fixtures:app:compileKotlinAndroidNativeArm64` and `...X64`. + */ +fun installOnNative(): String = installAndDescribeTable() diff --git a/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/AppExtras.kt b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/AppExtras.kt new file mode 100644 index 000000000..12792a5b0 --- /dev/null +++ b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/AppExtras.kt @@ -0,0 +1,11 @@ +package fixture.app + +/** + * A second source file in the app module. + * + * One module emits one fragment however many files it has, so this proves the scan is per-module + * rather than per-file -- and it is what makes the incremental measurement in + * `docs/design/upcall.md` §2.7 meaningful: with a single-file module every dirty-set + * measurement is trivially 100%. + */ +fun triple(x: Long): Long = x * 3 diff --git a/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/AppFunctions.kt b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/AppFunctions.kt new file mode 100644 index 000000000..ac954be03 --- /dev/null +++ b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/AppFunctions.kt @@ -0,0 +1,5 @@ +package fixture.app + +/** The app module's own exposed surface -- round 1 of `AppProcessor` must emit a fragment for + * this before round 2 can discover it alongside the library's. */ +fun double(x: Long): Long = x * 2 diff --git a/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/ArgumentRelease.kt b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/ArgumentRelease.kt new file mode 100644 index 000000000..598dbec87 --- /dev/null +++ b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/ArgumentRelease.kt @@ -0,0 +1,19 @@ +package fixture.app + +import python.multiplatform.ffi.PyObject + +/** + * Closes the Python object it is handed, inside the upcall (issue #98). + * + * The trampoline wraps a `PyObject` argument with `borrowed = true` (`PyTuple_GetItem` lends the + * item), so the wrapper holds one reference of its own. `close()` gives back exactly that one, and + * the wrapper's cleaner, which runs at most once, has nothing left to release when the JVM later + * collects it. `PyObjectArgumentReleaseTest` calls this 10,000 times and checks the count. + * + * @return 1 when there was an object to close, 0 for `None`. + */ +fun closeArgument(value: PyObject?): Long { + if (value == null) return 0L + value.close() + return 1L +} diff --git a/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TableInstall.kt b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TableInstall.kt new file mode 100644 index 000000000..0c28391a4 --- /dev/null +++ b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TableInstall.kt @@ -0,0 +1,35 @@ +package fixture.app + +import python.multiplatform.reflection.InstallsUpcallTable +import python.multiplatform.reflection.UpcallTable + +/** + * ROADMAP §13's first defect, reproduced here and closed here. + * + * The defect: KSP writes `python.multiplatform.generated.FunctionTable` into the *leaf* target's + * compilation, so this file -- `commonMain`, which every leaf depends on -- cannot name it. + * Written the obvious way, the fixture does not compile: + * + * e: ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TableInstall.kt:4:39 + * Unresolved reference 'FunctionTable'. + * + * observed on `:ksp-fixtures:app:compileKotlinDesktop`. No fixture had this shape before, which + * is why the whole defect only turned up in `sample`: both existing fixtures reached the table + * from `desktopTest`, a *leaf* compilation, where it resolves fine. + * + * The seam: one `expect` here, and `python-multiplatform-ksp` emits the `actual` into every leaf + * it generates a `FunctionTable` for. `:ksp-fixtures:app` has three leaves (`desktop`, + * `androidNativeArm64`, `androidNativeX64`) and not one of them needs a hand-written line. + */ +@InstallsUpcallTable +expect fun installGeneratedUpcallTable() + +/** + * Shared code doing the thing that could not be written before: installing the generated table + * and reading it back, naming nothing that KSP generated. + */ +fun installAndDescribeTable(): String { + installGeneratedUpcallTable() + return "${UpcallTable.callableCount} entries, ${UpcallTable.classCount} classes, " + + "from ${UpcallTable.moduleNames.sorted().joinToString(", ")}" +} diff --git a/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TypedPythonBridge.kt b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TypedPythonBridge.kt new file mode 100644 index 000000000..5b621546f --- /dev/null +++ b/ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TypedPythonBridge.kt @@ -0,0 +1,92 @@ +package fixture.app + +import python.multiplatform.ffi.PyObject +import python.multiplatform.reflection.PythonInternal + +/* + * The Kotlin surface compiled TypedPython code calls through the binder (issue #45). The Python + * side is `src/desktopTest/typedpython/tp_kotlin_bridge.py`; the test is + * `TypedPythonKotlinCallTest`. Every call records itself in [TpBridgeLedger], so the test can + * compare what Kotlin saw from the interpreted run with what it saw from the compiled one. + */ + +/** + * A Kotlin object Python creates through its constructor, receives as a typed `TpSample` proxy, + * calls a method on, and hands back ([tpAccumulate]). + * + * Created by calling the class, not through a factory function: a Kotlin function returning a + * Kotlin object reaches Python as a generic `_PmObject` owner, not as the class's rendered proxy, + * so its methods are not reachable (`PythonProxySource`'s "What is still not rendered" table; the + * first run of this test hit it). The rendered class's own constructor is the path that gives a + * typed proxy today. + */ +class TpSample(val index: Long, val weight: Double) { + init { + TpBridgeLedger.samples += 1 + } + + fun weigh(factor: Double): Double { + TpBridgeLedger.weighs += 1 + return weight * factor + index + } +} + +/** Takes the proxy back (a parameter declared as a Kotlin class, so the binder unwraps it to the object). */ +fun tpAccumulate(sample: TpSample, amount: Double): Double { + TpBridgeLedger.accumulates += 1 + TpBridgeLedger.received += amount + TpBridgeLedger.indexSum += sample.index + return amount / (1.0 + sample.weight) +} + +/** Throws once [value] passes [limit]; the binder turns the throw into a Python `RuntimeError(message)`. */ +fun tpCheck(value: Double, limit: Double): Double { + TpBridgeLedger.checks += 1 + if (value > limit) throw IllegalStateException("tpCheck: $value exceeds $limit") + return value +} + +/** + * Receives a Python object as a [PyObject]. The trampoline wraps it with a reference of its own + * (`borrowed = true`), which the wrapper's cleaner gives back once the Kotlin collector finds the + * wrapper unreachable -- so the token's `sys.getrefcount` is only meaningful after a JVM collection + * and the cleaner have run (`TypedPythonKotlinCallTest.settledCounts`). + * + * Not closed here, so the test also covers the cleaner path. Closing it would be equally correct: + * `close()` gives back the wrapper's one reference and the cleaner then has nothing left to release + * (`closeArgument`, `PyObjectArgumentReleaseTest`). The JVM crash once blamed on that (issue #98) came + * from a test wrapping fake addresses as owned objects, not from the binder. + */ +fun tpTouch(token: PyObject?): Long { + TpBridgeLedger.touches += 1 + return if (token == null) 0L else 1L +} + +/** What Kotlin observed. Not exposed to Python: only the test reads and resets it. */ +@PythonInternal +object TpBridgeLedger { + var samples: Long = 0 + var weighs: Long = 0 + var accumulates: Long = 0 + var checks: Long = 0 + var touches: Long = 0 + + /** Sum of every `amount` [tpAccumulate] received -- a Kotlin-side effect that depends on Python's float math. */ + var received: Double = 0.0 + var indexSum: Long = 0 + + fun reset() { + samples = 0 + weighs = 0 + accumulates = 0 + checks = 0 + touches = 0 + received = 0.0 + indexSum = 0 + } + + /** Every field, `received` as its exact bits, in one comparable string. */ + fun snapshot(): String = + "samples=$samples weighs=$weighs accumulates=$accumulates checks=$checks touches=$touches " + + "received=$received (bits ${received.toRawBits().toULong().toString(16)}) indexSum=$indexSum" +} diff --git a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/CommonInstallTest.kt b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/CommonInstallTest.kt new file mode 100644 index 000000000..e7db0e188 --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/CommonInstallTest.kt @@ -0,0 +1,86 @@ +package fixture.app + +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * ROADMAP §13's first defect, closed: shared code installs the generated table. + * + * Every other test in this module reaches `python.multiplatform.generated.FunctionTable` by name + * from `desktopTest`, which is a *leaf* compilation and can. Nothing here names it -- the call + * goes through `fixture.app.installAndDescribeTable`, which lives in `commonMain` and gets the + * same table. + */ +class CommonInstallTest { + + @BeforeTest + fun startFromAnEmptyTable() { + UpcallTable.clear() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + @Test + fun commonCodeInstallsTheGeneratedTableWithoutNamingIt() { + assertEquals(0, UpcallTable.callableCount, "the table must start empty or this proves nothing") + + val summary = installAndDescribeTable() + + assertTrue(UpcallTable.callableCount > 0, "commonMain installed nothing: $summary") + assertEquals( + setOf("io_github_thisisthepy_ksp_fixtures_library", "io_github_thisisthepy_ksp_fixtures_app"), + UpcallTable.moduleNames, + "the seam must install the same aggregate FunctionTable.fragments does", + ) + assertTrue(UpcallTable.resolve("fixture.library.add").isValid) + assertTrue(UpcallTable.resolve("fixture.app.double").isValid) + assertEquals(7L, UpcallTable.invoke(UpcallTable.resolve("fixture.library.add"), arrayOf(3L, 4L))) + } + + @Test + fun theSeamInstallsExactlyWhatTheGeneratedAggregatorHolds() { + // `installInto()` is `UpcallTable.install(fragments)`, so the two routes must agree + // entry for entry -- if they ever diverge, shared code is looking at a different table + // from the one every leaf-side test asserts against. + installGeneratedUpcallTable() + val viaSeam = UpcallTable.callableCount to UpcallTable.classCount + + UpcallTable.clear() + UpcallTable.install(python.multiplatform.generated.FunctionTable.fragments) + val viaDirectReference = UpcallTable.callableCount to UpcallTable.classCount + + assertEquals(viaDirectReference, viaSeam) + } + + @Test + fun installingThroughTheSeamTwiceDoesNotDoubleTheTable() { + installGeneratedUpcallTable() + val once = UpcallTable.callableCount + installGeneratedUpcallTable() + assertEquals(once, UpcallTable.callableCount) + } + + @Test + fun theGeneratedActualIsNotItselfOfferedToPython() { + // The `actual` is a public top-level function in the user's own package, so an + // incremental round that fed the scanner its own previous output would put it in the + // table -- a Python-callable that reinstalls the table underneath its caller. The + // generator marks it `@PythonInternal` for exactly that reason. + installGeneratedUpcallTable() + assertFalse(UpcallTable.resolve("fixture.app.installGeneratedUpcallTable").isValid) + assertTrue( + UpcallTable.resolve("fixture.app.installAndDescribeTable").isValid, + "the ordinary commonMain function beside it is still exposed -- the exclusion is the annotation, not the file", + ) + } +} diff --git a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedDeclarationKindsTest.kt b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedDeclarationKindsTest.kt new file mode 100644 index 000000000..abe21e012 --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedDeclarationKindsTest.kt @@ -0,0 +1,342 @@ +package fixture.app + +import fixture.library.Color +import fixture.library.Level +import fixture.library.Registry +import fixture.library.RestrictedSetters +import fixture.library.WithCompanion +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.CallableKind +import python.multiplatform.reflection.ClassLookup +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.ReflectedClassKind +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * ROADMAP §7's "companion-object members, interfaces, enums and annotation classes are not + * exposed", closed against the table KSP generated from `ksp-fixtures/library` -- not against a + * hand-written fragment. + * + * Two of these tests assert an *absence* (annotation classes, generics). They are not + * vacuous: each names a declaration that exists in the fixture sources and that the generator + * decided against, and the reason is in [python.multiplatform.ksp.BindingPolicy]. + */ +class GeneratedDeclarationKindsTest { + + @BeforeTest + fun install() { + UpcallTable.install(FunctionTable.fragments) + Registry.size = 0 + WithCompanion.created = 0 + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + private fun kindOf(name: String): CallableKind = UpcallTable.callable(UpcallTable.resolve(name)).kind + + // ------------------------------------------------------------------------ companion objects + + @Test + fun companionMembersAreReachableUnderTheOwningClassNameWithNoReceiver() { + // docs/design/binding-policy.md: "companion object 멤버 -> 클래스의 정적 메서드처럼 노출". + // Python calls WithCompanion.create(7), not WithCompanion.Companion.create(7). + val create = UpcallTable.resolve("fixture.library.WithCompanion.create") + assertEquals(CallableKind.FUNCTION, kindOf("fixture.library.WithCompanion.create")) + + val made = UpcallTable.invoke(create, arrayOf(7L)) + assertEquals(7L, (made as WithCompanion).id) + assertEquals(1L, UpcallTable.invoke(UpcallTable.resolve("fixture.library.WithCompanion.created"), arrayOf())) + + // and the companion itself is not a class of its own + assertNull(ClassLookup.find("fixture.library.WithCompanion.Companion")) + assertTrue(ClassLookup.require("fixture.library.WithCompanion").memberNames.contains("fixture.library.WithCompanion.create")) + } + + @Test + fun aCompanionPropertyIsAStaticAccessorAndAConstIsReadOnly() { + assertEquals(CallableKind.STATIC_GETTER, kindOf("fixture.library.WithCompanion.TAG")) + assertEquals("with-companion", UpcallTable.invoke(UpcallTable.resolve("fixture.library.WithCompanion.TAG"), arrayOf())) + assertFalse(UpcallTable.resolve("fixture.library.WithCompanion.TAG=").isValid, "a const val has no setter") + + val setter = UpcallTable.resolve("fixture.library.WithCompanion.created=") + assertEquals(CallableKind.STATIC_SETTER, kindOf("fixture.library.WithCompanion.created=")) + // args[0] is the value, not a receiver -- the whole reason STATIC_SETTER exists + UpcallTable.invoke(setter, arrayOf(42L)) + assertEquals(42L, WithCompanion.created) + } + + @Test + fun anInstanceMemberAndACompanionMemberDoNotCollideBecauseTheInstanceOneWins() { + // WithCompanion has an `id` property and a companion; nothing in the fixture collides + // today, but the table must hold exactly one entry per name whatever the sources do. + val names = ClassLookup.require("fixture.library.WithCompanion").memberNames + assertEquals(names.distinct(), names) + } + + // -------------------------------------------------------------------------------- objects + + @Test + fun aKotlinObjectExposesItsMembersWithoutAReceiver() { + assertEquals(ReflectedClassKind.OBJECT, ClassLookup.require("fixture.library.Registry").kind) + assertEquals(CallableKind.FUNCTION, kindOf("fixture.library.Registry.ping")) + assertEquals("pong", UpcallTable.invoke(UpcallTable.resolve("fixture.library.Registry.ping"), arrayOf())) + + UpcallTable.invoke(UpcallTable.resolve("fixture.library.Registry.size="), arrayOf(5L)) + assertEquals(5L, Registry.size) + assertEquals(5L, UpcallTable.invoke(UpcallTable.resolve("fixture.library.Registry.size"), arrayOf())) + + assertFalse(UpcallTable.resolve("fixture.library.Registry.").isValid, "a singleton is not constructible") + } + + // ----------------------------------------------------------------------------- interfaces + + @Test + fun anInterfaceExposesItsMembersAndIsNotConstructible() { + // An interface has no instances of its own; its entries exist so a Kotlin object handed + // to Python can be called through the interface even when its concrete class does not + // redeclare the member. PoliteGreeter never declares `greet`. + val greeter = ClassLookup.require("fixture.library.Greeter") + assertEquals(ReflectedClassKind.INTERFACE, greeter.kind) + assertFalse(UpcallTable.resolve("fixture.library.Greeter.").isValid) + + val polite = UpcallTable.invoke(UpcallTable.resolve("fixture.library.PoliteGreeter."), arrayOf("Hi"))!! + val ref = HandleTable.register(polite) + + assertEquals(CallableKind.METHOD, kindOf("fixture.library.Greeter.greet")) + assertEquals( + "Hi, world!", + UpcallTable.invoke(UpcallTable.resolve("fixture.library.Greeter.greet"), arrayOf(HandleTable.require(ref), "world")), + ) + assertEquals( + "Hi", + UpcallTable.invoke(UpcallTable.resolve("fixture.library.Greeter.salutation"), arrayOf(HandleTable.require(ref))), + ) + + assertFalse( + UpcallTable.resolve("fixture.library.PoliteGreeter.greet").isValid, + "an inherited default implementation is reached through the interface, not duplicated", + ) + } + + @Test + fun anInterfaceCompanionIsFoldedIntoTheInterfaceLikeAClassCompanion() { + val polite = UpcallTable.invoke(UpcallTable.resolve("fixture.library.Greeter.polite"), arrayOf()) + assertEquals(CallableKind.FUNCTION, kindOf("fixture.library.Greeter.polite")) + assertEquals("Good day", (polite as fixture.library.Greeter).salutation) + } + + // ---------------------------------------------------------------------------------- enums + + @Test + fun everyEnumEntryIsAStaticAccessorForTheOneInstanceAndTheNamesAreCarriedAsData() { + // The Python side builds an `enum.Enum` mirror at import time from `enumEntryNames` and + // reaches each member through its accessor. `values()`/`entries` are deliberately absent: + // they return collections and the boundary has no collection marshalling. + val color = ClassLookup.require("fixture.library.Color") + assertEquals(ReflectedClassKind.ENUM, color.kind) + assertEquals(listOf("RED", "GREEN", "BLUE"), color.enumEntryNames) + + assertEquals(CallableKind.STATIC_GETTER, kindOf("fixture.library.Color.RED")) + val red = UpcallTable.invoke(UpcallTable.resolve("fixture.library.Color.RED"), arrayOf()) + assertSame(Color.RED, red) + assertSame(red, UpcallTable.invoke(UpcallTable.resolve("fixture.library.Color.RED"), arrayOf())) + + assertFalse(UpcallTable.resolve("fixture.library.Color.values").isValid) + assertFalse(UpcallTable.resolve("fixture.library.Color.entries").isValid) + assertFalse(UpcallTable.resolve("fixture.library.Color.").isValid) + } + + @Test + fun anEnumCarriesNameOrdinalAndValueOfSoPythonCanMapAHandleBackOntoItsMirror() { + val green = UpcallTable.invoke(UpcallTable.resolve("fixture.library.Color.GREEN"), arrayOf())!! + + assertEquals("GREEN", UpcallTable.invoke(UpcallTable.resolve("fixture.library.Color.name"), arrayOf(green))) + assertEquals(1L, UpcallTable.invoke(UpcallTable.resolve("fixture.library.Color.ordinal"), arrayOf(green))) + assertSame(green, UpcallTable.invoke(UpcallTable.resolve("fixture.library.Color.valueOf"), arrayOf("GREEN"))) + assertEquals(CallableKind.FUNCTION, kindOf("fixture.library.Color.valueOf")) + } + + @Test + fun anEnumsOwnConstructorParametersAndMethodsAreInstanceMembers() { + val high = UpcallTable.invoke(UpcallTable.resolve("fixture.library.Level.HIGH"), arrayOf())!! + + assertEquals(CallableKind.GETTER, kindOf("fixture.library.Level.weight")) + assertEquals(10L, UpcallTable.invoke(UpcallTable.resolve("fixture.library.Level.weight"), arrayOf(high))) + + assertEquals(CallableKind.METHOD, kindOf("fixture.library.Level.describe")) + assertEquals("HIGH/10", UpcallTable.invoke(UpcallTable.resolve("fixture.library.Level.describe"), arrayOf(high))) + assertSame(Level.HIGH, high) + } + + @Test + fun pythonInternalExcludesAWholeEnum() { + assertNull(ClassLookup.find("fixture.library.HiddenEnum")) + assertFalse(UpcallTable.resolve("fixture.library.HiddenEnum.A").isValid) + } + + // ---------------------------------------------------------- what is deliberately not there + + @Test + fun annotationClassesAreNotExposedAtAll() { + // Applying an annotation is a compile-time act and reading one back needs runtime + // reflection, which this design cannot have. An instance Python could build would have + // nothing to be attached to. + assertNull(ClassLookup.find("fixture.library.Marker")) + assertFalse(UpcallTable.resolve("fixture.library.Marker.").isValid) + assertFalse(UpcallTable.resolve("fixture.library.Marker.value").isValid) + } + + @Test + fun genericDeclarationsAreNotExposedBecauseTheGeneratedCastHasNoTypeToName() { + assertNull(ClassLookup.find("fixture.library.Box")) + assertFalse(UpcallTable.resolve("fixture.library.Box.").isValid) + assertFalse(UpcallTable.resolve("fixture.library.Box.value").isValid) + assertFalse(UpcallTable.resolve("fixture.library.identity").isValid) + } + + @Test + fun compilerGeneratedDataClassMembersAreNotExposed() { + // KSP does report `copy` and `componentN` for a data class (it does not report `equals`, + // `hashCode` or `toString`); docs/design/binding-policy.md excludes compiler-generated members. + assertNotNull(ClassLookup.find("fixture.library.Point")) + assertTrue(UpcallTable.resolve("fixture.library.Point.x").isValid) + assertFalse(UpcallTable.resolve("fixture.library.Point.copy").isValid) + assertFalse(UpcallTable.resolve("fixture.library.Point.component1").isValid) + } + + // ------------------------------------------------- shapes that used to break generated code + + @Test + fun anAbstractClassExposesItsMembersButNoConstructor() { + assertFalse( + UpcallTable.resolve("fixture.library.AbstractBase.").isValid, + "'Cannot create an instance of an abstract class' -- the generated fragment used to carry one", + ) + val child = UpcallTable.invoke(UpcallTable.resolve("fixture.library.ConcreteChild."), arrayOf())!! + assertEquals("base:child", UpcallTable.invoke(UpcallTable.resolve("fixture.library.AbstractBase.describe"), arrayOf(child))) + } + + @Test + fun aParameterTypeWithTypeArgumentsKeepsThemSoTheFragmentCompiles() { + // `args[0] as kotlin.collections.List` is "One type argument expected" -- this fixture + // failing to compile is what the test is really pinning. + assertEquals(2L, UpcallTable.invoke(UpcallTable.resolve("fixture.library.listSize"), arrayOf(listOf("a", "b")))) + } + + @Test + fun nestedDeclarationsAreExposedUnderTheirOuterName() { + assertEquals(ReflectedClassKind.ENUM, ClassLookup.require("fixture.library.Outer.State").kind) + assertEquals(listOf("ON", "OFF"), ClassLookup.require("fixture.library.Outer.State").enumEntryNames) + assertEquals("nested", UpcallTable.invoke(UpcallTable.resolve("fixture.library.Outer.Nested.hello"), arrayOf())) + } + + @Test + fun topLevelPropertiesAreExposedAsModuleAttributes() { + assertEquals(CallableKind.STATIC_GETTER, kindOf("fixture.library.libraryVersion")) + assertEquals("1.0", UpcallTable.invoke(UpcallTable.resolve("fixture.library.libraryVersion"), arrayOf())) + + UpcallTable.invoke(UpcallTable.resolve("fixture.library.mutableCounter="), arrayOf(3L)) + assertEquals(3L, UpcallTable.invoke(UpcallTable.resolve("fixture.library.mutableCounter"), arrayOf())) + } + + // --------------------------------------------------- a `var` whose setter is not public API + + @Test + fun aVarWithARestrictedSetterIsExposedReadOnly() { + // ROADMAP §13: the scanner branched on `isMutable` alone. All three of these are mutable, + // and none of their setters may appear in the table -- `private`/`protected` because the + // generated assignment does not compile, `internal` because it compiles *only* by the + // accident of the fragment sharing this module's compilation. + for (name in listOf("privateSet", "protectedSet", "internalSet")) { + val getter = "fixture.library.RestrictedSetters.$name" + assertTrue(UpcallTable.resolve(getter).isValid, "the read side must stay exposed: $getter") + assertEquals(CallableKind.GETTER, kindOf(getter)) + assertFalse(UpcallTable.resolve("$getter=").isValid, "a restricted setter must not be in the table: $getter=") + } + + // The control, in the same class: an ordinary `var` still round-trips. + val holder = UpcallTable.invoke(UpcallTable.resolve("fixture.library.RestrictedSetters."), arrayOf())!! + val ref = HandleTable.register(holder) + assertEquals(CallableKind.SETTER, kindOf("fixture.library.RestrictedSetters.openSet=")) + UpcallTable.invoke( + UpcallTable.resolve("fixture.library.RestrictedSetters.openSet="), + arrayOf(HandleTable.require(ref), 40L), + ) + assertEquals(40L, (holder as RestrictedSetters).openSet) + + // and the read side really reads: a value moved by Kotlin is visible through the getter. + val readPrivate = UpcallTable.resolve("fixture.library.RestrictedSetters.privateSet") + assertEquals(1L, UpcallTable.invoke(readPrivate, arrayOf(holder))) + holder.bumpAll() + assertEquals(2L, UpcallTable.invoke(readPrivate, arrayOf(holder))) + } + + @Test + fun aRestrictedSetterIsAbsentFromTheOwningClassesMemberNamesToo() { + // `ReflectedClass.memberNames` is what a Python mirror is built from, so an entry dropped + // from the callable table has to be dropped here as well, or attribute access on the + // mirror resolves to nothing. + val members = ClassLookup.require("fixture.library.RestrictedSetters").memberNames + assertFalse(members.contains("fixture.library.RestrictedSetters.privateSet=")) + assertFalse(members.contains("fixture.library.RestrictedSetters.protectedSet=")) + assertFalse(members.contains("fixture.library.RestrictedSetters.internalSet=")) + assertTrue(members.contains("fixture.library.RestrictedSetters.openSet=")) + for (member in members) { + assertTrue(UpcallTable.resolve(member).isValid, "unresolved generated member: $member") + } + } + + @Test + fun theStaticSetterPathHonoursSetterVisibilityAsWell() { + // Top-level and `object` properties go through `staticPropertyEntries`, which carried the + // same `isMutable`-only branch. "Cannot access 'topLevelPrivateSet': it is private in + // file." was one of the three compile errors this fixture produced before the fix. + assertTrue(UpcallTable.resolve("fixture.library.topLevelPrivateSet").isValid) + assertEquals(CallableKind.STATIC_GETTER, kindOf("fixture.library.topLevelPrivateSet")) + assertFalse(UpcallTable.resolve("fixture.library.topLevelPrivateSet=").isValid) + + assertTrue(UpcallTable.resolve("fixture.library.topLevelOpenSet=").isValid, "the control keeps its setter") + + assertTrue(UpcallTable.resolve("fixture.library.RestrictedRegistry.counted").isValid) + assertFalse(UpcallTable.resolve("fixture.library.RestrictedRegistry.counted=").isValid) + assertFalse( + ClassLookup.require("fixture.library.RestrictedRegistry").memberNames + .contains("fixture.library.RestrictedRegistry.counted="), + ) + } + + @Test + fun everyGeneratedMemberNameOfEveryGeneratedClassResolves() { + // One walk over everything the generator emitted: a member name a class advertises but + // the table cannot resolve would fail at the first Python attribute access instead. + for (className in listOf( + "fixture.library.WithCompanion", + "fixture.library.Registry", + "fixture.library.Greeter", + "fixture.library.Color", + "fixture.library.Level", + "fixture.library.Outer.State", + "fixture.library.Outer.Nested", + "fixture.library.AbstractBase", + "fixture.library.Point", + )) { + val reflected = assertNotNull(ClassLookup.find(className), "missing generated class: $className") + for (member in reflected.memberNames) { + assertTrue(UpcallTable.resolve(member).isValid, "unresolved generated member: $member") + } + } + } +} diff --git a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedStaticPropertyProxyTest.kt b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedStaticPropertyProxyTest.kt new file mode 100644 index 000000000..485cb0c83 --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedStaticPropertyProxyTest.kt @@ -0,0 +1,165 @@ +package fixture.app + +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.CallableKind +import python.multiplatform.reflection.ClassLookup +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * `CallableKind.STATIC_GETTER`/`STATIC_SETTER` from the table KSP actually generated, all the way + * to the Python text that will be `exec`'d for them. + * + * `PythonProxySourceTest` (commonTest) pins the renderer against hand-built entries and + * `PythonProxyInstallTest` (python-multiplatform desktopTest) runs the result in a real CPython. + * Neither says anything about whether the *generator* produces entries of the shape the renderer + * expects. This does, and it needs no interpreter to do it: [PythonProxySource.render] is a pure + * function, and this module has the generated `FunctionTable` but no CPython wiring. + * + * The four owner shapes are asserted separately because the renderer treats them differently, and + * the difference is the whole design decision (see [PythonProxySource]'s "Static surfaces"): + * a member of a class that is itself rendered as a Python `class` goes on that class's metaclass; + * everything else becomes a live attribute of a module. + */ +class GeneratedStaticPropertyProxyTest { + + @BeforeTest + fun install() { + UpcallTable.install(FunctionTable.fragments) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + private fun render(): String = PythonProxySource.render(UpcallTable.entries(), ClassLookup.all()) + + private fun assertRegisters(source: String, module: String, name: String, writable: Boolean) { + val setter = if (writable) "_pm_h_\\d+" else "None" + val pattern = Regex( + "_pm_static_property\\(_pm_module\\('${Regex.escape(module)}'\\), " + + "'${Regex.escape(name)}', _pm_h_\\d+, $setter\\)", + ) + assertTrue( + pattern.containsMatchIn(source), + "no ${if (writable) "read/write" else "read-only"} registration for $module.$name in:\n$source", + ) + } + + @Test + fun theGeneratorEmitsStaticEntriesAtAllForEveryOwnerShapeTheRendererDistinguishes() { + // Before asserting where they land, assert they exist: a renderer that handles a kind the + // generator never emits proves nothing. + val kinds = UpcallTable.entries().associate { it.name to it.kind } + assertEquals(CallableKind.STATIC_GETTER, kinds["fixture.library.libraryVersion"], "top-level val") + assertEquals(CallableKind.STATIC_GETTER, kinds["fixture.library.mutableCounter"], "top-level var") + assertEquals(CallableKind.STATIC_SETTER, kinds["fixture.library.mutableCounter="], "top-level var setter") + assertEquals(CallableKind.STATIC_GETTER, kinds["fixture.library.WithCompanion.TAG"], "companion const val") + assertEquals(CallableKind.STATIC_GETTER, kinds["fixture.library.WithCompanion.created"], "companion var") + assertEquals(CallableKind.STATIC_SETTER, kinds["fixture.library.WithCompanion.created="], "companion var setter") + assertEquals(CallableKind.STATIC_GETTER, kinds["fixture.library.Registry.size"], "object var") + assertEquals(CallableKind.STATIC_SETTER, kinds["fixture.library.Registry.size="], "object var setter") + assertEquals(CallableKind.STATIC_GETTER, kinds["fixture.library.Color.RED"], "enum entry") + } + + @Test + fun aTopLevelPropertyBecomesALiveAttributeOfItsPackageModule() { + val source = render() + + assertContains(source, "_pm_lookup('fixture.library.mutableCounter')") + assertContains(source, "_pm_lookup('fixture.library.mutableCounter=')") + assertRegisters(source, "fixture.library", "mutableCounter", writable = true) + assertRegisters(source, "fixture.library", "libraryVersion", writable = false) + } + + @Test + fun aCompanionPropertyOfARenderedClassGoesOnThatClassesMetaclass() { + val source = render() + + val metaclass = Regex("class (_pm_t_\\d+)\\(type\\):\\n(?:.|\\n)*?class WithCompanion\\(metaclass=\\1\\):") + .find(source) + assertTrue(metaclass != null, "WithCompanion's statics need a metaclass on the class itself:\n$source") + + assertContains(source, " def TAG(cls):") + assertContains(source, " def created(cls):") + assertContains(source, " @created.setter") + assertContains(source, " def created(cls, a0):") + assertFalse( + source.contains("_pm_static_property(_pm_module('fixture.library.WithCompanion')"), + "a rendered class owns its statics; publishing them on a module of the same name would " + + "put them somewhere the class rendering then overwrites", + ) + } + + @Test + fun anObjectsPropertyStaysOnTheModuleItsOwnFunctionsAlreadyUse() { + val source = render() + + // `Registry.ping` is a FUNCTION entry named `fixture.library.Registry.ping`, so it has + // always been published into a module named after the object. `Registry.size` has to + // follow it there or the two would resolve against different Python objects. + assertContains(source, "setattr(_pm_module('fixture.library.Registry'), 'ping', ") + assertRegisters(source, "fixture.library.Registry", "size", writable = true) + assertRegisters(source, "fixture.library.Registry", "label", writable = false) + } + + @Test + fun anEnumEntryLandsAsAModuleAttributeBecauseAnEnumIsNotRenderedAsAClass() { + val source = render() + + assertRegisters(source, "fixture.library.Color", "RED", writable = false) + assertRegisters(source, "fixture.library.Color", "BLUE", writable = false) + } + + @Test + fun aCompanionFunctionOfARenderedClassGoesOnTheSameMetaclassItsCompanionPropertiesDo() { + // The other half of a companion, and the one that used to be lost: KSP emits + // `WithCompanion.create` as a `CallableKind.FUNCTION`, which is by name indistinguishable + // from `Registry.ping` -- an `object`'s function, which must stay on its module. Only the + // `ReflectedClassKind` tells them apart, so both are asserted here against the real table. + val kinds = UpcallTable.entries().associate { it.name to it.kind } + assertEquals(CallableKind.FUNCTION, kinds["fixture.library.WithCompanion.create"], "companion fun") + assertEquals(CallableKind.FUNCTION, kinds["fixture.library.Greeter.polite"], "interface companion fun") + + val source = render() + + assertContains(source, " def create(cls, a0):") + assertContains(source, " def polite(cls):") + assertFalse( + source.contains("_pm_module('fixture.library.WithCompanion')"), + "a module of that name is exactly what the class rendering overwrites:\n$source", + ) + assertFalse(source.contains("_pm_module('fixture.library.Greeter')")) + // and the same metaclass carries the companion's properties: one companion, one type object + val metaclass = Regex("class (_pm_t_\\d+)\\(type\\):\\n(?:.|\\n)*?class WithCompanion\\(metaclass=\\1\\):") + .find(source) + assertTrue(metaclass != null, "WithCompanion needs one metaclass for both halves:\n$source") + assertContains(metaclass.value, " def create(cls, a0):") + assertContains(metaclass.value, " def TAG(cls):") + } + + @Test + fun aVarWhoseSetterIsNotPublicApiRendersReadOnlyRatherThanGainingOneBack() { + // ROADMAP §13's rule, carried through to the last stage: the table has no STATIC_SETTER + // entry for these, and this stage must not invent one. What it emits instead is a + // registration whose setter slot is `None`, which raises on assignment. + val source = render() + + assertFalse(UpcallTable.resolve("fixture.library.topLevelPrivateSet=").isValid, "premise") + assertRegisters(source, "fixture.library", "topLevelPrivateSet", writable = false) + assertRegisters(source, "fixture.library", "topLevelOpenSet", writable = true) + + assertFalse(UpcallTable.resolve("fixture.library.RestrictedRegistry.counted=").isValid, "premise") + assertRegisters(source, "fixture.library.RestrictedRegistry", "counted", writable = false) + } +} diff --git a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedSuspendTest.kt b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedSuspendTest.kt new file mode 100644 index 000000000..49ba2299f --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedSuspendTest.kt @@ -0,0 +1,353 @@ +package fixture.app + +import fixture.library.SuspendingService +import fixture.library.ValueHandler +import python.multiplatform.ffi.upcall.PendingCall +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.CallableKind +import python.multiplatform.reflection.ClassLookup +import python.multiplatform.reflection.ExposedCallable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.TypeTag +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What the generator does with `suspend`, pinned against the table KSP actually produced from + * `ksp-fixtures/library/.../Suspending.kt`. + * + * The answer used to be **silently skipped** -- `BindingPolicy.isExposedFunctionShape` dropped any + * declaration carrying `Modifier.SUSPEND`, so nothing was emitted and nothing was logged, and a + * user exposing a `suspend fun` got an `AttributeError` from Python with no indication that the + * function had been seen and rejected. `docs/design/upcall.md` §5.4 recorded that as + * deliberate: the silence was held in place so that the day a convention landed, the change would + * show up as these tests failing rather than as one silence quietly becoming another. + * + * It has landed. A `suspend fun` now gets an ordinary entry whose body is + * `PendingCall.start { ... }` and which carries [ExposedCallable.isSuspend]; the trampoline reads + * that flag and either hands back the real value (the body never suspended) or an `asyncio.Future` + * (it did). So what every assertion below checks is the *presence* of a shape rather than the + * absence of one -- and the controls that used to guard against "the whole scope was dropped" are + * still here, now guarding the opposite mistake: that suspending and non-suspending declarations + * are not being given the same treatment. + * + * Nothing here touches CPython. The entry's shape and its `PendingCall` are checkable in plain + * Kotlin; whether `await` works is `AsyncUpcallDeliveryTest` in `python-multiplatform`. + * + * A `suspend` *type* (`val h: suspend (Long) -> Long`) is a different question from a `suspend + * fun` -- `BindingPolicy.isSuspending` reads `Modifier.SUSPEND` on a *declaration*, and a type + * usage carries no such modifier -- and it used to have a worse answer than the one above: not + * silently skipped, but silently *let through* as an `OBJECT` handle whose generated cast named a + * classifier (`kotlin.coroutines.SuspendFunction1<...>`) with no source declaration anywhere in + * the compilation and no entry of its own in the table. It compiled and the runtime checkcast + * passed, so nothing caught it -- worse than the `suspend fun` silence, because that one at least + * surfaced as a KSP-detectable shape rather than a classifier that happened to satisfy the + * compiler. That question also has a landed answer now: `BindingPolicy.hasExposableTypes` + * (`KSType.isSuspendFunctionType`, recursing through generic arguments and one level of + * `typealias`) rejects it at every point a type enters the table, and the tests from + * `aSuspendingFunctionTypeHasNoEntryAsATopLevelPropertyParameterOrReturn` onward pin the + * *absence* this time, the same way the tests above pin the `suspend fun` entry's *presence*. + */ +class GeneratedSuspendTest { + + @BeforeTest + fun install() { + UpcallTable.install(FunctionTable.fragments) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + private fun entry(name: String): ExposedCallable { + val handle = UpcallTable.resolve(name) + assertTrue(handle.isValid, "$name is not in the table") + return UpcallTable.callable(handle) + } + + /** Calls through the table the way the trampoline does, and asserts the shape it gets back. */ + private fun startCall(name: String, vararg args: Any?): PendingCall { + val result = UpcallTable.invoke(UpcallTable.resolve(name), arrayOf(*args)) + assertNotNull(result, "$name returned nothing") + assertTrue(result is PendingCall, "a suspending entry must hand back a PendingCall, got $result") + return result + } + + // ------------------------------------------------------------ the entry, shape by shape + + @Test + fun aSuspendingTopLevelFunctionIsInTheTableAlongsideItsControl() { + val suspending = entry("fixture.library.suspendingTopLevel") + assertTrue(suspending.isSuspend, "the trampoline branches on this and nothing else") + assertEquals(CallableKind.FUNCTION, suspending.kind) + assertEquals(1, suspending.arity) + assertEquals(listOf(TypeTag.INT), suspending.paramTypes) + // The *declared* return type, not the PendingCall that actually comes back: the fast path + // marshals the real value with this tag, and so does the Future's set_result. + assertEquals(TypeTag.INT, suspending.returnType) + + val control = entry("fixture.library.blockingTopLevel") + assertFalse(control.isSuspend, "a non-suspending function must not be given the async path") + assertEquals(4L, UpcallTable.invoke(UpcallTable.resolve("fixture.library.blockingTopLevel"), arrayOf(2L))) + } + + @Test + fun aSuspendingMemberKeepsItsReceiverSlotAndItsClassStaysConstructible() { + val service = UpcallTable.invoke(UpcallTable.resolve("fixture.library.SuspendingService."), arrayOf(7L)) + assertEquals(7L, (service as SuspendingService).id) + + val fetch = entry("fixture.library.SuspendingService.fetch") + assertTrue(fetch.isSuspend) + // `suspend` and "has a receiver" are orthogonal, which is why the flag is not a + // CallableKind: this is still a METHOD, and args[0] is still the instance. + assertEquals(CallableKind.METHOD, fetch.kind) + assertEquals(2, fetch.expectedArgCount) + assertEquals(TypeTag.STRING, fetch.returnType) + + assertEquals( + "sync:k", + UpcallTable.invoke( + UpcallTable.resolve("fixture.library.SuspendingService.fetchBlocking"), + arrayOf(service, "k"), + ), + ) + } + + @Test + fun aSuspendingCompanionMemberJoinsTheOwnersStaticSurface() { + val create = entry("fixture.library.SuspendingService.create") + assertTrue(create.isSuspend) + assertEquals(CallableKind.FUNCTION, create.kind) + assertEquals(TypeTag.OBJECT, create.returnType) + + val made = UpcallTable.invoke(UpcallTable.resolve("fixture.library.SuspendingService.createBlocking"), arrayOf(3L)) + assertEquals(3L, (made as SuspendingService).id) + } + + @Test + fun aSuspendingInterfaceMemberIsExposedLikeAnyOtherMethod() { + assertNotNull(ClassLookup.find("fixture.library.SuspendingSource")) + val load = entry("fixture.library.SuspendingSource.load") + assertTrue(load.isSuspend) + assertEquals(CallableKind.METHOD, load.kind) + assertEquals(1, load.expectedArgCount, "no declared parameters, but the receiver is still a slot") + + assertEquals(CallableKind.METHOD, entry("fixture.library.SuspendingSource.describe").kind) + assertFalse(entry("fixture.library.SuspendingSource.describe").isSuspend) + } + + @Test + fun aSuspendingObjectMemberJoinsTheSingletonsSurface() { + val ping = entry("fixture.library.SuspendingRegistry.ping") + assertTrue(ping.isSuspend) + assertEquals(CallableKind.FUNCTION, ping.kind) + assertEquals(0, ping.expectedArgCount) + + assertEquals("pong", UpcallTable.invoke(UpcallTable.resolve("fixture.library.SuspendingRegistry.pingBlocking"), arrayOf())) + } + + @Test + fun everySuspendingMemberIsAdvertisedOnItsClassAndResolves() { + // `ReflectedClass.memberNames` is what a Python mirror is built from. A member missing + // from it is invisible to Python however well the table entry is formed -- which is the + // shape the old skip took, and the thing that had to change with it. + for ((className, suspending, control) in listOf( + Triple("fixture.library.SuspendingService", "fetch", "fetchBlocking"), + Triple("fixture.library.SuspendingSource", "load", "describe"), + Triple("fixture.library.SuspendingRegistry", "ping", "pingBlocking"), + )) { + val members = ClassLookup.require(className).memberNames + assertTrue(members.contains("$className.$suspending"), "$className.$suspending must be advertised") + assertTrue(members.contains("$className.$control"), "$className.$control must stay advertised") + for (member in members) { + assertTrue(UpcallTable.resolve(member).isValid, "unresolved generated member: $member") + } + } + } + + // ------------------------------------------------- the generated body, run without an interpreter + + @Test + fun theGeneratedBodyStartsTheCoroutineAndParksItsOutcomeInAPendingCall() { + // `PendingCall.start { suspendingTopLevel(args[0] as Long) }` is what the generator emits. + // Nothing in that fixture reaches a suspension point, so `docs/design/upcall.md` §5.2's + // fast path applies: the call is already complete before `start` returned, and the boundary + // can hand Python a real `int` with no Future and no event loop. + val call = startCall("fixture.library.suspendingTopLevel", 21L) + + assertTrue(call.isDone, "a body with no suspension point completes on the calling thread") + assertEquals(42L, call.value) + assertNull(call.failure) + } + + @Test + fun aSuspendingMethodsGeneratedBodyReadsItsReceiverFromArgsZero() { + val service = UpcallTable.invoke( + UpcallTable.resolve("fixture.library.SuspendingService."), + arrayOf(1L), + ) as SuspendingService + + val call = startCall("fixture.library.SuspendingService.fetch", service, "k") + + assertTrue(call.isDone) + assertEquals("async:k", call.value) + } + + @Test + fun aSuspendingCompanionMemberReturnsARealKotlinObjectThroughThePendingCall() { + val call = startCall("fixture.library.SuspendingService.create", 9L) + + assertTrue(call.isDone) + assertEquals(9L, (call.value as SuspendingService).id) + } + + // -------------------------------------------------- a suspending *type* is a different answer + // + // This whole section used to pin the opposite: a `suspend` function *type* used as a + // property, parameter or return type carries no `Modifier.SUSPEND` (that lives on a + // *declaration*), so `BindingPolicy.isSuspending` never saw it, and the generated cast + // (`args[0] as kotlin.coroutines.SuspendFunction1<...>`) compiled and checkcast cleanly + // anyway -- a classifier with no source declaration in this compilation and no `invoke` entry + // in the table, so what crossed was a handle Python could hold and hand back and nothing + // else. `docs/design/upcall.md` §5.3 measured that as deliberately left open. + // + // It is closed now: `BindingPolicy.hasExposableTypes` rejects any property, parameter or + // return type where `KSType.isSuspendFunctionType` is true (recursing into type arguments and + // through one level of `typealias`), and `FragmentScanner` applies it at every point a type + // can enter the table -- top-level and member functions, the primary constructor, and + // properties at every scope (top-level, member, companion). Every test below asserts a + // *rejection*: no table entry, and a KSP warning was observed for it + // (`grep "has a type with no exposable classifier" build output`) rather than the old silence. + + @Test + fun aSuspendingFunctionTypeHasNoEntryAsATopLevelPropertyParameterOrReturn() { + assertFalse(UpcallTable.resolve("fixture.library.suspendingHandler").isValid) + assertFalse(UpcallTable.resolve("fixture.library.runsHandler").isValid) + assertFalse(UpcallTable.resolve("fixture.library.makeHandler").isValid) + } + + @Test + fun aSuspendingFunctionTypeNestedInAGenericTypeArgumentHasNoEntry() { + // `List Unit>` renders its cast as + // args[0] as kotlin.collections.List> + // -- the same unusable classifier one level down. `isExposableType` has to recurse into + // type arguments to catch it; the outer `List` on its own would be a renderable (if inert) + // `OBJECT` handle, a separate and older limitation this task does not touch. + assertFalse(UpcallTable.resolve("fixture.library.handlerList").isValid) + assertFalse(UpcallTable.resolve("fixture.library.runsHandlerList").isValid) + } + + @Test + fun aNullableSuspendingFunctionTypeHasNoEntry() { + assertFalse(UpcallTable.resolve("fixture.library.nullableHandler").isValid) + } + + @Test + fun aTypealiasForASuspendingFunctionTypeHasNoEntryEitherSideOfTheAlias() { + // Measured, not assumed: `KSType.isSuspendFunctionType` answers `false` for the + // alias-typed `KSType` itself (`KSType.declaration` is the `KSTypeAlias`, not the + // expansion), so `isExposableType` has to expand one level of `typealias` before asking. + // Without that expansion, `runsAliasedHandler`'s cast would target + // `fixture.library.LongHandler` -- a real, source-declared classifier this time, since the + // `typealias` is itself a declaration in this compilation -- and it would have passed + // every check while remaining exactly as uncallable underneath. + assertFalse(UpcallTable.resolve("fixture.library.aliasedHandler").isValid) + assertFalse(UpcallTable.resolve("fixture.library.runsAliasedHandler").isValid) + } + + @Test + fun aSuspendingFunctionTypeHasNoEntryAsAMemberPropertyParameterOrReturn() { + assertFalse(UpcallTable.resolve("fixture.library.SuspendingService.handler").isValid) + assertFalse(UpcallTable.resolve("fixture.library.SuspendingService.runsHandlerMember").isValid) + assertFalse(UpcallTable.resolve("fixture.library.SuspendingService.makesHandlerMember").isValid) + } + + @Test + fun aSuspendingFunctionTypeHasNoEntryAsACompanionProperty() { + // A companion member is folded onto the owner's static surface through + // `FragmentScanner.companionEntries`, a separate code path from the member-property one + // the test above exercises -- both needed their own wiring to `hasExposableTypes`. + assertFalse(UpcallTable.resolve("fixture.library.SuspendingService.companionHandler").isValid) + } + + @Test + fun aSuspendingFunctionTypeHasNoEntryAsAPrimaryConstructorParameter() { + // The primary constructor does not route through + // `BindingPolicy.isExposedMemberFunction`/`isExposedTopLevelFunction` the way every other + // function entry does -- `FragmentScanner` selects it directly -- so this is the one + // location that needed its own, separate call to `hasExposableTypes` rather than + // inheriting the check through a shared gate. + assertFalse(UpcallTable.resolve("fixture.library.ValueHandler.").isValid) + assertFalse(UpcallTable.resolve("fixture.library.ValueHandler.f").isValid) + } + + @Test + fun aClassWhoseOnlyMembersAreUnexposableStaysExposedWithAnEmptySurface() { + // `ValueHandler`'s constructor and its one property are both this shape, so nothing of it + // is left in the table -- but the class declaration itself is unaffected: it is still a + // renderable classifier (`runsValueHandler` casts a parameter to it below), the same way a + // class with only `suspend fun` members stayed exposed before this task + // (`aSuspendingMemberKeepsItsReceiverSlotAndItsClassStaysConstructible`). + val cls = ClassLookup.require("fixture.library.ValueHandler") + assertTrue(cls.memberNames.isEmpty()) + } + + @Test + fun everyMemberOfSuspendingServiceThatIsThisShapeIsMissingFromItsAdvertisedSurface() { + // The negative counterpart to `everySuspendingMemberIsAdvertisedOnItsClassAndResolves`: + // an excluded member must not leak into `ReflectedClass.memberNames` either, or a Python + // mirror built from that list would still advertise a name with no entry behind it. + val members = ClassLookup.require("fixture.library.SuspendingService").memberNames + assertFalse(members.contains("fixture.library.SuspendingService.handler")) + assertFalse(members.contains("fixture.library.SuspendingService.runsHandlerMember")) + assertFalse(members.contains("fixture.library.SuspendingService.makesHandlerMember")) + assertFalse(members.contains("fixture.library.SuspendingService.companionHandler")) + // and the members that survive are still exactly the ones earlier tests in this file + // already pin the shape of -- this only asserts they are still present alongside the gap. + assertTrue(members.contains("fixture.library.SuspendingService.fetch")) + assertTrue(members.contains("fixture.library.SuspendingService.create")) + } + + @Test + fun anOrdinaryNonSuspendingFunctionTypeIsUnaffectedAndStaysAnObjectHandle() { + // The control: only `suspend` function types are unrenderable. An ordinary + // `(Long) -> Long` has a real, source-declared `kotlin.Function1` behind it and is exactly + // as exposable as it was before this task -- an opaque but legitimate `OBJECT` handle. + val getter = UpcallTable.resolve("fixture.library.plainHandler") + assertTrue(getter.isValid, "a non-suspending function type must not be caught by this check") + assertEquals(TypeTag.OBJECT, UpcallTable.callable(getter).returnType) + assertEquals(CallableKind.STATIC_GETTER, UpcallTable.callable(getter).kind) + } + + @Test + fun aValueClassWrappingAnUnexposableTypeIsStillAnOrdinaryParameterElsewhere() { + // The control for the constructor case: what is unexposable lives *inside* `ValueHandler`, + // not in the classifier itself. Casting a parameter *to* `ValueHandler` -- as any other + // already-exposed class would be -- is unaffected. + val consumer = UpcallTable.resolve("fixture.library.runsValueHandler") + assertTrue(consumer.isValid) + assertEquals(listOf(TypeTag.OBJECT), UpcallTable.callable(consumer).paramTypes) + + val handle = ValueHandler { it + 1 } + val described = UpcallTable.invoke(consumer, arrayOf(handle)) + assertTrue((described as String).isNotEmpty()) + } + + @Test + fun theRejectedClassifierHasNoInvokeEntryEitherWayConsistentWithTheOldMeasurement() { + // Even where a suspending-type handle *did* used to cross (the old `suspendingHandler` + // path this file no longer exercises), nothing ever exposed `SuspendFunction1.invoke` -- + // `docs/design/upcall.md` §5.3's point stands regardless of which side of this fix + // it is read from. + assertFalse(UpcallTable.resolve("kotlin.coroutines.SuspendFunction1.invoke").isValid) + } +} diff --git a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedTableTest.kt b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedTableTest.kt new file mode 100644 index 000000000..1be5539ff --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/GeneratedTableTest.kt @@ -0,0 +1,178 @@ +package fixture.app + +import fixture.library.Counter +import fixture.library.RefHolder +import python.multiplatform.ffi.Python3 +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.ClassLookup +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import python.native.ffi.toRawValue +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * This is the whole point of ROADMAP §7's generator half: everything + * `python.multiplatform.reflection.UpcallTableTest` proves against hand-written fragments in + * `TestFragments.kt`, proved here against a `FunctionTable` that KSP actually generated from + * `ksp-fixtures/library`'s and this module's own sources -- no hand-written fragment anywhere in + * this file. + */ +class GeneratedTableTest { + + @BeforeTest + fun install() { + UpcallTable.install(FunctionTable.fragments) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + @Test + fun everyModuleParticipatesInTheAggregatedTable() { + // Proves the two-round aggregation: the app's own fragment (round 1) and the library's + // (found on the compiled classpath in round 2) both land in one FunctionTable. + // + // The names are what `python-multiplatform-gradle-plugin` derives from each module's + // group and path -- neither fixture states a module name any more, and the group is in + // there because every fragment object in every artifact shares one package. + assertEquals( + setOf("io_github_thisisthepy_ksp_fixtures_library", "io_github_thisisthepy_ksp_fixtures_app"), + UpcallTable.moduleNames, + ) + assertTrue(UpcallTable.resolve("fixture.library.add").isValid) + assertTrue(UpcallTable.resolve("fixture.app.double").isValid) + assertTrue(UpcallTable.resolve("fixture.app.triple").isValid, "every file in a module feeds its one fragment") + } + + @Test + fun topLevelFunctionsInvokeAndReturnCorrectly() { + val add = UpcallTable.resolve("fixture.library.add") + assertEquals(7L, UpcallTable.invoke(add, arrayOf(3L, 4L))) + + val greet = UpcallTable.resolve("fixture.library.greet") + assertEquals("Hello, world!", UpcallTable.invoke(greet, arrayOf("world"))) + + val double = UpcallTable.resolve("fixture.app.double") + assertEquals(20L, UpcallTable.invoke(double, arrayOf(10L))) + } + + @Test + fun narrowerIntParametersAndReturnsRoundTripThroughTheBoundaryLongRepresentation() { + // fun scale(value: Int, by: Int): Int -- the generator must narrow the Long the boundary + // hands it down to Int for the call, then widen the Int result back to Long. + val scale = UpcallTable.resolve("fixture.library.scale") + assertTrue(scale.isValid) + + val result = UpcallTable.invoke(scale, arrayOf(6L, 7L)) + + assertEquals(42L, result) + assertTrue(result is Long, "an Int-returning function must still cross as the boundary's Long") + } + + @Test + fun unitReturningFunctionCrossesAsUnit() { + val noArgs = UpcallTable.resolve("fixture.library.noArgs") + assertEquals(Unit, UpcallTable.invoke(noArgs, arrayOf())) + } + + @Test + fun pythonInternalExcludesTheAnnotatedFunctionFromTheTable() { + assertFalse(UpcallTable.resolve("fixture.library.hiddenFromPython").isValid) + } + + @Test + fun pythonInternalOnAWholeClassExcludesItAndItsMembers() { + assertFalse(UpcallTable.resolve("fixture.library.HiddenClass.").isValid) + assertFalse(UpcallTable.resolve("fixture.library.HiddenClass.shouldNeverBeReachable").isValid) + assertNull(ClassLookup.find("fixture.library.HiddenClass")) + } + + @Test + fun pythonInternalOnOneMethodExcludesOnlyThatMethod() { + assertFalse(UpcallTable.resolve("fixture.library.Counter.hiddenMethod").isValid) + assertTrue(UpcallTable.resolve("fixture.library.Counter.increment").isValid) + } + + @Test + fun aGeneratedClassRoundTripsConstructMethodGetterAndSetterThroughTheHandleTable() { + // The exact end-to-end shape UpcallTableTest.aMethodCallGoesHandleTableThenCallableTable + // pins against the hand-written TestAppFragment -- here it is the generated one. + val construct = UpcallTable.resolve("fixture.library.Counter.") + val counter = UpcallTable.invoke(construct, arrayOf(10L))!! + val ref = HandleTable.register(counter) + + val increment = UpcallTable.resolve("fixture.library.Counter.increment") + val incremented = UpcallTable.invoke(increment, arrayOf(HandleTable.require(ref), 5L)) + assertEquals(15L, incremented) + + val label = UpcallTable.resolve("fixture.library.Counter.label") + assertEquals("n=15", UpcallTable.invoke(label, arrayOf(HandleTable.require(ref), "n="))) + + val getter = UpcallTable.resolve("fixture.library.Counter.count") + assertEquals(15L, UpcallTable.invoke(getter, arrayOf(HandleTable.require(ref)))) + + val setter = UpcallTable.resolve("fixture.library.Counter.count=") + UpcallTable.invoke(setter, arrayOf(HandleTable.require(ref), 99L)) + assertEquals(99L, (counter as Counter).count) + + assertTrue(HandleTable.release(ref)) + assertNull(HandleTable.resolve(ref)) + } + + @Test + fun everyGeneratedClassMemberNameResolvesInTheCallableTable() { + val counter = assertNotNull(ClassLookup.find("fixture.library.Counter")) + for (member in counter.memberNames) { + assertTrue(UpcallTable.resolve(member).isValid, "unresolved generated member: $member") + } + } + + @Test + fun aClassWithoutPyObjectFieldsReportsNoTraverse() { + assertFalse(ClassLookup.require("fixture.library.Counter").hasTraverse) + } + + @Test + fun aClassWithPyObjectFieldsGetsAGeneratedTraverseThatVisitsTheirRawPointers() { + // docs/design/object-lifetime.md's "Cycle collection is part of the table's job": the generator + // must find PyObject-typed fields on RefHolder and emit a traverse function that visits + // their pointer value, with no reflection at runtime. + assertTrue(ClassLookup.require("fixture.library.RefHolder").hasTraverse) + + // Real objects, not made-up addresses. A `PyObject` registers a cleaner that calls + // `Py_DecRef(pointer)` once the JVM collects it, whatever the test meant by it. This used to + // wrap 0xAAAA and 0xBBBB with `borrowed = false`; the test passed, and the first collection + // after it in the same JVM -- any test that ran 10,000 upcalls or called System.gc() -- + // died in `Py_XDECREF` on the Cleaner thread with si_addr 0xaaaa (issue #98). + // + // `PyObject_CallNoArgs` returns a new reference, and `invoke()` builds the wrapper with + // `borrowed = false`, so each wrapper owns exactly the reference its cleaner gives back. + Python3.initialize(silent = true) + val objectType = Python3.import("builtins").getAttr("object") + val primary = objectType.invoke() + val secondary = objectType.invoke() + val primaryAddress = primary.pointer.toRawValue() + val secondaryAddress = secondary.pointer.toRawValue() + val holder = RefHolder(primary, secondary) + + val visited = mutableListOf() + ClassLookup.require("fixture.library.RefHolder").traverse(holder) { visited.add(it) } + + assertEquals(listOf(primaryAddress, secondaryAddress), visited) + + holder.primary = null + visited.clear() + ClassLookup.require("fixture.library.RefHolder").traverse(holder) { visited.add(it) } + assertEquals(listOf(secondaryAddress), visited) + } +} diff --git a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/KspClassResultProxyTest.kt b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/KspClassResultProxyTest.kt new file mode 100644 index 000000000..5c3881ec7 --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/KspClassResultProxyTest.kt @@ -0,0 +1,80 @@ +package fixture.app + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * #94 (SPEC U-11): a KSP function or method returning a Kotlin class gives that class's rendered + * proxy, not a generic `_PmObject`. Ownership is not what is under test here. + * + * Red before the fix: `isinstance(_c, Counter)` is False and `_c.increment` raises + * `AttributeError: '_PmObject' object has no attribute 'increment'`. + */ +class KspClassResultProxyTest { + + @BeforeTest + fun install() { + Python3.initialize(silent = true) + UpcallTable.clear() + HandleTable.releaseAll() + UpcallTable.install(FunctionTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + } + + @AfterTest + fun cleanup() { + Python3.exec("_c = None\n_t = None\nimport gc\ngc.collect()") + UpcallTable.clear() + HandleTable.releaseAll() + } + + @Test + fun aTopLevelFunctionReturningAClassGivesTheClassProxy() { + Python3.exec( + """ + from fixture.library import Counter, makeCounter + _c = makeCounter(5) + assert isinstance(_c, Counter), 'expected a Counter proxy, got ' + repr(type(_c)) + assert _c.increment(2) == 7, 'method call on the result failed' + assert _c.count == 7, 'property read on the result failed; got ' + repr(_c.count) + assert _c.label('n=') == 'n=7' + """.trimIndent(), + ) + } + + @Test + fun aMethodReturningAClassGivesTheClassProxy() { + Python3.exec( + """ + from fixture.library import Counter + _c = Counter(4) + _t = _c.twin() + assert isinstance(_t, Counter), 'expected a Counter proxy, got ' + repr(type(_t)) + assert _t is not _c + assert _t.increment(1) == 5 + assert _c.count == 4, 'the twin must be a separate Kotlin object' + """.trimIndent(), + ) + } + + /** A result is itself a proxy, so a method on it can return another. */ + @Test + fun aReturnedProxyCanBeChained() { + Python3.exec( + """ + from fixture.library import makeCounter + _c = makeCounter(1) + _c2 = _c.twin() + assert _c2.count == 1 + """.trimIndent(), + ) + } +} diff --git a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/ProxyObjectArgumentTest.kt b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/ProxyObjectArgumentTest.kt new file mode 100644 index 000000000..27a0fb83d --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/ProxyObjectArgumentTest.kt @@ -0,0 +1,118 @@ +package fixture.app + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * Which `TypeTag.OBJECT` arguments `_pm_unwrap` may unwrap, decided by the **declared** Kotlin + * type rather than by the shape of the value. + * + * `99acd830` filed this and left it: the generated proxy wrapped every `TypeTag.OBJECT` argument + * in `_pm_unwrap`, which turns any `_PmObject`-derived value into its raw `HandleTable` handle. + * That is right for the chain shape `OwnedResultLifetimeTest` pins -- `size(padding(m, 16.0), + * 24.0)`, where the argument really is a Kotlin object and the callee's parameter is declared as + * one -- and wrong for a parameter declared `PyObject`, where the callee wants the Python object + * itself. `UpcallTrampoline.toKotlinObject` reads an `int` as a handle and anything else as a + * `PyObject`, so an unwrapped proxy reaches such a parameter as the Kotlin object behind it and + * fails the entry's own cast. + * + * `fixture.library.RefHolder(var primary: PyObject?, ...)` is the shape, and `_rh.primary = _rh` + * is the assignment: a real KSP-generated `SETTER` whose one parameter is declared `PyObject`. + * + * The two tests are deliberately the two sides of one decision. Fixing the first one by dropping + * `_pm_unwrap` altogether would pass it and break [describeHolder]; fixing it by sniffing the + * value (`isinstance`, or "does it look like a handle") would pass both and be wrong for any + * `PyObject`-typed parameter that is handed a proxy of a *different* class. + */ +class ProxyObjectArgumentTest { + + @BeforeTest + fun install() { + Python3.initialize(silent = true) + UpcallTable.clear() + HandleTable.releaseAll() + UpcallTable.install(FunctionTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + } + + @AfterTest + fun cleanup() { + // The first test builds a genuine self-cycle through a Kotlin field, so dropping the name + // is not enough on its own -- break the edge before collecting, or the handle survives + // into the next test's baseline. + Python3.exec("import gc\ntry:\n _rh.primary = None\nexcept Exception:\n pass\n_rh = None\ngc.collect()") + UpcallTable.clear() + HandleTable.releaseAll() + } + + /** + * `99acd830`'s report, reproduced on the shape that still shows it: a proxy that renders on the + * plain `_PmObject` owner, handed to a parameter declared `PyObject?`. `_pm_unwrap` turns it + * into its raw handle, the trampoline reads that `int` as a `HandleTable` entry, and the entry's + * own `args[0] as PyObject?` cast fails on the Kotlin object it resolves to. + */ + @Test + fun aPlainProxyPassedToAPyObjectTypedParameterCrossesAsThePythonObject() { + Python3.exec( + """ + from fixture.library import Counter, echoObject + _pm_c = Counter(0) + _pm_e = echoObject(_pm_c) + assert _pm_e is _pm_c, ( + 'a PyObject-typed parameter must receive the proxy object itself, not the Kotlin ' + 'object behind its handle; got ' + repr(_pm_e) + ) + """.trimIndent(), + ) + } + + /** + * The same decision on a proxy that renders on the *GC* owner. `_PmGcObject` is deliberately not + * an `_PmObject` (two solid bases cannot be combined), so `_pm_unwrap` already leaves it alone + * and this passes today -- by accident of the owner it happens to have, not because anything + * consulted the declared type. It is here so that fixing the two tests around it cannot break it. + */ + @Test + fun aProxyAssignedToAPyObjectTypedPropertyCrossesAsThePythonObject() { + Python3.exec( + """ + from fixture.library import RefHolder + _rh = RefHolder(None, None) + _rh.primary = _rh + assert _rh.primary is _rh, ( + 'a PyObject-typed parameter must receive the proxy object itself, not the Kotlin ' + 'object behind its handle; got ' + repr(_rh.primary) + ) + """.trimIndent(), + ) + } + + /** + * The other side, and the reason the decision cannot be "stop unwrapping": `describeHolder`'s + * parameter is declared `fixture.library.RefHolder`, so the same proxy passed to it must still + * be unwrapped to its handle and resolved back to the Kotlin object. + */ + @Test + fun aProxyPassedToAKotlinTypedParameterIsStillUnwrappedToItsHandle() { + Python3.exec( + """ + from fixture.library import RefHolder, describeHolder + _rh = RefHolder(None, None) + _rh.secondary = [1] + _pm_d = describeHolder(_rh) + assert _pm_d == 'primary=false,secondary=true', ( + 'a parameter declared as a Kotlin type must still receive the Kotlin object; got ' + + repr(_pm_d) + ) + """.trimIndent(), + ) + } +} diff --git a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/PyObjectArgumentReleaseTest.kt b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/PyObjectArgumentReleaseTest.kt new file mode 100644 index 000000000..4b3cbda1e --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/PyObjectArgumentReleaseTest.kt @@ -0,0 +1,235 @@ +package fixture.app + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.system.exitProcess +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Issue #98: once the JVM collected the wrappers a test run had left behind, the cleaner released a + * "reference" that was never one, and the JVM died with SIGSEGV in `Py_XDECREF` on `Cleaner-0`. + * + * ### What it was + * + * Not the upcall path. `GeneratedTableTest`'s traverse proof wrapped two made-up addresses, + * `PyObject(0xAAAA, borrowed = false)` and `0xBBBB`, and every `PyObject` registers a cleaner that + * calls `Py_DecRef(pointer)`. The test passed; the first collection after it in the same JVM ran + * `Py_DecRef(0xAAAA)`. Measured on the #45 suite (`hs_err`): `x0=0xaaaa`, `si_addr: 0xaaaa`, + * `Py_XDECREF+0x1c` ← `topLevelDecRefAction` ← `CleanupAction.run`, thread `Cleaner-0`, twice in + * two runs; the same suite with `GeneratedTableTest` left out ran 63 tests clean with the leak test + * enabled. A test that runs 10,000 upcalls, or calls `System.gc()`, is simply the first thing that + * makes the collector run. + * + * The upcall path itself was measured clean before anything changed: 30,000 `PyObject` arguments, + * kept or closed inside the call, then a forced collection, leave `sys.getrefcount` exactly where it + * started. These tests keep that true. + * + * ### Why a child JVM + * + * The failure is a SIGSEGV. In the test JVM it would take every other test down with it and report + * nothing; in a child it becomes an exit code and a log, as `PackagedPythonHomeLaunchTest` does it. + * The child runs `GeneratedTableTest`'s traverse proof first -- the test that left the bad wrappers -- + * and then the upcalls, so the red before the fix is the crash itself: exit 134 with `Py_XDECREF` + * in the `hs_err` file. + */ +class PyObjectArgumentReleaseTest { + + private val scratchDirs = mutableListOf() + + @AfterTest + fun cleanup() { + scratchDirs.forEach { dir -> + if (dir.exists()) { + Files.walk(dir.toPath()).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.delete(it) } + } + } + } + scratchDirs.clear() + } + + /** The cleaner path: 10,000 wrappers left for the JVM to collect. */ + @Test + fun tenThousandPyObjectArgumentsLeftToTheCleanerReleaseEachReferenceOnce() { + assertProbeRunsClean(PyObjectArgumentReleaseProbe.KEEP) + } + + /** The close path: the argument closed inside the call, 10,000 times (issue #98's own criterion). */ + @Test + fun tenThousandPyObjectArgumentsClosedInsideTheUpcallReleaseEachReferenceOnce() { + assertProbeRunsClean(PyObjectArgumentReleaseProbe.CLOSE) + } + + private fun assertProbeRunsClean(mode: String) { + val outcome = launchProbe(mode) + assertEquals( + 0, outcome.exitCode, + "the child JVM died (134 is SIGSEGV/abort -- a reference released that was never held).\n" + + outcome.crashSummary + "\n--- child output ---\n" + outcome.output, + ) + assertNotNull( + outcome.output.lineSequence().firstOrNull { it == PyObjectArgumentReleaseProbe.SENTINEL }, + "the child exited 0 but never reached the sentinel:\n${outcome.output}", + ) + val counts = assertNotNull( + outcome.output.lineSequence().firstOrNull { it.startsWith(PyObjectArgumentReleaseProbe.COUNTS) }, + "the child reported no reference counts:\n${outcome.output}", + ) + val fields = counts.removePrefix(PyObjectArgumentReleaseProbe.COUNTS).trim().split(' ') + .associate { it.substringBefore('=') to it.substringAfter('=') } + assertEquals( + fields["before"], fields["after"], + "sys.getrefcount(token) did not return to its value before the 10,000 calls once the JVM " + + "had collected the wrappers ($counts)", + ) + assertEquals("0", fields["handles"], "the calls left Kotlin objects rooted in HandleTable ($counts)") + } + + private class Outcome(val exitCode: Int, val output: String, val crashSummary: String) + + private fun launchProbe(mode: String): Outcome { + val workDir = File("build/tmp/pyObjectArgumentReleaseTest/${java.util.UUID.randomUUID()}").absoluteFile + workDir.mkdirs() + scratchDirs += workDir + val log = File(workDir, "probe.log") + val java = File(System.getProperty("java.home"), "bin/java").path + val command = buildList { + add(java) + // java.lang.foreign is a preview API on JDK 21, which is what desktopTest runs on. + add("--enable-preview") + add("--enable-native-access=ALL-UNNAMED") + add("-XX:ErrorFile=${File(workDir, "hs_err_%p.log").path}") + System.getProperty("java.library.path")?.let { add("-Djava.library.path=$it") } + add("-cp") + add(System.getProperty("java.class.path")) + add(PyObjectArgumentReleaseProbe::class.java.name) + add(mode) + } + // PYTHONHOME and the rest of the environment are inherited from this test JVM. + val process = ProcessBuilder(command) + .directory(workDir) + .redirectErrorStream(true) + .redirectOutput(log) + .start() + if (!process.waitFor(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly() + return Outcome(-1, "timed out after $PROBE_TIMEOUT_SECONDS s\n" + log.readText(), "") + } + val crash = workDir.listFiles().orEmpty().filter { it.name.startsWith("hs_err_") }.joinToString("\n") { f -> + f.readLines().filter { + it.startsWith("# C ") || it.startsWith("siginfo:") || it.startsWith("Current thread") || + it.startsWith(" x0=") || "topLevelDecRefAction" in it + }.take(8).joinToString("\n", prefix = "--- ${f.name} ---\n") + } + return Outcome(process.exitValue(), log.readText(), crash) + } + + private companion object { + const val PROBE_TIMEOUT_SECONDS = 180L + } +} + +/** + * The child side of [PyObjectArgumentReleaseTest]. Prints [COUNTS] and then [SENTINEL]; a crash + * prints neither. + */ +object PyObjectArgumentReleaseProbe { + const val KEEP = "keep" + const val CLOSE = "close" + const val COUNTS = "RELEASE_PROBE_COUNTS" + const val SENTINEL = "RELEASE_PROBE_SENTINEL_OK" + + private const val ITERATIONS = 10_000 + private const val SETTLE_TIMEOUT_MS = 60_000L + private const val MIN_ROUNDS = 10 + + @JvmStatic + fun main(args: Array) { + val mode = args.firstOrNull() ?: KEEP + Python3.initialize(silent = true) + + // What every earlier test in this module's JVM has done by the time a long test forces a + // collection: left wrappers for the cleaner. The one that crashed is this one, so it runs + // here, in this process, before the calls. Its own install/cleanup clears the tables, so + // the binder is installed after it. + GeneratedTableTest().run { + install() + try { + aClassWithPyObjectFieldsGetsAGeneratedTraverseThatVisitsTheirRawPointers() + } finally { + cleanup() + } + } + + UpcallTable.clear() + HandleTable.releaseAll() + UpcallTable.install(FunctionTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + + // `echoObject(value: PyObject?)` keeps nothing: the wrapper the trampoline made is left for + // the cleaner. `closeArgument` closes it inside the call. Either way each call takes one + // reference through `PyObject(item, borrowed = true)` and must give back exactly one. + val call = if (mode == CLOSE) "assert closeArgument(_rp_token) == 1" else "assert echoObject(_rp_token) is _rp_token" + Python3.exec( + """ + import gc, sys + from fixture.library import echoObject + from fixture.app import closeArgument + _rp_token = object() + + def _rp_loop(n): + for _ in range(n): + $call + """.trimIndent(), + ) + val handlesBefore = HandleTable.liveCount + val before = refcount() + Python3.exec("_rp_loop($ITERATIONS)") + val during = refcount() + val after = settle(before) + println("$COUNTS mode=$mode before=$before during=$during after=$after handles=${HandleTable.liveCount - handlesBefore}") + + // Something that allocates and frees a great deal, so a corrupted free list or a freed + // object still in use shows up here rather than in whatever runs next. + Python3.exec("_rp_x = [object() for _ in range(200000)]\ndel _rp_x\ngc.collect()") + println(SENTINEL) + System.out.flush() + exitProcess(0) + } + + /** `sys.getrefcount(_rp_token)`, read between `exec` calls so this thread holds no GIL. */ + private fun refcount(): Long { + Python3.exec("_rp_rc = sys.getrefcount(_rp_token)") + return Python3.import("__main__").getAttr("_rp_rc").toString().toLong() + } + + /** + * Drives `System.gc()` until the count is back at [target] and at least [MIN_ROUNDS] rounds have + * run -- the rounds are what make the cleaner reach the wrappers `GeneratedTableTest` left, which + * the count alone does not need. Gives up after [SETTLE_TIMEOUT_MS] and returns the last reading. + */ + private fun settle(target: Long): Long { + val deadline = System.currentTimeMillis() + SETTLE_TIMEOUT_MS + var rounds = 0 + var last = refcount() + while (System.currentTimeMillis() < deadline) { + if (last == target && rounds >= MIN_ROUNDS) return last + System.gc() + Thread.sleep(20) + rounds++ + last = refcount() + } + return last + } +} diff --git a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/RefHolderCycleCollectionTest.kt b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/RefHolderCycleCollectionTest.kt new file mode 100644 index 000000000..395a467ea --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/RefHolderCycleCollectionTest.kt @@ -0,0 +1,137 @@ +package fixture.app + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.ClassLookup +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * ROADMAP §7's last open item, proved against a *real* KSP-exposed class rather than the + * hand-built proxy `CycleCollectionTest` (python-multiplatform desktopTest) constructs through + * direct C-API calls. + * + * `fixture.library.RefHolder(var primary: PyObject?, var secondary: PyObject?)` was written + * specifically to exercise the generator's `tp_traverse` field-detection path + * (`GeneratedTableTest.aClassWithPyObjectFieldsGetsAGeneratedTraverseThatVisitsTheirRawPointers` + * proves the generator's half). What that test does not prove is whether the *rendered proxy + * class* -- `PythonProxySource`'s `class RefHolder(_PmObject):` -- ever lets CPython's collector + * reach that traverse function at all. It does not: `_PmObject` is a plain Python class with no + * `tp_traverse` override, so a cycle that closes only through the Kotlin object's own `primary` + * field (as opposed to through a Python-visible attribute) is invisible to `gc.collect()` and + * leaks forever. `ProxyHandleLifetimeTest.aProxyInsideAReferenceCycleStillGivesItsHandleBackWhen...` + * does not catch this: its cycle (`c.box -> box -> c`) closes entirely through ordinary Python + * attributes that `subtype_traverse` already walks, so it passes today without exercising the gap + * at all. + * + * This test builds the cycle the other one cannot: `_rh.primary = [_rh]`, a reference back to + * `_rh` stored inside a plain Python list that only the *Kotlin* object's own field points at. + * The edge `list -> _rh` is ordinary and every collector already walks it; the edge that decides + * this test is `_rh -> (opaque handle) -> Kotlin field -> list`, which no Python-visible attribute + * carries. (A direct `_rh.primary = _rh` was tried first and hit a different, pre-existing bug: + * `_pm_unwrap` unwrapped *any* `_PmObject`-derived argument to its raw handle before a call, which + * is right for an owned-result argument but wrong for a `PyObject`-typed parameter -- passing `_rh` + * itself resolved to the underlying Kotlin object and failed the property's own cast. That bug is + * fixed and [ProxyObjectArgumentTest] pins both sides of it: the unwrapping decision is now made + * when the call is rendered, from the parameter's declared Kotlin type. The `list` stays here + * regardless -- it is what makes the cycle's only edge back to `_rh` live on the Kotlin heap, which + * is the whole point of this test and is not something the direct assignment would show.) + */ +class RefHolderCycleCollectionTest { + + @BeforeTest + fun install() { + Python3.initialize(silent = true) + UpcallTable.clear() + HandleTable.releaseAll() + UpcallTable.install(FunctionTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + } + + @AfterTest + fun cleanup() { + Python3.exec("import gc\n_rh = None\ngc.collect()") + UpcallTable.clear() + HandleTable.releaseAll() + } + + private fun settledBaseline(): Int { + Python3.exec("import gc\n_rh = None\ngc.collect()") + return HandleTable.liveCount + } + + @Test + fun aSelfCycleThroughARealKspExposedClassIsCollected() { + assertTrue( + ClassLookup.require("fixture.library.RefHolder").hasTraverse, + "premise: RefHolder must carry a generated traverse function or this proves nothing", + ) + + val baseline = settledBaseline() + + // The cycle: _rh (a Python proxy) -> its own _pm_handle -> the Kotlin RefHolder object -> + // .primary (a PyObject field) -> a plain list -> back to _rh. Nothing here is reachable + // from _rh through an ordinary Python attribute -- the only edge back to _rh's Kotlin + // object lives on the Kotlin heap, invisible to a traverse that does not reach it. + Python3.exec( + """ + from fixture.library import RefHolder + _rh = RefHolder(None, None) + _rh.primary = [_rh] + """.trimIndent(), + ) + assertEquals( + baseline + 1, HandleTable.liveCount, + "constructing one RefHolder must root exactly one Kotlin object", + ) + + Python3.exec("_rh = None\nimport gc\ngc.collect()") + assertEquals( + baseline, HandleTable.liveCount, + "a self-cycle closing through the Kotlin object's own PyObject field must be collected " + + "by CPython's cyclic collector, not leaked forever", + ) + } + + /** Negative control: no cycle, so refcounting alone -- not tp_traverse -- must release it. */ + @Test + fun aNonCyclicRefHolderIsReleasedByOrdinaryRefcountingAlone() { + val baseline = settledBaseline() + + Python3.exec( + """ + from fixture.library import RefHolder + _rh = RefHolder(None, None) + """.trimIndent(), + ) + assertEquals(baseline + 1, HandleTable.liveCount) + + // No gc.collect() here on purpose: if this needed the collector to run, the cycle-closing + // test above would prove nothing about the ordinary path being intact. + Python3.exec("_rh = None") + assertEquals( + baseline, HandleTable.liveCount, + "an object with no cycle must be released by refcounting alone", + ) + } + + /** Negative control: an unregistered class's proxy is unaffected -- nothing here should ever + * touch the ordinary (non-`hasTraverse`) proxy shape. */ + @Test + fun anOrdinaryClassWithNoPyObjectFieldsKeepsRenderingOnTheOldOwner() { + val source = PythonProxySource.render(UpcallTable.entries(), ClassLookup.all()) + assertTrue( + source.contains("class Counter(_PmObject):"), + "Counter has no PyObject-typed field, so hasTraverse is false and it must keep the " + + "plain owner -- changing this would widen the blast radius far past RefHolder:\n$source", + ) + } +} diff --git a/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/TypedPythonKotlinCallTest.kt b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/TypedPythonKotlinCallTest.kt new file mode 100644 index 000000000..805106f9b --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/kotlin/fixture/app/TypedPythonKotlinCallTest.kt @@ -0,0 +1,348 @@ +package fixture.app + +import org.junit.Assume.assumeTrue +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import java.io.File +import java.util.Properties +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Issue #45: compiled TypedPython code calls Kotlin through the binder, inside the embedded + * runtime, and behaves exactly as the same code interpreted. + * + * `src/desktopTest/typedpython/tp_kotlin_bridge.py` is loaded twice into this JVM's interpreter: + * as source (`_tpi`, interpreted) and as the extension `buildTypedPythonBridge` compiled from it + * against the embedded CPython's headers and extension suffix (`_tpc`, compiled). Its functions call + * the Kotlin in `commonMain/.../TypedPythonBridge.kt` through the KSP-generated binder -- the Kotlin + * constructor (a typed, owned `TpSample` proxy), a method on that proxy, the proxy passed back to a + * Kotlin function, a Python object passed to a `PyObject` parameter, and a Kotlin throw. + * + * The four done criteria, one test each, plus a premise test that the comparison is not vacuous + * (the compiled module really is compiled, for this interpreter): + * + * | criterion | test | + * |---|---| + * | equal results | [compiledAndInterpretedRunsReturnTheSameFloatAndKotlinSeesTheSameCalls] | + * | equal Kotlin-side call counts | the same test, through [TpBridgeLedger] | + * | a Kotlin exception has the same Python type and message | [aKotlinExceptionReachesTheCompiledCallerAsTheSamePythonException] | + * | no reference leaks across 10,000 calls | [tenThousandCompiledCallsLeakNoReferenceAndNoKotlinHandle] | + * + * **Skipped, not failed,** when the build could not compile the extension: no host Python with + * Pyrefly 1.3.2 (`-PtypedpythonPython=`, default `python-multiplatform-ksp/.venv/bin/python`) + * or a host other than macOS. The skip reason names which. When the build *did* try and failed, the + * build fails -- `buildTypedPythonBridge` is a dependency of `desktopTest`. + */ +class TypedPythonKotlinCallTest { + + private lateinit var bridge: Properties + + @BeforeTest + fun install() { + val skip = System.getProperty("typedpython.bridge.skip") + assumeTrue("TypedPython bridge extension not built: $skip", skip == null) + val propertiesPath = System.getProperty("typedpython.bridge.properties") + assumeTrue( + "TypedPython bridge extension not built: system property typedpython.bridge.properties is unset " + + "(run through :ksp-fixtures:app:desktopTest)", + propertiesPath != null, + ) + val file = File(propertiesPath!!) + // Present-but-missing is a build defect, not a skip: the task ran and wrote nothing. + assertTrue(file.isFile, "buildTypedPythonBridge ran but $file does not exist") + bridge = Properties().apply { file.inputStream().use { load(it) } } + + Python3.initialize(silent = true) + UpcallTable.clear() + HandleTable.releaseAll() + UpcallTable.install(FunctionTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + TpBridgeLedger.reset() + + // Both copies are loaded after the binder is installed: the module body runs + // `from fixture.app import ...`, which must find the proxies this install just rendered. + // A fresh copy per test, because `UpcallTable.clear()` above invalidates the handles the + // previous test's proxies were rendered with. + Python3.exec( + """ + import importlib.machinery as _tp_machinery + import importlib.util as _tp_util + + def _tp_load_pair(source, extension, name): + spec = _tp_util.spec_from_file_location(name + '_interpreted', source) + interpreted = _tp_util.module_from_spec(spec) + spec.loader.exec_module(interpreted) + # The extension's PyInit_ is named after the source stem, so it is loaded under it. + loader = _tp_machinery.ExtensionFileLoader(name, extension) + spec = _tp_util.spec_from_file_location(name, extension, loader=loader) + compiled = _tp_util.module_from_spec(spec) + loader.exec_module(compiled) + return interpreted, compiled + + _tpi, _tpc = _tp_load_pair(${py(prop("source"))}, ${py(prop("extension"))}, ${py(prop("module"))}) + del _tp_load_pair, _tp_machinery, _tp_util + """.trimIndent(), + ) + } + + @AfterTest + fun cleanup() { + if (!::bridge.isInitialized) return + Python3.exec( + "import gc\n" + + "for _n in ('_tpi', '_tpc', '_tp_token', '_tp_r_i', '_tp_r_c', '_tp_e_i', '_tp_e_c'):\n" + + " globals().pop(_n, None)\n" + + "gc.collect()", + ) + UpcallTable.clear() + HandleTable.releaseAll() + TpBridgeLedger.reset() + } + + /** + * Premise for everything below. If any line here fails, the other tests would be comparing the + * interpreter with itself, or loading an extension built for a different CPython. + */ + @Test + fun theCompiledModuleIsCompiledForTheInterpreterItRunsIn() { + Python3.exec( + """ + import importlib.machinery, sys, sysconfig, types + _v = '%d.%d.%d' % sys.version_info[:3] + assert _v == ${py(prop("pythonVersion"))}, ( + 'built against headers of ' + ${py(prop("pythonVersion"))} + ' but running in ' + _v) + assert ${py(prop("extSuffix"))} in importlib.machinery.EXTENSION_SUFFIXES, ( + 'extension suffix ' + ${py(prop("extSuffix"))} + ' is not one this interpreter imports: ' + + repr(importlib.machinery.EXTENSION_SUFFIXES)) + _gil_disabled = bool(sysconfig.get_config_var('Py_GIL_DISABLED') or 0) + assert _gil_disabled == ${pyBool(prop("gilDisabled"))}, ( + 'flavour mismatch: extension built with Py_GIL_DISABLED=' + ${py(prop("gilDisabled"))} + + ', interpreter has ' + repr(_gil_disabled)) + _names = ${py(prop("compiledFunctions"))}.split(',') + assert sorted(_names) == ['checked', 'drive'], _names + for _n in _names: + assert type(getattr(_tpc, _n)) is types.BuiltinFunctionType, (_n, type(getattr(_tpc, _n))) + assert type(getattr(_tpi, _n)) is types.FunctionType, (_n, type(getattr(_tpi, _n))) + assert sorted(_tpc.__typedpython_interpreted__) == ['checked', 'drive'], _tpc.__typedpython_interpreted__ + assert _tpc.__typedpython_deopts__ == 0 + # Calling the Kotlin class gives its typed proxy -- with the method the module calls -- + # not a bare handle integer or a generic owner. + from fixture.app import TpSample + _s = TpSample(3, 0.5) + assert type(_s) is TpSample, type(_s) + assert _s.index == 3 and _s.weight == 0.5 and _s.weigh(2.0) == 4.0 + del _s, _n, _names, _v, _gil_disabled, TpSample + """.trimIndent(), + ) + } + + @Test + fun compiledAndInterpretedRunsReturnTheSameFloatAndKotlinSeesTheSameCalls() { + Python3.exec("_tp_token = object()\n_tp_r_i = _tpi.drive(200, 0.25, _tp_token)") + val interpreted = TpBridgeLedger.snapshot() + // Not vacuous: every Kotlin entry point was reached, once per step. + assertEquals(200L, TpBridgeLedger.samples, interpreted) + assertEquals(200L, TpBridgeLedger.weighs, interpreted) + assertEquals(200L, TpBridgeLedger.accumulates, interpreted) + assertEquals(200L, TpBridgeLedger.touches, interpreted) + assertEquals((0L until 200L).sum(), TpBridgeLedger.indexSum, interpreted) + + TpBridgeLedger.reset() + Python3.exec("_tp_r_c = _tpc.drive(200, 0.25, _tp_token)") + val compiled = TpBridgeLedger.snapshot() + + assertEquals(interpreted, compiled, "Kotlin saw different calls or values from the compiled run") + Python3.exec( + """ + assert type(_tp_r_c) is float and type(_tp_r_i) is float, (type(_tp_r_i), type(_tp_r_c)) + assert _tp_r_c.hex() == _tp_r_i.hex(), ( + 'compiled ' + _tp_r_c.hex() + ' != interpreted ' + _tp_r_i.hex()) + assert _tpc.__typedpython_deopts__ == 0, _tpc.__typedpython_deopts__ + """.trimIndent(), + ) + } + + @Test + fun aKotlinExceptionReachesTheCompiledCallerAsTheSamePythonException() { + Python3.exec( + """ + def _tp_catch(f): + try: + f(100, 40.0) + except BaseException as e: + return type(e), str(e) + raise AssertionError('tpCheck never threw: the limit is never passed') + _tp_e_i = _tp_catch(_tpi.checked) + """.trimIndent(), + ) + val interpreted = TpBridgeLedger.snapshot() + assertTrue(TpBridgeLedger.checks > 1, "the throw must come after some successful calls: $interpreted") + + TpBridgeLedger.reset() + Python3.exec("_tp_e_c = _tp_catch(_tpc.checked)\ndel _tp_catch") + val compiled = TpBridgeLedger.snapshot() + + assertEquals(interpreted, compiled, "the compiled caller stopped at a different call than the interpreted one") + Python3.exec( + """ + assert _tp_e_i[0] is RuntimeError, _tp_e_i + assert _tp_e_i[1].startswith('tpCheck: '), _tp_e_i + assert _tp_e_c == _tp_e_i, 'compiled raised ' + repr(_tp_e_c) + ', interpreted raised ' + repr(_tp_e_i) + # And the path that does not throw agrees too. + assert _tpc.checked(5, 1e9).hex() == _tpi.checked(5, 1e9).hex() + assert _tpc.__typedpython_deopts__ == 0, _tpc.__typedpython_deopts__ + """.trimIndent(), + ) + } + + /** + * 10,000 calls of each compiled function -- `drive` (3 Kotlin objects created and released per + * call) and `checked` on its throwing path -- must leave every reference count and the Kotlin + * handle table where they were. + * + * Watched: the token passed to Kotlin on every step, the binder proxies the compiled code reads + * as globals on every step (a missed release of a `Global` read shows here), and + * [HandleTable.liveCount] (a proxy never deallocated keeps its Kotlin object rooted). The same + * loop runs interpreted first as a control, so a leak in the binder itself is reported as such and + * not blamed on the compiled code. + * + * **The token's count is read only after the Kotlin side has given its references back.** The + * binder wraps the token in a `PyObject` that takes a reference of its own, and that reference + * returns only when the JVM collects the wrapper and its cleaner runs (`tpTouch` cannot close it: + * #98). Read straight after the loop, the interpreted control showed `{'token': (10, 17350)}`. + * [settledCounts] therefore drives `System.gc()` / `System.runFinalization()` between reads -- + * with the GIL released, so the cleaner thread can take it -- until the counts reach the baseline + * or stop moving, within a bounded time. Same idea as `CycleCollectionTest.settleJvmFinalisation`; + * the library's own `ReleaseCounter` is `internal` and not reachable from this module. + * + * Free-threaded CPython defers reference counting of functions and classes, so `sys.getrefcount` + * on those says nothing there; on that flavour only the token and the handle table are compared. + */ + @Test + fun tenThousandCompiledCallsLeakNoReferenceAndNoKotlinHandle() { + Python3.exec( + """ + import gc, sys, sysconfig + import fixture.app as _tp_app + _tp_token = object() + _tp_watched = {'token': _tp_token} + if not (sysconfig.get_config_var('Py_GIL_DISABLED') or 0): + for _n in ('TpSample', 'tpAccumulate', 'tpCheck', 'tpTouch'): + _tp_watched[_n] = getattr(_tp_app, _n) + _tp_watched['drive'] = _tpc.drive + _tp_watched['checked'] = _tpc.checked + del _tp_app + + def _tp_counts(): + gc.collect() + return repr(sorted((k, sys.getrefcount(v)) for k, v in _tp_watched.items())) + + def _tp_loop(module, n): + for _ in range(n): + module.drive(3, 0.25, _tp_token) + try: + module.checked(100, 40.0) + except RuntimeError: + pass + + _tp_loop(_tpi, 1) + _tp_loop(_tpc, 1) + """.trimIndent(), + ) + // The baseline has no target to reach, so it is "stopped moving" -- with a floor of rounds so + // the warm-up's pending wrappers are collected before it is taken. + val before = settledCounts(target = null) + val baseline = HandleTable.liveCount + TpBridgeLedger.reset() + + Python3.exec("_tp_loop(_tpi, 10000)") + assertEquals( + before, settledCounts(target = before), + "control: the INTERPRETED loop leaks references even after the Kotlin side has released, " + + "so the binder does (sorted (name, sys.getrefcount) pairs)", + ) + assertEquals(baseline, HandleTable.liveCount, "control: the interpreted loop left Kotlin objects rooted") + val interpreted = TpBridgeLedger.snapshot() + assertEquals(30_000L, TpBridgeLedger.samples, interpreted) + assertEquals(10_000L * 3, TpBridgeLedger.touches, interpreted) + + TpBridgeLedger.reset() + Python3.exec("_tp_loop(_tpc, 10000)\nassert _tpc.__typedpython_deopts__ == 0, _tpc.__typedpython_deopts__") + assertEquals( + before, settledCounts(target = before), + "the COMPILED loop leaks references (sorted (name, sys.getrefcount) pairs; the interpreted " + + "control above settled back to the baseline)", + ) + assertEquals(baseline, HandleTable.liveCount, "the compiled loop left Kotlin objects rooted") + assertEquals(interpreted, TpBridgeLedger.snapshot(), "Kotlin saw different calls from the compiled loop") + + Python3.exec( + "for _n in ('_tp_watched', '_tp_counts', '_tp_loop', '_n'):\n" + + " globals().pop(_n, None)", + ) + } + + /** + * The watched reference counts once the JVM has collected the `PyObject` wrappers the binder made + * and their cleaners have handed the references back. + * + * Called with this thread **not** holding the GIL (between `Python3.exec` calls), so the cleaner + * thread can take it. Each round: `System.gc()`, `System.runFinalization()`, a short sleep, then one + * read. Returns as soon as the reading equals [target]; with no target, once it has not moved for + * [STABLE_ROUNDS] reads after at least [MIN_ROUNDS]. Gives up after [SETTLE_TIMEOUT_MS] and returns + * the last reading, so the caller's assertion shows where it stopped. + */ + private fun settledCounts(target: String?): String { + val deadline = System.currentTimeMillis() + SETTLE_TIMEOUT_MS + var last = readCounts() + var stable = 0 + var rounds = 0 + while (System.currentTimeMillis() < deadline) { + if (target != null && last == target) return last + System.gc() + System.runFinalization() + Thread.sleep(20) + val now = readCounts() + rounds++ + if (now == last) stable++ else stable = 0 + last = now + if (target == null && rounds >= MIN_ROUNDS && stable >= STABLE_ROUNDS) return last + } + return last + } + + private fun readCounts(): String = + Python3.import("__main__").getAttr("__dict__").let { globals -> + Python3.eval("_tp_counts()", PY_EVAL_INPUT, globals, globals).toString() + } + + private fun prop(key: String): String = + bridge.getProperty(key) ?: error("bridge.properties has no '$key': $bridge") + + /** A Python string literal for [s]. */ + private fun py(s: String): String = + "'" + s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n") + "'" + + private fun pyBool(s: String): String = when (s) { + "true" -> "True" + "false" -> "False" + else -> error("not a boolean: $s") + } + + private companion object { + /** `Py_eval_input`: [Python3.eval]'s start symbol for a single expression. */ + const val PY_EVAL_INPUT = 258 + const val SETTLE_TIMEOUT_MS = 30_000L + const val MIN_ROUNDS = 10 + const val STABLE_ROUNDS = 5 + } +} diff --git a/ksp-fixtures/app/src/desktopTest/typedpython/fixture/__init__.pyi b/ksp-fixtures/app/src/desktopTest/typedpython/fixture/__init__.pyi new file mode 100644 index 000000000..e69de29bb diff --git a/ksp-fixtures/app/src/desktopTest/typedpython/fixture/app/__init__.pyi b/ksp-fixtures/app/src/desktopTest/typedpython/fixture/app/__init__.pyi new file mode 100644 index 000000000..267595828 --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/typedpython/fixture/app/__init__.pyi @@ -0,0 +1,16 @@ +# Type-checking stub for the TypedPython gate only (issue #45). It mirrors the part of +# `ksp-fixtures/app/src/commonMain/kotlin/fixture/app/TypedPythonBridge.kt` that +# `tp_kotlin_bridge.py` uses. Kotlin `Long` is `int`, `Double` is `float`, `PyObject?` is `object`. +# If this drifts from the Kotlin, the gate still passes and the run-time test fails at the call. + +class TpSample: + def __init__(self, index: int, weight: float) -> None: ... + @property + def index(self) -> int: ... + @property + def weight(self) -> float: ... + def weigh(self, factor: float) -> float: ... + +def tpAccumulate(sample: TpSample, amount: float) -> float: ... +def tpCheck(value: float, limit: float) -> float: ... +def tpTouch(token: object) -> int: ... diff --git a/ksp-fixtures/app/src/desktopTest/typedpython/tp_kotlin_bridge.py b/ksp-fixtures/app/src/desktopTest/typedpython/tp_kotlin_bridge.py new file mode 100644 index 000000000..7677eca9c --- /dev/null +++ b/ksp-fixtures/app/src/desktopTest/typedpython/tp_kotlin_bridge.py @@ -0,0 +1,44 @@ +# typedpython: compiled +"""Issue #45: compiled TypedPython code calling Kotlin through the binder. + +Every name imported below is a binder proxy (`fixture/app/TypedPythonBridge.kt`, published by +`PythonProxySource`). In the IR they are opaque objects: `Global` reads them, `GetAttr` reaches the +proxy's method, `CallObject` calls them. The proxy comes from calling the Kotlin class itself +(`TpSample(...)`): a Kotlin *function* returning a Kotlin object hands Python a generic owner +without the class's methods (see `TypedPythonBridge.kt`). That makes both functions impure, so nothing in them may +deopt -- the floats and the `range()` indices around the calls stay native, everything crossing to +Kotlin is boxed. + +`TypedPythonKotlinCallTest` imports this file twice in the embedded interpreter: as source +(interpreted) and as the extension `build_bridge.py` built from it against the embedded CPython's +headers (compiled), and compares the two. + +The gate type-checks the imports against `fixture/app/__init__.pyi` beside this file, a hand-written +stub of the same Kotlin surface (the bindings plugin generates `.pyi` only for walked artefacts, not +for a KSP-scanned module). Nothing at run time reads that stub: the binder's modules are in +`sys.modules` before this file is loaded. +""" +from fixture.app import TpSample, tpAccumulate, tpCheck, tpTouch + + +def drive(n: int, scale: float, token: object) -> float: + """Per step: a Kotlin constructor call that returns a typed proxy, a method call on that proxy, + the proxy handed back to a Kotlin function, and a PyObject handed to Kotlin -- inside native + float math.""" + total: float = 0.0 + for i in range(n): + x: float = float(i) * scale + 0.5 + sample: TpSample = TpSample(i, x) + w: float = float(sample.weigh(scale)) + total = total * 0.75 + float(tpAccumulate(sample, w * w + 1.0)) + tpTouch(token) + return total + + +def checked(n: int, limit: float) -> float: + """`tpCheck` throws in Kotlin once `acc` passes `limit`; the caller sees what Python sees.""" + acc: float = 0.0 + for i in range(n): + acc = acc + float(i) * 1.5 + acc = float(tpCheck(acc, limit)) + return acc diff --git a/ksp-fixtures/app/typedpython/build_bridge.py b/ksp-fixtures/app/typedpython/build_bridge.py new file mode 100644 index 000000000..cd754b3e6 --- /dev/null +++ b/ksp-fixtures/app/typedpython/build_bridge.py @@ -0,0 +1,104 @@ +"""Builds `tp_kotlin_bridge.py` into a TypedPython extension for the CPython this fixture embeds (#45). + +Run by the `buildTypedPythonBridge` task in `ksp-fixtures/app/build.gradle.kts` with a *host* +interpreter that has Pyrefly 1.3.2 (the gate needs it; `-PtypedpythonPython`, default +`python-multiplatform-ksp/.venv/bin/python`). The host interpreter only runs the compiler; the +extension is built for the embedded one: + +- headers: `include_dir`, from `python-multiplatform`'s `cpythonIncludeDirectories` provider + (`docs/platforms/python-version-acquisition.md` §7) -- the directory holding `Python.h`; +- extension suffix and flavour: read from that same tree's `_sysconfigdata_*.py`, which sits at + `/../../lib//` in a python-build-standalone prefix + (`include/python3.14[t]` beside `lib/python3.14[t]`). It is the file the embedded interpreter's own + `sysconfig` reads, so `EXT_SUFFIX` there is the suffix its import system accepts. `Py_GIL_DISABLED` + is cross-checked against `pyconfig.h` in `include_dir`, so headers and suffix cannot come from + different flavours. + +Writes `/` and `/bridge.properties`, which the test reads to +check that the interpreter it runs in is the one the extension was built for. + +Fails (exit 1) when the module does not compile in full: a function left interpreted would make +the test compare the interpreter with itself. + +usage: build_bridge.py +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def sysconfig_vars(include_dir: Path) -> tuple[Path, dict[str, object]]: + lib = include_dir.parent.parent / "lib" / include_dir.name + found = sorted(lib.glob("_sysconfigdata_*.py")) + if len(found) != 1: + raise SystemExit(f"build_bridge: expected one _sysconfigdata_*.py in {lib}, found {found}") + namespace: dict[str, object] = {} + exec(compile(found[0].read_text(encoding="utf-8"), str(found[0]), "exec"), namespace) + data = namespace.get("build_time_vars") + if not isinstance(data, dict): + raise SystemExit(f"build_bridge: {found[0]} has no build_time_vars dict") + return found[0], data + + +def header_gil_disabled(include_dir: Path) -> bool: + text = (include_dir / "pyconfig.h").read_text(encoding="utf-8", errors="replace") + return re.search(r"^\s*#\s*define\s+Py_GIL_DISABLED\s+1\b", text, re.MULTILINE) is not None + + +def header_version(include_dir: Path) -> str: + text = (include_dir / "patchlevel.h").read_text(encoding="utf-8", errors="replace") + m = re.search(r'^\s*#\s*define\s+PY_VERSION\s+"([^"]+)"', text, re.MULTILINE) + if m is None: + raise SystemExit(f"build_bridge: no PY_VERSION in {include_dir / 'patchlevel.h'}") + return m.group(1) + + +def main(argv: list[str]) -> int: + if len(argv) != 5: + print(__doc__, file=sys.stderr) + return 2 + package_parent, module, include_dir, out_dir = (Path(a).resolve() for a in argv[1:]) + sys.path.insert(0, str(package_parent)) + from typedpython import pipeline # noqa: E402 (needs the path above) + + if not (include_dir / "Python.h").is_file(): + raise SystemExit(f"build_bridge: no Python.h in {include_dir}") + data_file, data = sysconfig_vars(include_dir) + suffix = data.get("EXT_SUFFIX") + if not isinstance(suffix, str) or not suffix: + raise SystemExit(f"build_bridge: no EXT_SUFFIX in {data_file}") + gil_disabled = bool(data.get("Py_GIL_DISABLED") or 0) + if gil_disabled != header_gil_disabled(include_dir): + raise SystemExit(f"build_bridge: Py_GIL_DISABLED differs between {data_file} ({gil_disabled}) " + f"and {include_dir / 'pyconfig.h'}") + + out_dir.mkdir(parents=True, exist_ok=True) + for stale in out_dir.glob(module.stem + ".*"): + stale.unlink() + result = pipeline.compile_module(module, out_dir, python_include=include_dir, ext_suffix=suffix) + if result.extension is None or result.skipped or result.diagnostics: + raise SystemExit(f"build_bridge: {module.name} did not compile in full: extension=" + f"{result.extension}, skipped={result.skipped}, diagnostics={result.diagnostics}") + + compiled = ",".join(f.name for f in result.module.functions) + (out_dir / "bridge.properties").write_text( + "\n".join([ + f"extension={result.extension}", + f"module={module.stem}", + f"source={module}", + f"extSuffix={suffix}", + f"gilDisabled={str(gil_disabled).lower()}", + f"pythonVersion={header_version(include_dir)}", + f"includeDir={include_dir}", + f"compiledFunctions={compiled}", + ]) + "\n", + encoding="utf-8", + ) + print(f"build_bridge: {result.extension} ({compiled}; {suffix}; gilDisabled={gil_disabled})") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/ksp-fixtures/artifact-valueclass/build.gradle.kts b/ksp-fixtures/artifact-valueclass/build.gradle.kts new file mode 100644 index 000000000..5c12a588a --- /dev/null +++ b/ksp-fixtures/artifact-valueclass/build.gradle.kts @@ -0,0 +1,14 @@ +/** + * A real, separately-compiled jar for [ArtifactScanner]'s value-class handling to be proved against + * from Python, not just from a plugin unit test. + * + * `kotlin.time.Duration` (the real-world case `ArtifactScanner`'s KDoc discusses) cannot prove a + * *positive* round trip: its constructor is `internal`, so no generated Kotlin outside + * `kotlin-stdlib` can ever build one. This module exists only to be walked -- like `:ksp-fixtures + * :artifact`'s `junit` dependency, it is a plain `kotlin("jvm")` library with no bindings plugin of + * its own applied, resolved as an ordinary dependency and never compiled by the module that walks + * it. + */ +plugins { + alias(libs.plugins.kotlin.jvm) +} diff --git a/ksp-fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/Meters.kt b/ksp-fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/Meters.kt new file mode 100644 index 000000000..e627cab6a --- /dev/null +++ b/ksp-fixtures/artifact-valueclass/src/main/kotlin/fixture/valueclass/Meters.kt @@ -0,0 +1,13 @@ +// Never compiled by `:ksp-fixtures:artifact`, which only ever sees this module's resolved jar -- +// see the `build.gradle.kts` doc comment on why this module exists at all. +package fixture.valueclass + +/** A public `@JvmInline value class`, the shape `kotlin.time.Duration` has except for its + * constructor's visibility. */ +@JvmInline +value class Meters(val value: Double) + +/** Both parameters and the return are the value class: proves `ArtifactScanner` unwraps a + * value-class *parameter* on the way in and wraps a value-class *return* on the way out, in a + * declaration reachable from a real, separately-resolved jar. */ +fun sumMeters(a: Meters, b: Meters): Meters = Meters(a.value + b.value) diff --git a/ksp-fixtures/artifact/build.gradle.kts b/ksp-fixtures/artifact/build.gradle.kts new file mode 100644 index 000000000..0b37dedd8 --- /dev/null +++ b/ksp-fixtures/artifact/build.gradle.kts @@ -0,0 +1,160 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/** + * The artefact walker end to end: a real third-party jar the build resolves, walked at build time, + * installed into the same `UpcallTable` KSP's own fragments go into, and imported from Python. + * + * `docs/design/ecosystem.md` §5b names two producers of bindings and splits them by *what they look at* -- + * KSP the consumer's own source, the walker everything the build resolves. `:ksp-fixtures:app` + * covers the first. Nothing covered the second, which is why `androidx.compose.material3` was + * believed to be unreachable. + * + * Deliberately a module of its own rather than another target in `:ksp-fixtures:app`: that module's + * `CommonInstallTest` asserts `UpcallTable.moduleNames` **exactly**, and a walked artefact appearing + * there would be a change to what `FunctionTable` means rather than an addition beside it. Keeping + * the two apart in the build is the same judgement `ArtifactTableRenderingTest` records for the + * generated code. + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) + id("io.github.thisisthepy.python.multiplatform.bindings") +} + +kotlin { + // One target. The walker reads jars, and jars are what a JVM target resolves; whether the same + // walk is possible over a `.klib` is `docs/design/ecosystem.md` §5b's open question and is not + // answered by adding a Native target that would silently bind nothing. + jvm("desktop") { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.pythonMultiplatform) + } + } + val desktopMain by getting { + dependencies { + // A real dependency, declared the ordinary way. Nothing about it knows it is going + // to be walked, which is the whole claim: KSP cannot see inside it, and the walker + // does not need it to cooperate. + implementation(libs.junit) + // See its own `build.gradle.kts`: a real jar for the value-class round trip + // `kotlin.time.Duration` cannot prove. + implementation(projects.kspFixtures.artifactValueclass) + + // Compose Multiplatform, as plain Maven coordinates and **without** the Compose + // Gradle or compiler plugin. That is deliberate and is the whole shape of the claim: + // `docs/design/kotlin-extensions-in-python.md` §3 measured zero declarations bound from + // these exact jars, and what makes `Modifier.padding(16.dp)` reachable is the + // walker's own two gates opening, not any cooperation from Compose's tooling. + // + // A `Modifier` chain is also the one part of Compose that needs no composition at + // all: `padding` is an ordinary function returning an ordinary object, so + // `WalkedArtifactComposeModifierTest` can call it in a plain JVM test with no + // `Composer` anywhere. Composables are a different problem + // (`docs/archive/pythonx-adapter-design.md` §5) and are not touched here. + // + // Version pinned to the catalog's `compose-plugin`, which is what the rest of this + // build resolves, so no second Compose version enters the cache. + val composeVersion = libs.versions.compose.plugin.get() + implementation("org.jetbrains.compose.ui:ui-desktop:$composeVersion") + implementation("org.jetbrains.compose.ui:ui-unit-desktop:$composeVersion") + implementation("org.jetbrains.compose.foundation:foundation-layout-desktop:$composeVersion") + // `Modifier.clickable` lives here rather than in `foundation-layout`, and it is the + // acceptance criterion for a **non-composable** function-typed slot: an ordinary + // function taking `onClick: () -> Unit`, so it needs no composition to call and yet + // was unreachable for as long as a `kotlin.FunctionN` parameter was declined one + // layer before the slot grammar was consulted (`ArtifactScanner.functionSlotOrNull`). + implementation("org.jetbrains.compose.foundation:foundation-desktop:$composeVersion") + } + } + val desktopTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + } +} + +pythonBindings { + // Neither `application` nor `com.android.application` is applied, so the aggregating role has to + // be stated -- this module needs a `FunctionTable` to install *beside* `ArtifactTable`. + role.set("app") + processor.set(projects.pythonMultiplatformKsp) + + artifactConfiguration.set("desktopCompileClasspath") + artifactSourceSet.set("desktopMain") + // Narrow on purpose. `junit.runner.Version.id()` answers `"4.13.2"`, which is the artefact's own + // version and therefore cannot be produced by anything except the artefact's own code -- the + // end-to-end test reads exactly that string back out of Python. + // + // `kotlin.text` is here for a different proof: it needs no dependency of its own (`kotlin-stdlib` + // is already on every Kotlin module's classpath), and `kotlin.text.trimIndent` is the exact case + // `ArtifactScanner`'s KDoc names as unreachable before it read `@Metadata` -- a top-level + // extension function behind a `MULTI_FILE_CLASS_FACADE`. `WalkedArtifactPythonImportTest` calls + // it from real Python. + // `fixture.valueclass` is `:ksp-fixtures:artifact-valueclass`'s `Meters`/`sumMeters` -- see that + // module's `build.gradle.kts` for why `kotlin.time.Duration` cannot stand in for it. + // + // `androidx.compose.foundation.layout` is the scorecard of + // `docs/design/kotlin-extensions-in-python.md`: the package `Modifier.padding` and `Modifier.size` live + // in, measured at **zero bound declarations** before the metadata-kind gate and the type gate + // were opened. Narrow on purpose -- binding all of Compose here would make this fixture's + // compile time the cost of a proof it does not need. + // + // The last two are for the **non-composable function-typed slot**, and both are named as single + // *classes* rather than packages, which `artifactIncludePackages` allows and which keeps this + // list's own "narrow on purpose" rule: + // + // - `androidx.compose.foundation.ClickableKt` is `Modifier.clickable` and `combinedClickable`. + // Asking for `androidx.compose.foundation` would sweep in `gestures`, `lazy`, `text` and + // everything else under it, which is compile time spent on a proof this does not need. + // - `kotlin.system.TimingKt` is `measureTimeMillis(block: () -> Unit)`, which has the **same** + // `kotlin.Function0()->kotlin.Unit` slot as `clickable.onClick` and, unlike every Compose + // callback, *invokes* it at the moment it is called. Compose stores its callbacks -- `clickable` + // is `composed { }`, `drawBehind` is a node -- so nothing in those jars can show a Python + // callable actually running outside a composition, and `WalkedArtifactCallbackTest` needs a + // declaration that runs one. + artifactIncludePackages.set( + listOf( + "junit.runner", "junit.framework", "kotlin.text", "fixture.valueclass", + "androidx.compose.foundation.layout", + "androidx.compose.foundation.ClickableKt", + "kotlin.system.TimingKt", + ), + ) +} + +/** + * The same JVM the library's own `desktopTest` needs: JDK 21 with the preview FFM API, and a + * `java.library.path` for the `libpython` that `manager.loadLibPython` extracts out of + * `python-multiplatform`'s desktop jar next to the working directory. + * + * `PYTHONHOME` is *not* set here -- the bindings plugin's `stagePythonHome` already points every + * `Test` task at a staged CPython prefix, and this is the first fixture that actually exercises it. + */ +tasks.named("desktopTest") { + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }, + ) + jvmArgs("--enable-preview", "-Djava.library.path=.") + + // `WalkedArtifactStubTest` reads the stubs the *build* generated -- not a fixture checked in + // beside it. Nothing compiles a `.pyi` (`docs/design/pyi-generation-design.md` §6.2), so unlike the + // generated Kotlin fragments there is no source-set registration that would make this task + // depend on the generator; the dependency has to be stated. + val stubs = tasks.named("generatePythonStubs") + dependsOn(stubs) + systemProperty( + "python.multiplatform.stubDir", + layout.buildDirectory.dir("generated/pythonStubs/desktopMain").get().asFile.absolutePath, + ) +} diff --git a/ksp-fixtures/artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt b/ksp-fixtures/artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt new file mode 100644 index 000000000..dc6a9e821 --- /dev/null +++ b/ksp-fixtures/artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt @@ -0,0 +1,8 @@ +package fixture.artifact + +/** + * One declaration of this module's own, so the end-to-end test can show both producers answering in + * the same Python interpreter: this one reaches Python through KSP's `FunctionTable`, and + * `junit.runner.Version.id` reaches it through the walker's `ArtifactTable`. + */ +fun whichSideAmIFrom(): String = "ksp" diff --git a/ksp-fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt b/ksp-fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt new file mode 100644 index 000000000..ef0651b0c --- /dev/null +++ b/ksp-fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt @@ -0,0 +1,86 @@ +package fixture.artifact + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * The declarations `WalkedArtifactComposeModifierTest` needs that the *walker* cannot supply, + * written as this module's own Kotlin so KSP binds them. + * + * ### Why a seed is needed at all + * + * A `Modifier` chain has to start somewhere, and the thing it starts from is `Modifier` the + * *expression* -- `androidx.compose.ui.Modifier.Companion`, an object instance + * (`docs/design/kotlin-extensions-in-python.md` §4.2). The artefact walker binds **functions**, so a + * companion's instance is not something it can hand out; `docs/design/ecosystem.md` §5b's next step (a + * `ReflectedClass` and a receiver handle) is where that will come from. Until it does, the empty + * modifier is one function of our own -- and everything after it is Compose's own code, reached + * through the walker under Compose's own name. + * + * ### Why the checks are functions here rather than assertions in Python + * + * The test must prove that a `Modifier` **built in Python** is the real thing, and "the real thing" + * is a claim only Compose's own types can settle. `toString` on a modifier chain is not it: + * `PaddingElement` and `SizeElement` are `internal` classes with no `toString` of their own, so the + * text is `PaddingElement@1a2b3c` and says nothing. What *is* settled is structural equality -- + * `ModifierNodeElement` subclasses implement `equals` by value, and `CombinedModifier` compares + * both halves -- so comparing the Python-built chain against one this file builds independently + * proves that Compose's own `padding` ran, with the argument Python supplied, in the order Python + * chained it. + * + * Nothing here builds anything on Python's behalf: [equalsPaddingThenSize] takes the finished chain + * as its *parameter* and only says whether it matches. + */ +fun emptyModifier(): Modifier = Modifier + +/** For a failure message. See this file's KDoc for why nothing asserts on it. */ +fun describeModifier(modifier: Modifier): String = modifier.toString() + +/** Proves the handle round trip is *identity*: `HandleTable.resolve` is a slot read, and this says + * it resolved to the object that was registered rather than to an equal one. */ +fun isTheEmptyModifier(modifier: Modifier): Boolean = modifier === Modifier + +/** How many elements the chain has. `Modifier.foldIn` is Compose's own traversal, so a chain that + * did not actually compose cannot report 2. */ +fun modifierElementCount(modifier: Modifier): Int = modifier.foldIn(0) { count, _ -> count + 1 } + +/** Whether [modifier] is structurally `Modifier.padding(pad.dp).size(size.dp)`. */ +fun equalsPaddingThenSize(modifier: Modifier, pad: Double, size: Double): Boolean = + modifier == Modifier.padding(pad.dp).size(size.dp) + +/** + * The comparisons `WalkedArtifactDefaultOmissionTest` needs, and why each is written the way it is. + * + * A default is only *observable* where the value it supplies differs from what a caller could have + * accidentally passed. Compose's `padding` defaults are all `0.dp`, so "omitted" and "passed zero" + * produce equal modifiers and would prove nothing. These three do not have that problem: + * + * | comparison | the default it reaches | why Python could not have faked it | + * |---|---|---| + * | [equalsFillMaxWidth] | `fraction = 1f` | a body that passed `0f` into the omitted slot builds a different `FillElement`, and [equalsFillMaxWidthFraction] is the control that says so | + * | [equalsWrapContentSize] | `align = Alignment.Center` | `Alignment` crosses as a `TypeTag.OBJECT` handle and **no bound declaration anywhere produces one**, so if the default were not reached there would be nothing to put in the slot at all. This is `docs/archive/pythonx-adapter-design.md` §4.5's argument in a single function | + * | [equalsVerticalPadding] | `start`, `top` and `end` | the argument that *is* written sits in the middle of the list, which §4.5's arity-prefix candidate cannot express | + */ +fun equalsFillMaxWidth(modifier: Modifier): Boolean = modifier == Modifier.fillMaxWidth() + +/** The negative control for [equalsFillMaxWidth]: the same call with the fraction written out. */ +fun equalsFillMaxWidthFraction(modifier: Modifier, fraction: Double): Boolean = + modifier == Modifier.fillMaxWidth(fraction.toFloat()) + +fun equalsWrapContentSize(modifier: Modifier): Boolean = modifier == Modifier.wrapContentSize() + +/** The negative control for [equalsWrapContentSize]: an alignment that is not the default. */ +fun equalsWrapContentSizeTopStart(modifier: Modifier): Boolean = + modifier == Modifier.wrapContentSize(Alignment.TopStart) + +fun equalsVerticalPadding(modifier: Modifier, vertical: Double): Boolean = + modifier == Modifier.padding(vertical = vertical.dp) + +/** The negative control for [equalsVerticalPadding]: the same number in the *horizontal* slot. */ +fun equalsHorizontalPadding(modifier: Modifier, horizontal: Double): Boolean = + modifier == Modifier.padding(horizontal = horizontal.dp) diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/PythonxModules.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/PythonxModules.kt new file mode 100644 index 000000000..c216beb39 --- /dev/null +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/PythonxModules.kt @@ -0,0 +1,17 @@ +package fixture.artifact + +/** + * The value classes these tests may write as a raw primitive. + * + * There is no module map beside this any more. The binder does not rename Kotlin namespaces at + * all -- a Kotlin package is importable under its own name and nothing else -- so there is nothing + * to map. What a `pythonx.*` module is called, and what it restructures, is the business of the + * real distribution that ships those modules on disk. + * + * This set survives because it is a different kind of fact: whether a value class may be written + * as the primitive it wraps is a property of the library that declares it, and no bytecode + * witnesses it. `Dp`'s public constructor is the identity on its float, so `padding(16)` and + * `padding(Dp(16f))` mean the same thing; `Color` and `TextUnit` pack several fields into one + * value and a raw number would decode as something else. + */ +internal val PYTHONX_RAW_VALUE_CLASSES: Set = setOf("androidx.compose.ui.unit.Dp") diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt new file mode 100644 index 000000000..2169d808d --- /dev/null +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt @@ -0,0 +1,180 @@ +package fixture.artifact + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Issue #31's second completion criterion: the stub's signature for a declaration is the one + * `inspect.signature` answers at run time. + * + * The stubs are the ones the **build** generated (`generatePythonStubs`, over the real Compose jars + * this module resolves), and the runtime is the real one: the same table installed into a real + * interpreter through `PythonProxySource`, whose published functions carry `__wrapped__` carriers that + * `KotlinSurface` builds `inspect.Signature`s from. Both are read **inside Python** -- the stub with + * `ast`, the runtime with `inspect` -- and compared declaration by declaration over every stubbed + * function in the modules below, not a hand-picked sample, so a rule changed on one side only fails + * here. + * + * What is compared, per parameter: its name, its kind (positional-only, positional-or-keyword, + * keyword-only), whether it has a default, and its type. The type is where the two sides speak + * different dialects -- the runtime says the declared Kotlin name as a string + * (`'androidx.compose.ui.unit.Dp'`), the stub says a Python annotation -- so the comparison is the + * mapping the stub documents: a Kotlin primitive is its Python builtin, a function type is a + * `Callable`, and any other class is that class's stub, named by its fully qualified name. + * + * Skipped, and counted so that the skip cannot grow silently: a declaration whose runtime signature is + * the generic `(*args, **kwargs)` (a Kotlin parameter name Python cannot spell, or a Java class + * compiled without `-parameters` -- the stub is positional-only there, `__a`, and the runtime + * has no names to compare). + */ +class StubSignatureAgreesWithRuntimeTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + private val stubDir: String + get() = System.getProperty("python.multiplatform.stubDir") + ?: error("the stubDir system property is not set; see build.gradle.kts") + + @Test + fun everyStubbedSignatureMatchesInspectSignatureAtRunTime() { + Python3.exec( + """ + import ast, importlib, inspect + + STUBS = r'$stubDir' + MODULES = [ + 'androidx.compose.foundation.layout', + 'androidx.compose.foundation', + 'kotlin.text', + ] + BUILTIN = { + 'kotlin.Boolean': 'bool', 'kotlin.Byte': 'int', 'kotlin.Short': 'int', 'kotlin.Int': 'int', + 'kotlin.Long': 'int', 'kotlin.Float': 'float', 'kotlin.Double': 'float', 'kotlin.String': 'str', + 'kotlin.Char': 'str', 'kotlin.ByteArray': 'bytes', 'kotlin.Unit': 'None', 'kotlin.Any': '_t.Any', + } + + TAG = {'INT': 'int', 'FLOAT': 'float', 'BOOLEAN': 'bool', 'STRING': 'str', 'BYTES': 'bytes', 'UNIT': 'None'} + + def annotation_agrees(runtime, stub_node, tag): + if stub_node is None: + return runtime in ('', None, inspect.Parameter.empty) + stub = ast.unparse(stub_node) + parts = [part for part in stub.split(' | ') if part != 'None'] or ['None'] + if runtime is inspect.Parameter.empty or not runtime: + return False + runtime = runtime.rstrip('?') + if runtime.startswith('kotlin.Function') or runtime.startswith('@Composable'): + return stub.startswith('_t.Callable') + if runtime in BUILTIN: + return stub == BUILTIN[runtime] or parts == [BUILTIN[runtime]] or parts[0].endswith('Any') + if tag in TAG: + # a value class bound as its primitive: the raw number crosses (`Dp | float`, or `float` + # where the class name is taken by a function in its module) + return TAG[tag] in parts + # an object: the stub class named by its FQN (or bare inside its own module), or `Any` + # where a function in its module owns the name -- never an `int` handle + simple = runtime.rsplit('.', 1)[-1] + return parts[0].endswith('Any') or runtime in stub or parts[0] == simple + + def stub_parameters(fn): + a = fn.args + positional = a.posonlyargs + a.args + defaults = [None] * (len(positional) - len(a.defaults)) + list(a.defaults) + out = [(p.arg, 'POSITIONAL_ONLY' if i < len(a.posonlyargs) else 'POSITIONAL_OR_KEYWORD', + d is not None, p.annotation) for i, (p, d) in enumerate(zip(positional, defaults))] + for p, d in zip(a.kwonlyargs, a.kw_defaults): + out.append((p.arg, 'KEYWORD_ONLY', d is not None, p.annotation)) + return out + + compared = 0 + skipped = [] + problems = [] + for module_name in MODULES: + path = STUBS + '/' + module_name.replace('.', '/') + '/__init__.pyi' + tree = ast.parse(open(path).read()) + module = importlib.import_module(module_name) + for node in tree.body: + if not isinstance(node, ast.FunctionDef): + continue + if any(isinstance(d, ast.Attribute) and d.attr == 'overload' for d in node.decorator_list): + continue # an overload set's base name has no single signature: `(*args, **kwargs)` + runtime_fn = getattr(module, node.name, None) + if runtime_fn is None: + problems.append(module_name + '.' + node.name + ': stubbed but not on the module') + continue + try: + signature = inspect.signature(runtime_fn) + except (TypeError, ValueError): + skipped.append(module_name + '.' + node.name) + continue + runtime_parameters = list(signature.parameters.values()) + row = runtime_fn.__kotlin_rows__[0] + row_names, row_tags = row[4] or (), row[5] or () + + def tag_of(parameter_name): + key = '' if parameter_name in ('receiver', '_receiver') and '' in row_names else parameter_name + return row_tags[row_names.index(key)] if key in row_names else None + if any(p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD) for p in runtime_parameters): + skipped.append(module_name + '.' + node.name) + continue + stub = stub_parameters(node) + label = module_name + '.' + node.name + if [p.name for p in runtime_parameters] != [s[0] for s in stub]: + # a Java-sourced or keyword-named declaration: the stub is positional-only `__a` + if all(s[0].startswith('__a') for s in stub[:len(runtime_parameters)]) or any(s[0].startswith('__a') for s in stub): + skipped.append(label) + continue + problems.append(label + ': names ' + repr([p.name for p in runtime_parameters]) + ' vs stub ' + repr([s[0] for s in stub])) + continue + for p, (name, kind, has_default, ann) in zip(runtime_parameters, stub): + if p.kind.name != kind: + problems.append(label + '.' + name + ': kind ' + p.kind.name + ' vs stub ' + kind) + if (p.default is not p.empty) != has_default: + problems.append(label + '.' + name + ': default ' + repr(p.default) + ' vs stub ' + repr(has_default)) + if not annotation_agrees(p.annotation, ann, tag_of(p.name)): + problems.append(label + '.' + name + ': type ' + repr(p.annotation) + ' vs stub ' + (ast.unparse(ann) if ann else 'none')) + if not annotation_agrees(signature.return_annotation, node.returns, row[7]): + problems.append(label + ' -> ' + repr(signature.return_annotation) + ' vs stub ' + ast.unparse(node.returns)) + compared += 1 + + assert compared >= 30, 'only ' + str(compared) + ' signatures were compared; skipped ' + repr(skipped[:10]) + assert not problems, str(len(problems)) + ' stub/runtime disagreements, first: ' + repr(problems[:12]) + print('compared', compared, 'skipped', len(skipped)) + """.trimIndent(), + ) + } + + @Test + fun theComparisonCanFailBecauseTheStubDirIsReallyRead() { + // A comparison that cannot fail is not one (AGENTS.md rule 8): the stub it reads must be the + // generated one, so a path with no stub in it has to raise rather than compare nothing. + val raised = runCatching { + Python3.exec( + """ + open(r'$stubDir/androidx/compose/foundation/layout/no_such_stub.pyi').read() + """.trimIndent(), + ) + }.isFailure + assertTrue(raised) + } +} diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt new file mode 100644 index 000000000..f5c06d447 --- /dev/null +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt @@ -0,0 +1,300 @@ +package fixture.artifact + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonCallableScope +import python.multiplatform.ffi.pythonx.PythonCallables +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **A Python lambda in a walked, non-`@Composable` declaration** -- `Modifier.clickable(onClick=…)` + * out of the real `foundation-desktop` jar, and a stdlib declaration that actually *runs* the + * callable it is handed. + * + * ### What this is the end of + * + * `a179b747` made a function-typed slot describable and fillable, and both halves are producer- + * independent -- `ArtifactScanner.functionSlotTypeName` writes the signature, `pythonx._make_function` + * builds a `FunctionN` out of it. Only composables could use them, because only composables reach + * their callee through generated **bytecode**: a `CHECKCAST` to `kotlin/jvm/functions/FunctionN` + * needs no Kotlin name, while generated **source** has to spell the type it casts to, and + * `resolveKotlinType` correctly refuses to spell a classifier with no class file anywhere. + * `ArtifactScannerTest` counted the price: 44 of the 46 declined public top-level `Modifier` + * extensions declined for exactly that. + * + * ### Why `measureTimeMillis` is here beside `clickable` + * + * Because **no Compose callback runs outside a composition**, and a test that only ever asserts "the + * call returned a Modifier" cannot tell a wrapper Compose would invoke from one it would not. + * `clickable` is `composed { }`, `drawBehind` is a node, `semantics` is an element -- every one of + * them *stores* the lambda for a composition to call later. `kotlin.system.measureTimeMillis(block: + * () -> Unit)` has the identical slot (`kotlin.Function0()->kotlin.Unit`, same walker, same + * fragment, same `PythonFunction` wrapper) and invokes it at the moment it is called, so it is what + * makes "the callback is called" a measurement rather than an inference. Rendering a real click is + * `:ksp-fixtures:compose`'s job and needs the Compose compiler plugin this module deliberately does + * not apply. + * + * ### Lifetime + * + * A Python callable crossing into Kotlin needs a [PythonCallableScope] open -- Compose keeps calling + * a `content` long after the statement that wrote it dropped Python's last reference, so something on + * the Kotlin side has to hold one, and the scope is the only candidate that closes at the right time + * (`PythonCallables`' KDoc has the other three and why they fail). Outside a composition that scope + * is explicit, which makes it something this test can count: [scope] holds exactly one wrapper per + * callable that crossed, gives it back on `close`, and answers `0` for every later `close`. + * + * `agent-rules` §14 is why the leak and the double release are **separate** assertions with a second + * scope in between: a root released twice does not fail where it happens, it fails when the slot it + * freed has since been handed to somebody else. + */ +class WalkedArtifactCallbackTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The measurement: a Python `lambda` handed to a walked Kotlin declaration **runs**. + * + * `_ran` is appended to from inside the lambda, so the assertion is about the callable having + * been invoked and not about the call having returned. `_never` is the control for exactly that + * -- an identically shaped lambda that was never passed anywhere, which must stay empty, so a + * mutation that appended on *construction* rather than on invocation fails here. + */ + @Test + fun aPythonCallableHandedToAWalkedDeclarationIsInvoked() { + PythonCallables.withScope(PythonCallables.newScope()) { + Python3.exec( + """ + from kotlin.system import measureTimeMillis + + _ran = [] + _never = [] + _control = lambda: _never.append('should not run') + + _elapsed = measureTimeMillis(lambda: _ran.append('ran')) + + assert _ran == ['ran'], 'the Python callable was not invoked: ' + repr(_ran) + assert _never == [], 'a callable nobody passed anywhere ran: ' + repr(_never) + assert isinstance(_elapsed, int), ( + 'measureTimeMillis returns a Long, which crosses as INT; got ' + repr(_elapsed) + ) + """.trimIndent(), + ) + } + } + + /** + * The arity rule, from the Python side, which is what makes the test above non-vacuous in the + * other direction. + * + * `() -> Unit` invokes with nothing, so a callable that *wants* an argument is refused at the + * crossing rather than at the invocation -- which for a stored Compose callback would otherwise + * be a failure on some later recomposition with no statement to blame. A callable that takes + * fewer than the slot supplies is the opposite case and is allowed (`Column { Text(…) }`); there + * is nothing to drop here, so this pins only the refusal. + */ + @Test + fun aCallableThatWantsMoreThanTheSlotSuppliesIsRefusedAtTheCrossing() { + PythonCallables.withScope(PythonCallables.newScope()) { + Python3.exec( + """ + from kotlin.system import measureTimeMillis + try: + measureTimeMillis(lambda wanted: None) + raise AssertionError('a one-argument callable filled a zero-argument slot') + except AssertionError: + raise + except Exception as e: + assert '0 argument' in str(e), 'unexpected refusal: ' + repr(e) + """.trimIndent(), + ) + } + } + + /** + * The acceptance criterion: **`Modifier.clickable` called from Python, against the real Compose + * jar**, with its `onClick` written as a Python lambda. + * + * Everything but the receiver and `onClick` is left to Kotlin, which is the shape a caller would + * actually write and which exercises the presence branching and the new slot in the same call: + * `enabled`, `onClickLabel` and `role` all declare defaults, and `onClickLabel: String?` is why + * `clickable` stayed declined even after its lambda became bindable (a nullable `String` was + * refused by a rule whose stated justification -- numeric narrowing -- does not apply to a + * reference; see `nullablePrimitiveBoundaryTypeOf`). + * + * The controls are Compose's own structural equality and its own traversal, so neither can pass + * because the boundary did nothing: an unclicked `Modifier` is `Modifier` itself and folds to + * zero elements. + * + * The overload is `(enabled, onClickLabel, role, interactionSource, onClick)`: Compose 1.6's + * `(enabled, onClickLabel, role, onClick)` is binary-only (hidden) by Compose Multiplatform 1.11, + * and its source-visible successor adds a defaulted `interactionSource`, left to Kotlin here too. + */ + @Test + fun clickableIsBuiltInPythonFromTheRealComposeJar() { + PythonCallables.withScope(PythonCallables.newScope()) { + Python3.exec( + """ + from androidx.compose.foundation import clickable__Boolean_String_Role_MutableInteractionSource_Unit as clickable + from fixture.artifact import emptyModifier, modifierElementCount, isTheEmptyModifier + from fixture.artifact import describeModifier + + _empty = emptyModifier() + assert isTheEmptyModifier(_empty), 'the seed is not Modifier itself' + assert modifierElementCount(_empty) == 0 + + _clicks = [] + _tappable = clickable(_empty, onClick=lambda: _clicks.append('tap')) + + assert not isTheEmptyModifier(_tappable._pm_handle), ( + 'clickable returned the receiver unchanged' + ) + assert modifierElementCount(_tappable._pm_handle) >= 1, ( + 'clickable produced no element: ' + describeModifier(_tappable._pm_handle) + ) + """.trimIndent(), + ) + } + } + + /** + * The other half of the slot: something that is **not** callable is refused with the signature, + * rather than falling through to "expected a handle". + * + * Kotlin never hands a `FunctionN` back out for Python to pass in again, so a handle is not the + * answer for this slot and saying so would send the caller looking for one. + */ + @Test + fun aNonCallableInTheOnClickSlotIsRefusedWithTheSignature() { + PythonCallables.withScope(PythonCallables.newScope()) { + Python3.exec( + """ + from androidx.compose.foundation import clickable__Boolean_String_Role_MutableInteractionSource_Unit as clickable + from fixture.artifact import emptyModifier + + try: + clickable(emptyModifier(), onClick='not a lambda') + raise AssertionError('a string filled a () -> Unit slot') + except AssertionError: + raise + except Exception as e: + assert 'callable' in str(e), 'unexpected refusal: ' + repr(e) + """.trimIndent(), + ) + } + } + + /** + * The lifetime contract, counted rather than assumed, and with the double release pinned apart + * from the leak. + * + * The wrapper is a `HandleTable` root for as long as its scope is open -- that is the whole + * reason the scope exists -- so `liveCount` must rise by exactly one while the callable is live + * and come back to where it started when the scope closes. `measureTimeMillis` is used rather + * than `clickable` because its result is an `INT` and roots nothing of its own, so the one handle + * counted here is unambiguously the callable's. + * + * The second `close` is asserted **with another scope's root outstanding**: a double release that + * freed a slot would free *that* one, and a test that closed twice over an empty table could not + * tell the difference. `agent-rules` §14, one level up from the reference counts. + */ + @Test + fun theScopeHoldsOneRootPerCallableAndASecondCloseFreesNothing() { + val baseline = settledBaseline() + + val first = PythonCallables.newScope() + PythonCallables.withScope(first) { + Python3.exec( + """ + from kotlin.system import measureTimeMillis + measureTimeMillis(lambda: None) + """.trimIndent(), + ) + assertEquals( + baseline + 1, HandleTable.liveCount, + "the wrapper must be rooted while its scope is open -- Compose calls a stored " + + "callback long after the statement that passed it returned", + ) + } + assertEquals(1, first.liveCount, "the scope is what holds the Python reference") + + assertEquals(1, first.close(), "close reports what it released") + assertEquals(baseline, HandleTable.liveCount, "and the root came back") + + // A second scope takes the slot the release above just freed, so the double release below + // has something to be wrong about. + val second = PythonCallables.newScope() + PythonCallables.withScope(second) { + Python3.exec("measureTimeMillis(lambda: None)") + } + assertEquals(baseline + 1, HandleTable.liveCount) + + assertEquals(0, first.close(), "a second close must release nothing") + assertEquals( + baseline + 1, HandleTable.liveCount, + "the second close of an already-closed scope must not free the new owner's root", + ) + + assertEquals(1, second.close()) + assertEquals(baseline, HandleTable.liveCount) + } + + /** + * A callable cannot cross with no scope open, and the reference it was given is not leaked when + * it is refused. + * + * The refusal is the honest answer rather than an inconvenience: a wrapper with no holder would + * be invoked by Compose after the `PyObject` behind it had been collected, which on a target with + * no cleaner is never and on one with a cleaner is whenever. + */ + @Test + fun aCallableWithNoScopeOpenIsRefusedRatherThanRooted() { + val baseline = settledBaseline() + assertEquals(0, PythonCallables.openScopeCount, "no scope may be left open by an earlier test") + + Python3.exec( + """ + from kotlin.system import measureTimeMillis + try: + measureTimeMillis(lambda: None) + raise AssertionError('a callable crossed with nowhere to live') + except AssertionError: + raise + except Exception as e: + assert 'scope' in str(e), 'unexpected refusal: ' + repr(e) + """.trimIndent(), + ) + assertTrue( + HandleTable.liveCount <= baseline, + "a refused crossing must root nothing: ${HandleTable.liveCount} against $baseline", + ) + } + + /** [HandleTable.liveCount] with anything an earlier test left as garbage already collected. */ + private fun settledBaseline(): Int { + Python3.exec("import gc\ngc.collect()") + return HandleTable.liveCount + } +} diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactComposeModifierTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactComposeModifierTest.kt new file mode 100644 index 000000000..ff927a99f --- /dev/null +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactComposeModifierTest.kt @@ -0,0 +1,286 @@ +package fixture.artifact + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * `Modifier.padding(16.dp).size(24.dp)`, built from Python, out of the real Compose jars. + * + * ### What this is the end of + * + * `docs/design/kotlin-extensions-in-python.md` §3 measured the artefact walker binding **zero** + * declarations from all 19 Compose desktop jars, and named two gates that each produced that zero on + * their own: the metadata-kind gate (a Kotlin top-level function hides behind a file facade whose + * JVM name Kotlin cannot spell) and the type gate (`Modifier` is an ordinary interface, and + * `boundaryTypeOf` had no representation for one). A third rule, dropping any name carried by more + * than one overload, took `padding`, `size`, `background`, `border` and 29 other `Modifier` names + * even once those were open. + * + * All three are gone, and this is what that buys: a chain assembled in Python, out of Compose's own + * `androidx.compose.foundation.layout` functions, under Compose's own names. + * + * ### Why no `Composer` appears anywhere + * + * A `Modifier` extension is not a `@Composable` -- §2.6 counted exactly one function that is both, + * out of 500 -- so `padding` is an ordinary function returning an ordinary object and needs no + * composition to run. Composables are a separate problem with a separate blocker + * (`docs/archive/pythonx-adapter-design.md` §5.3) and nothing here touches them. This module does not even + * apply the Compose compiler plugin. + * + * ### What Python actually holds + * + * An integer. A `Modifier` crosses as a `HandleTable` handle (`TypeTag.OBJECT`), so each link of the + * chain is a fresh handle and **each one is a strong root until something releases it**. + * + * This used to leak three per run, then two once the two walked links came back wrapped so Python + * drops their handles when it drops the chain -- **one still leaked**: `emptyModifier()` is a KSP + * entry, and KSP emitted no `returnTypeName`, which is the gate, because `TypeTag.OBJECT` also + * covers a `PyObject` that may itself be an `int`, and owning one of those would release a handle + * nobody issued. + * + * That gap is closed: `FragmentScanner` now fills `returnTypeName` (and `paramNames`/ + * `paramTypeNames`/`paramHasDefault`) at every call site that produces a [CallableEntryModel][ + * python.multiplatform.ksp.CallableEntryModel], so a KSP-generated `TypeTag.OBJECT` result is + * owned exactly like a walked one is. [emptyModifierGivesItsHandleBackWhenPythonDropsIt] is the + * measured claim -- a [HandleTable.liveCount] that returns to baseline once Python drops the + * result, not "it did not crash" -- and + * [emptyModifierDoesNotFreeANewOwnersSlotOnADoubleRelease] pins the other half that turning + * ownership on puts at risk: a double release must not free a slot a new owner has since taken. + * + * Wrapping each return in the class rendered for its own type is still `§4.1`'s proxy and still + * does not exist: the walker emits no `ReflectedClass`, so no rendered class has ever shared a name + * with a walked return type. + * + * ### The overload names + * + * `padding__Dp` and `size__Dp` are not decoration. `androidx.compose.foundation.layout.padding` has + * four overloads -- `PaddingValues`, one `Dp`, two `Dp`s, four `Dp`s -- and the walker refuses to + * pick between them (see `ArtifactScanner.disambiguateOverloads`). The suffix is the caller saying + * which one, and `padding__Dp` is the one a reader means by "16dp of padding". + */ +class WalkedArtifactComposeModifierTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The whole claim in one Python block. + * + * `emptyModifier` is this module's own (KSP); `padding__Dp` and `size__Dp` are + * `foundation-layout-desktop-1.6.11.jar`'s, walked at build time. The assertion is Compose's own + * structural equality against a chain `ComposeSeed.kt` builds independently, so it cannot pass + * unless Compose's `padding` ran with `16.0` and Compose's `size` ran with `24.0`, in that + * order, on the object Python passed in. + */ + @Test + fun aModifierChainIsAssembledInPythonFromTheComposeJars() { + Python3.exec( + """ + from androidx.compose.foundation.layout import padding__Dp, size__Dp + from fixture.artifact import emptyModifier, describeModifier, modifierElementCount + from fixture.artifact import equalsPaddingThenSize, isTheEmptyModifier + + _empty = emptyModifier() + assert isTheEmptyModifier(_empty), 'the handle did not resolve to Modifier itself' + assert modifierElementCount(_empty) == 0, 'the empty modifier has elements' + + _padded = padding__Dp(_empty, 16.0) + assert not isTheEmptyModifier(_padded), 'padding returned the receiver unchanged' + assert modifierElementCount(_padded) == 1, ( + 'padding produced ' + str(modifierElementCount(_padded)) + ' elements' + ) + + _chained = size__Dp(_padded, 24.0) + assert modifierElementCount(_chained) == 2, ( + 'the chain has ' + str(modifierElementCount(_chained)) + ' elements: ' + + describeModifier(_chained) + ) + assert equalsPaddingThenSize(_chained, 16.0, 24.0), ( + 'the chain is not Modifier.padding(16.dp).size(24.dp): ' + describeModifier(_chained) + ) + """.trimIndent(), + ) + } + + /** + * The negative half of the one above, and the reason to trust it. + * + * A test that only ever asserts a `True` cannot tell "Compose ran with 16dp" from "the assertion + * never executed". This drives the same chain with a value it was not built with and requires + * the comparison to answer `False` -- so the `assert` in the test above is a statement about the + * argument that crossed, not about the boundary having done anything at all. + */ + @Test + fun theSameChainComparedAgainstADifferentPaddingDoesNotMatch() { + Python3.exec( + """ + from androidx.compose.foundation.layout import padding__Dp, size__Dp + from fixture.artifact import emptyModifier, equalsPaddingThenSize + + _chained = size__Dp(padding__Dp(emptyModifier(), 16.0), 24.0) + assert not equalsPaddingThenSize(_chained, 15.0, 24.0), 'a 16dp chain matched 15dp' + assert not equalsPaddingThenSize(_chained, 16.0, 25.0), 'a 24dp chain matched 25dp' + """.trimIndent(), + ) + } + + /** + * The overload rule, from the Python side. + * + * The bare name is absent by design: four `padding` overloads would otherwise have to be + * arbitrated between, and the one a sort order picks for `padding` is the `PaddingValues` + * overload -- the only unmangled one, and the one a Python caller is least likely to want + * (`docs/design/kotlin-extensions-in-python.md` §3). An `AttributeError` naming a declaration that does + * not exist is the honest answer; a silent call to the wrong overload is not. + */ + @Test + fun theBareNameOfAnOverloadSetIsNotBoundAndItsMembersAre() { + assertEquals( + false, + UpcallTable.resolve("androidx.compose.foundation.layout.padding").isValid, + "four overloads must not be arbitrated down to one", + ) + assertTrue(UpcallTable.resolve("androidx.compose.foundation.layout.padding__Dp").isValid) + assertTrue(UpcallTable.resolve("androidx.compose.foundation.layout.padding__Dp_Dp").isValid) + assertTrue(UpcallTable.resolve("androidx.compose.foundation.layout.padding__Dp_Dp_Dp_Dp").isValid) + assertTrue(UpcallTable.resolve("androidx.compose.foundation.layout.padding__PaddingValues").isValid) + } + + /** + * The entry carries what a Python adapter needs to build a keyword-argument surface over it -- + * `docs/archive/pythonx-adapter-design.md` §2.4's table, which recorded every one of these as missing. + * + * `padding__Dp` is a good witness for all of them at once: it is an extension (so slot 0 is a + * receiver, not a first parameter), its declared parameter type is `Dp` while its `TypeTag` is + * `FLOAT` (so the tag alone cannot describe it), and its `all` parameter is the name a keyword + * call would use. + */ + @Test + fun aWalkedEntryCarriesItsDeclarationAndNotOnlyItsTags() { + val padding = UpcallTable.callable(UpcallTable.resolve("androidx.compose.foundation.layout.padding__Dp")) + assertEquals(true, padding.isExtension) + assertEquals("androidx.compose.ui.Modifier", padding.receiverTypeName) + assertEquals(listOf("", "all"), padding.paramNames) + assertEquals(listOf("androidx.compose.ui.Modifier", "androidx.compose.ui.unit.Dp"), padding.paramTypeNames) + assertEquals("androidx.compose.ui.Modifier", padding.returnTypeName) + // Defaults are carried and nothing acts on them: every generated body passes every + // argument. `padding(all:)` declares none; `padding(horizontal:, vertical:)` declares two, + // which is what makes this pair worth asserting together. + assertEquals(listOf(false, false), padding.paramHasDefault) + val symmetric = UpcallTable.callable(UpcallTable.resolve("androidx.compose.foundation.layout.padding__Dp_Dp")) + assertEquals(listOf("", "horizontal", "vertical"), symmetric.paramNames) + assertEquals(listOf(false, true, true), symmetric.paramHasDefault) + } + + /** + * The measured claim behind "one still leaks" above, and the one this fix is for: a + * [python.multiplatform.reflection.HandleTable] root count, not "it did not crash". `emptyModifier` + * is a top-level [python.multiplatform.reflection.CallableKind.FUNCTION] returning `TypeTag.OBJECT` + * with no receiver, so it is a KSP entry the same way [aWalkedEntryCarriesItsDeclarationAndNotOnlyItsTags]'s + * `padding__Dp` is a walked one -- and it is what should now carry `returnTypeName` + * (`FragmentScanner.topLevelFunctionEntry`), the field `PythonProxySource.ownedTypeOf` gates + * ownership on. + * + * Both ends are checked, per `OwnedResultLifetimeTest`'s own rule: `baseline + 1` while Python still + * holds the object is what tells "a root was taken" apart from "nothing was ever registered", and + * only `baseline` again after `gc.collect()` is the actual leak claim. + */ + @Test + fun emptyModifierGivesItsHandleBackWhenPythonDropsIt() { + // The specific field this whole change is about: `FragmentScanner.topLevelFunctionEntry` + // filling `returnTypeName` from the declared Kotlin return type is what + // `PythonProxySource.ownedTypeOf` gates ownership on. + val entry = UpcallTable.callable(UpcallTable.resolve("fixture.artifact.emptyModifier")) + assertEquals("androidx.compose.ui.Modifier", entry.returnTypeName) + + val baseline = settledBaseline() + + Python3.exec( + """ + from fixture.artifact import emptyModifier + _wm = emptyModifier() + assert type(_wm).__name__ != 'int', ( + 'a KSP FUNCTION returning TypeTag.OBJECT with a returnTypeName must come back ' + 'wrapped, not as a bare handle int' + ) + """.trimIndent(), + ) + assertEquals( + baseline + 1, HandleTable.liveCount, + "emptyModifier() must root exactly one handle while Python still holds it", + ) + + Python3.exec("import gc\n_wm = None\ngc.collect()") + assertEquals( + baseline, HandleTable.liveCount, + "the KSP entry's handle must come back once Python has dropped it -- this is the leak " + + "this class's own KDoc records; a producer that supplies returnTypeName is the fix", + ) + } + + /** + * The other half of turning ownership on: a double release must not free a slot that has + * already been handed to a new owner. [OwnedResultLifetimeTest.releasingAnOwnedResultTwiceDoesNotFreeTheSlotsNewOwner] + * pins the general mechanism (`_PmObject.__del__` clears its own handle before releasing, and + * `HandleTable`'s generation tag is the second line of defence); this pins it end to end through + * the KSP-generated proxy specifically, since that is the path this change turns ownership on for. + */ + @Test + fun emptyModifierDoesNotFreeANewOwnersSlotOnADoubleRelease() { + val baseline = settledBaseline() + + Python3.exec( + """ + from fixture.artifact import emptyModifier + _wm = emptyModifier() + """.trimIndent(), + ) + assertEquals(baseline + 1, HandleTable.liveCount) + + Python3.exec("_wm.__del__()") + assertEquals(baseline, HandleTable.liveCount, "the explicit release must have done the work") + + // A fresh handle takes the slot the release above just freed. An unguarded double release + // would free *this* object instead, silently handing one slot to two owners -- + // `agent-rules.md`'s §14 failure mode, one level up from the reference counts. + Python3.exec("_wm_b = emptyModifier()") + assertEquals(baseline + 1, HandleTable.liveCount) + + Python3.exec("_wm.__del__()") + assertEquals( + baseline + 1, HandleTable.liveCount, + "the second release of the already-released handle must be a no-op, not free the new " + + "owner's slot", + ) + + Python3.exec("import gc\n_wm = None\n_wm_b = None\ngc.collect()") + assertEquals(baseline, HandleTable.liveCount) + } + + /** [HandleTable.liveCount] with anything an earlier test left as garbage already collected. */ + private fun settledBaseline(): Int { + Python3.exec("import gc\n_wm = None\n_wm_b = None\ngc.collect()") + return HandleTable.liveCount + } +} diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt new file mode 100644 index 000000000..25adbc2cd --- /dev/null +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt @@ -0,0 +1,191 @@ +package fixture.artifact + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * `docs/archive/pythonx-adapter-design.md` §4.5, closed against the real jars: **Compose's own functions, + * called from Python with their required arguments and nothing else.** + * + * ### What was wrong + * + * `WalkedArtifactComposeModifierTest` proves the walker reaches Compose, and every call in it passes + * every argument -- because that was all a generated body could do. §4.5 measures what that costs: + * across the `Modifier` surface **254 of 435 parameters (58%) declare a default**, and the values a + * caller would have had to supply instead are objects (`Alignment`, `TextStyle`) the type gate does + * not bind, so Python could not have supplied them at any price. The bound surface was reachable and + * unusable at the same time. + * + * ### What closes it + * + * A walked body now carries one Kotlin call expression per subset of its defaulted parameters and + * selects between them on `args[i] == null` (`ArtifactScanner.presenceBranchedCall`), and `pythonx` + * fills a missing argument with that sentinel. The default value itself is carried nowhere: + * `@Metadata` records only the *flag*, so the only thing that can produce `Alignment.Center` is + * `kotlinc` compiling a call that does not mention `align` -- which is exactly what the branch is. + * + * ### Why these functions + * + * All of them are ordinary functions, not composables -- no composable is in the table at all, since + * the arity check in `ArtifactScanner` declines every one for the synthetic `$composer`/`$changed` + * parameters its JVM signature carries -- and each has a default that **cannot be reproduced from + * the Python side**, so an assertion cannot pass for the wrong reason. `ComposeSeed.kt`'s KDoc has + * the table; the shortest form is `wrapContentSize`, whose default is an `Alignment` no bound + * declaration produces. + * + * Every claim is paired with a control that must answer `False`, for the reason + * `WalkedArtifactComposeModifierTest.theSameChainComparedAgainstADifferentPaddingDoesNotMatch` + * gives: a test that only ever sees a `True` cannot tell "Compose ran with the default" from "the + * comparison never executed". + * + * Asserted inside Python for this module's usual reason: `Python3.exec` raises a Kotlin + * `PyException` carrying the Python error, so a failed `assert` fails the test with its own message. + */ +class WalkedArtifactDefaultOmissionTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + // Both, for different names. `PythonProxySource` publishes this module's own KSP entries + // under their Kotlin packages (`fixture.artifact`), which is where the comparison functions + // live; `pythonx` is the adaptation layer and the only one of the two that fills defaults. + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The judgement, in Compose's own code: two calls that write **no** optional argument. + * + * `fillMaxWidth(m)` and `wrapContentSize(m)` are called with the receiver alone -- + * `f(x)`, not `f(x, None, None)` -- and the receiver is the only required parameter either of + * them declares. + */ + @Test + fun composeFunctionsAreCalledWithTheirRequiredArgumentsAlone() { + Python3.exec( + """ + from androidx.compose.foundation.layout import fillMaxWidth, wrapContentSize + from fixture.artifact import emptyModifier + from fixture.artifact import equalsFillMaxWidth, equalsFillMaxWidthFraction + from fixture.artifact import equalsWrapContentSize, equalsWrapContentSizeTopStart + + _filled = fillMaxWidth(emptyModifier()) + assert equalsFillMaxWidth(_filled._pm_handle), ( + 'fillMaxWidth() did not produce Modifier.fillMaxWidth()' + ) + # The controls. `fraction` defaults to 1f, so a body that had passed the sentinel through + # as a number would have produced one of these instead. + assert not equalsFillMaxWidthFraction(_filled._pm_handle, 0.5), 'a default of 1f matched 0.5' + assert not equalsFillMaxWidthFraction(_filled._pm_handle, 0.0), 'a default of 1f matched 0.0' + + _wrapped = wrapContentSize(emptyModifier()) + assert equalsWrapContentSize(_wrapped._pm_handle), ( + 'wrapContentSize() did not produce Modifier.wrapContentSize()' + ) + assert not equalsWrapContentSizeTopStart(_wrapped._pm_handle), ( + 'Alignment.Center matched Alignment.TopStart' + ) + """.trimIndent(), + ) + } + + /** + * The half an arity-prefix scheme cannot reach (§4.5's first candidate): one argument written in + * the **middle** of a defaulted list. + * + * `padding(vertical = 8.dp)` leaves `start`, `top` and `end` to Compose. The control writes the + * same number into `horizontal` instead, which is a different modifier -- so the claim is about + * *which* parameter the keyword selected, not merely that a call happened. + */ + @Test + fun oneKeywordArgumentSelectsItsParameterAndLeavesTheRestToKotlin() { + Python3.exec( + """ + from androidx.compose.foundation.layout import padding__Dp_Dp + from fixture.artifact import emptyModifier, equalsVerticalPadding, equalsHorizontalPadding + + _padded = padding__Dp_Dp(emptyModifier(), vertical=8.0) + assert equalsVerticalPadding(_padded._pm_handle, 8.0), ( + 'vertical=8 did not produce Modifier.padding(vertical = 8.dp)' + ) + assert not equalsHorizontalPadding(_padded._pm_handle, 8.0), 'the keyword chose the wrong slot' + assert not equalsVerticalPadding(_padded._pm_handle, 9.0), '8dp matched 9dp' + """.trimIndent(), + ) + } + + /** + * The table's side of it, read out of the real jar rather than out of a fixture. + * + * `paramHasDefault` was carried and read by nothing -- + * `WalkedArtifactComposeModifierTest.aWalkedEntryCarriesItsDeclarationAndNotOnlyItsTags` asserted + * exactly that. It is now the walker's statement about which slots its generated body can leave + * out, and it is what `pythonx._bind` reads. `wrapContentSize` is the witness because its two + * defaults are of different kinds -- an OBJECT and a BOOLEAN -- so a body that only handled + * primitives would show up here. + */ + @Test + fun theWalkedEntrySaysWhichSlotsItCanOmit() { + val wrap = UpcallTable.callable( + UpcallTable.resolve("androidx.compose.foundation.layout.wrapContentSize"), + ) + assertEquals(listOf("", "align", "unbounded"), wrap.paramNames) + assertEquals(listOf(false, true, true), wrap.paramHasDefault) + assertEquals( + listOf("androidx.compose.ui.Modifier", "androidx.compose.ui.Alignment", "kotlin.Boolean"), + wrap.paramTypeNames, + ) + val fill = UpcallTable.callable( + UpcallTable.resolve("androidx.compose.foundation.layout.fillMaxWidth"), + ) + assertEquals(listOf(false, true), fill.paramHasDefault) + // And a declaration that declares none is unchanged, which is 43 of this jar's 70 entries. + val paddingAll = UpcallTable.callable( + UpcallTable.resolve("androidx.compose.foundation.layout.padding__Dp"), + ) + assertEquals(listOf(false, false), paddingAll.paramHasDefault) + } + + /** + * The raw boundary is unchanged and still exact, which is what makes this design cheap. + * + * `pythonx` fills the omitted slots *before* it calls, so `_pm_invoke` still receives a tuple of + * exactly `arity` items and `UpcallTrampoline.unmarshalArguments` still refuses anything else. + * Nothing was added to the marshalling boundary, to `ExposedCallable`, or to any of the five + * bootstraps -- `None` already crossed as `null` for every tag, which is the whole reason a + * sentinel was available at all. + */ + @Test + fun theRawBoundaryStillRequiresEveryArgument() { + Python3.exec( + """ + _handle = _pm_resolve(b'androidx.compose.foundation.layout.fillMaxWidth') + assert _handle != -1, 'fillMaxWidth is not in the table' + try: + _pm_invoke(_handle, (None,)) + raise AssertionError('the trampoline accepted one argument for a two-slot entry') + except AssertionError: + raise + except Exception as e: + assert 'takes 2 arguments, 1 given' in str(e), 'unexpected refusal: ' + repr(e) + """.trimIndent(), + ) + } +} diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactPythonImportTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactPythonImportTest.kt new file mode 100644 index 000000000..ae8d02744 --- /dev/null +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactPythonImportTest.kt @@ -0,0 +1,178 @@ +package fixture.artifact + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The whole path, end to end, in one interpreter: a jar the build resolved -> ASM -> a generated + * `FunctionTableFragment` -> `UpcallTable` -> `PythonProxySource` -> `from junit.runner.Version + * import id`. + * + * `docs/design/ecosystem.md` §5b settles that the Python surface is an *import statement*, not a resolve + * call, and that a Kotlin fully-qualified name means the original code rather than a wrapper of ours + * wearing its name. This is that claim, for a third-party binary artefact: `junit.runner.Version` is + * JUnit's own package path, and `id()` answers `"4.13.2"` -- a string that exists nowhere except + * inside `junit/runner/Version.class`. + * + * The four names the generated proxies need (`_pm_resolve`, `_pm_invoke`, `_pm_release`, + * `_pm_cancel`) are bound through [UpcallBootstrap.publishToGlobals] -- the public, per-platform + * entry point a consumer outside this repository can call. It is what closes ROADMAP §16f's gap: + * before it existed, this same install had to hand-rebuild `ctypes.CFUNCTYPE`s over `UpcallStub`'s + * addresses, which only `python-multiplatform`'s own test source knew how to do. + */ +class WalkedArtifactPythonImportTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The import statement, and the value it returns. + * + * Asserted inside Python rather than marshalled back out: `Python3.exec` raises a Kotlin + * `PyException` carrying the Python error, so a failed `assert` here fails the test with the + * Python-side message intact -- and the check then costs no boundary crossing of its own that + * could be the thing that actually worked. + */ + @Test + fun aWalkedArtefactIsImportableByItsOwnPackagePath() { + Python3.exec( + """ + from junit.runner.Version import id + + _observed = id() + assert _observed == '4.13.2', 'junit.runner.Version.id() answered ' + repr(_observed) + """.trimIndent(), + ) + } + + /** Arguments cross too, and come back: `truncate` is JUnit's own string shortener, so a result + * that ends in `"..."` could only have come from the jar. */ + @Test + fun aWalkedDeclarationTakesArgumentsAndReturnsAValue() { + Python3.exec( + """ + from junit.runner.BaseTestRunner import truncate + + _observed = truncate('x' * 2000) + assert _observed.endswith('...'), 'truncate answered ' + repr(_observed[-20:]) + assert len(_observed) < 2000, 'truncate did not truncate: ' + str(len(_observed)) + """.trimIndent(), + ) + } + + /** + * Both producers answer in one interpreter, under the names their own languages give them. + * + * `fixture.artifact` is this module's Kotlin package, reached through KSP's `FunctionTable`; + * `junit.runner.Version` is a jar's package, reached through the walker's `ArtifactTable`. + * Nothing in the Python source distinguishes them, which is the point of them sharing one + * `UpcallTable`. + */ + @Test + fun kspAndTheWalkerShareOnePythonNamespace() { + Python3.exec( + """ + from fixture.artifact import whichSideAmIFrom + from junit.runner.Version import id + + assert whichSideAmIFrom() == 'ksp' + assert id() == '4.13.2' + """.trimIndent(), + ) + } + + /** The module object exists under the jar's own dotted path, not only the leaf name -- which is + * what makes `import junit.runner.Version` work as well as `from ... import ...`. */ + @Test + fun theJarsPackagePathIsARealModuleTree() { + Python3.exec( + """ + import sys + + assert 'junit.runner.Version' in sys.modules + assert 'junit.runner' in sys.modules + assert 'junit' in sys.modules + assert sys.modules['junit'].runner.Version.id() == '4.13.2' + """.trimIndent(), + ) + } + + /** + * `kotlin.text.trimIndent`: a top-level Kotlin extension function, reached through a + * `MULTI_FILE_CLASS_FACADE` (`StringsKt`) into a package-private part (`StringsKt__IndentKt`) -- + * exactly the shape `ArtifactScanner`'s KDoc names as unreachable before it read `@Metadata`. + * Called under the name `kotlin.text.trimIndent`, never under either JVM class name, which + * Kotlin (and so Python) has no way to spell. + */ + @Test + fun aTopLevelExtensionBehindAMultiFileFacadeIsCallableFromPython() { + Python3.exec( + """ + from kotlin.text import trimIndent + + _observed = trimIndent(' line one\n line two') + assert _observed == 'line one\nline two', 'trimIndent answered ' + repr(_observed) + """.trimIndent(), + ) + } + + /** + * `fixture.valueclass.sumMeters`: both parameters and the return are a public + * `@JvmInline value class` (`Meters`), from `:ksp-fixtures:artifact-valueclass` -- a jar built + * for exactly this proof, since `kotlin.time.Duration` (the real-world case) has an `internal` + * constructor and so can never round-trip. `12.0` is not producible except by unwrapping two + * `Meters` arguments, adding them, and wrapping the sum back up. + */ + @Test + fun aValueClassParameterAndReturnRoundTripThroughPython() { + Python3.exec( + """ + from fixture.valueclass import sumMeters + + _observed = sumMeters(5.0, 7.0) + assert _observed == 12.0, 'sumMeters answered ' + repr(_observed) + """.trimIndent(), + ) + } + + /** A declined declaration going out of scope must fail the same way any other assertion does, + * not "unexpectedly pass" because the negative case was never actually reached. */ + @Test + fun theWholeStringsKtFacadeItselfIsUnreachable() { + assertEquals( + false, + UpcallTable.resolve("kotlin.text.StringsKt.trimIndent").isValid, + "the facade's own JVM name has no Kotlin spelling and must never be a bound name", + ) + } + + /** A declaration the walker declined is absent rather than broken: `assertEquals` has eight + * bindable overloads and the walker refuses to pick one (see `ArtifactScannerTest`). */ + @Test + fun aDeclinedDeclarationIsSimplyNotThere() { + assertEquals( + false, + UpcallTable.resolve("org.junit.Assert.assertEquals").isValid, + "an ambiguous overload must not be silently bound to one arbitrary body", + ) + } +} diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt new file mode 100644 index 000000000..4ed7735b2 --- /dev/null +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt @@ -0,0 +1,234 @@ +package fixture.artifact + +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The `.pyi` stubs the **build** generated, checked against the table the same build installed. + * + * Every assertion here reads `build/generated/pythonStubs/desktopMain`, written by + * `generatePythonStubs` from the same jars `generatePythonArtifactBindings` walked. Nothing in this + * file is a hand-written stub: `PyiRenderingTest` pins the renderer against models it constructs, + * and this pins the renderer against Compose, JUnit and `kotlin-stdlib` as they actually are. + * + * The claim worth having is the last one. A stub's only job is to describe a surface that exists, and + * `docs/design/pyi-generation-design.md` §4.5 states the failure mode precisely -- "a stub that says + * `Modifier.weight(1.0)` checks is a stub that promises a call the runtime cannot make". So the test + * is not "the file contains this text", it is **every `def` in the Kotlin-FQN stubs is a key + * `UpcallTable` resolves, and every key is a `def`**. + */ +class WalkedArtifactStubTest { + + private val stubs: File + get() { + val path = System.getProperty("python.multiplatform.stubDir") + assertTrue(path != null, "the stubDir system property is not set; see build.gradle.kts") + return File(path).also { assertTrue(it.isDirectory, "generatePythonStubs produced nothing at $it") } + } + + private fun stub(relativePath: String): String { + val file = stubs.resolve(relativePath) + assertTrue(file.isFile, "no generated stub at $relativePath; generated: ${generatedPaths()}") + return file.readText() + } + + private fun generatedPaths(): List = stubs.walkTopDown() + .filter { it.isFile } + .map { it.relativeTo(stubs).invariantSeparatorsPath } + .sorted() + .toList() + + @BeforeTest + fun startFromAnEmptyTable() { + UpcallTable.clear() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The Kotlin-FQN product, against the package `docs/design/kotlin-extensions-in-python.md` §3 measured + * at zero bound declarations. + * + * The annotations are the **declared Kotlin types** (issue #31): a `Modifier` receiver and result, + * and `Dp | float` for the value class the binder binds as its primitive. + */ + @Test + fun theKotlinFqnStubsDescribeTheModulesTheRuntimePublishes() { + val layout = stub("androidx/compose/foundation/layout/__init__.pyi") + assertTrue( + "def padding__Dp(receiver: androidx.compose.ui.Modifier, /, all: androidx.compose.ui.unit.Dp | float) " + + "-> androidx.compose.ui.Modifier:" in layout, + layout.take(2000), + ) + // The class is stubbed in the module of its own package, with the extension as an attribute. + val ui = stub("androidx/compose/ui/__init__.pyi") + assertTrue("class Modifier:" in ui, ui.take(2000)) + assertTrue("padding__Dp: _t.ClassVar[_Modifier_padding__Dp]" in ui, ui.take(4000)) + assertTrue( + "\"\"\"Kotlin: androidx.compose.ui.Modifier.padding(all: androidx.compose.ui.unit.Dp): " + + "androidx.compose.ui.Modifier\"\"\"" in layout, + "the Kotlin signature belongs in the docstring, where no checker can act on it", + ) + + // A Java jar compiled without `-parameters` (§3.2): positional-only, and no invented names. + val version = stub("junit/runner/Version/__init__.pyi") + assertTrue("def id() -> str:" in version, version) + } + + /** + * The binder never exports a Kotlin namespace under another name (AGENTS.md section 12 rule 1). + * `androidx.compose.foundation.layout` is stubbed under that very path, with Kotlin names and + * Kotlin parameter names; making it Pythonic is the `pythonx-compose` package's job. + * + * Pinned over the whole generated tree: nothing under `pythonx/`, no snake_cased member, and none + * of the Pythonic product's side files (`py.typed`, `_pm_dispatch.json`). The `Protocol` classes + * that type an extension as a method of its receiver's class are typing machinery, not a + * renamed product: they are private (`_Receiver_name`) and carry the Kotlin name unchanged. + */ + @Test + fun theStubsUseKotlinNamesAndNothingIsExportedUnderPythonx() { + val paths = generatedPaths() + assertEquals(emptyList(), paths.filter { it.startsWith("pythonx/") }, paths.toString()) + assertTrue(paths.none { it.endsWith("py.typed") || it.endsWith("_pm_dispatch.json") }, paths.toString()) + + val layout = stub("androidx/compose/foundation/layout/__init__.pyi") + assertTrue( + "def fillMaxWidth(receiver: androidx.compose.ui.Modifier, /, fraction: float = ...) " + + "-> androidx.compose.ui.Modifier:" in layout, + layout.take(3000), + ) + assertTrue("fill_max_width" !in layout, "a Kotlin name was exported under another spelling") + assertTrue("alignmentLine: " in layout, "a Kotlin parameter name was renamed: ${layout.take(3000)}") + + paths.filter { it.endsWith(".pyi") }.forEach { path -> + val text = stub(path) + assertTrue("pythonx" !in text, "$path mentions pythonx") + assertTrue( + Regex("""class _(?!\w+_\w+\(_t\.Protocol\))""").find(text) == null, + "$path carries a private class that is not a receiver-method protocol", + ) + } + } + + /** + * The runtime resolves a Kotlin default when a defaulted parameter is omitted, so the stub + * marks it `= ...`; a parameter with no default stays required. Python rejects a required + * parameter after a defaulted one **unless it is keyword-only**, which is exactly what + * `inspect.signature` reports for such a parameter (`KotlinSurface.kt`), so the stub writes `*` + * before it. Checked textually, since no Python parser runs in this module -- and bracket-aware, + * because a `Callable[[A, B], R]` annotation has commas of its own. + */ + @Test + fun noGeneratedDefPutsARequiredPositionalParameterAfterADefaultedOne() { + generatedPaths().filter { it.endsWith(".pyi") }.forEach { path -> + Regex("""^(?:def [A-Za-z0-9_]+| def __call__)\((.*)\) -> """, RegexOption.MULTILINE).findAll(stub(path)).forEach { match -> + var sawDefault = false + var keywordOnly = false + splitTopLevel(match.groupValues[1]).filter { it != "/" }.forEach { parameter -> + if (parameter == "*") keywordOnly = true + else if ("= ..." in parameter) sawDefault = true + else assertTrue(!sawDefault || keywordOnly, "$path: required positional parameter after a default in ${match.value}") + } + } + } + } + + private fun splitTopLevel(parameters: String): List { + val out = mutableListOf() + var depth = 0 + val current = StringBuilder() + for (c in parameters) { + when { + c == '[' -> { depth++; current.append(c) } + c == ']' -> { depth--; current.append(c) } + c == ',' && depth == 0 -> { out += current.toString().trim(); current.clear() } + else -> current.append(c) + } + } + if (current.isNotBlank()) out += current.toString().trim() + return out + } + + /** + * The invariant the whole product rests on, stated in both directions. + * + * A `def` with no table key is a stub promising a call that raises `AttributeError`; a table key + * with no `def` is a declaration an editor cannot see, which is the entire problem stubs exist + * to solve. Neither is allowed, and this is the assertion that would catch the day one of the two + * generators is changed and the other is not. + */ + @Test + fun everyStubbedFunctionIsATableKeyAndEveryTableKeyIsStubbed() { + ArtifactTable.registerInto() + val installed = UpcallTable.entries().map { it.name }.toSet() + + val stubbed = generatedPaths() + .filter { it.endsWith("__init__.pyi") } + .flatMap { path -> + val module = path.removeSuffix("/__init__.pyi").replace('/', '.') + val source = stub(path) + // Two shapes, because a table key is not always a call. A `STATIC_GETTER` -- a + // constant an object or companion publishes, `Arrangement.Start` -- is read as an + // attribute rather than called (`46be0212`), so `ac8e4708` renders it as a value + // annotation instead of a `def`. Matching only `def` would call every such key + // unstubbed, which is what this assertion reported after that change: the stub is + // there, it just is not a function. Both shapes are stubs; neither may be missing. + val functions = Regex("""^def ([A-Za-z0-9_]+)\(""", RegexOption.MULTILINE) + .findAll(source) + .map { it.groupValues[1] } + val constants = Regex("""^([A-Za-z0-9_]+): """, RegexOption.MULTILINE) + .findAll(source) + .map { it.groupValues[1] } + (functions + constants).map { "$module.$it" }.toList() + } + .toSet() + // A third shape (issue #38): a `GETTER`/`SETTER` is an attribute of its receiver's proxy, not of + // a module, so its stub is a `@property` in that receiver's stub class -- or, where the class + // has no stub, a comment saying so -- and either way it carries its table key after + // `Kotlin property: `. Matched by key, because the key is not derivable from where it sits. + val properties = generatedPaths() + .filter { it.endsWith(".pyi") } + .flatMap { path -> + Regex("""Kotlin property: ([^\s"]+)""").findAll(stub(path)).map { it.groupValues[1] }.toList() + } + .toSet() + val propertyKeys = UpcallTable.entries() + .filter { it.kind.name == "GETTER" || it.kind.name == "SETTER" } + .map { it.name } + .toSet() + // Not vacuous: `kotlin.text` is walked here, and `Regex.pattern` is a member `val` of a class + // that crosses as a handle. + assertTrue("kotlin.text.Regex.pattern" in propertyKeys, "no property was bound: $propertyKeys") + assertEquals(emptySet(), properties - propertyKeys, "a property stub names no property key") + assertEquals(emptySet(), propertyKeys - properties, "a property key has no stub") + + // A fourth shape (issue #73): a class's own constructor, whose key (`kotlin.text.MatchGroup`) is a + // module attribute at run time, is stubbed as that class's `__init__` so the class keeps its stub + // -- and its docstring carries the key after `Kotlin constructor: `. Matched by key, like a + // property, and counted as stubbed only in the direction "this key has a stub": a marker naming + // no installed key is still "stubbed but not callable" below. + val constructors = generatedPaths() + .filter { it.endsWith(".pyi") } + .flatMap { path -> + Regex("""Kotlin constructor: ([^\s"]+)""").findAll(stub(path)).map { it.groupValues[1] }.toList() + } + .toSet() + assertEquals(emptySet(), constructors - installed, "a constructor stub names no table key") + + // The base name of an overload set (`padding` for `padding__Dp`, ...) is served by the binding + // layer's dispatcher and is stubbed as `@overload`s; it is not itself a table key. + val overloadBases = installed.map { it.substringBeforeLast('.') + "." + it.substringAfterLast('.').substringBefore("__") } + .filter { it !in installed }.toSet() + assertEquals(emptySet(), stubbed - installed - overloadBases, "stubbed but not callable") + assertEquals(emptySet(), installed - propertyKeys - stubbed - constructors, "callable but not stubbed") + } +} diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactTableTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactTableTest.kt new file mode 100644 index 000000000..569fa47bf --- /dev/null +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactTableTest.kt @@ -0,0 +1,166 @@ +package fixture.artifact + +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * What the artefact walker put in the table, checked against the jar it was walked from. + * + * The fixed set is `ArtifactScannerTest`'s, arrived at from the other end: that test walks the jar + * directly in a plugin unit test, this one reads what the *build* produced, compiled and installed. + * If they ever disagree, the wiring between them is what broke. + */ +class WalkedArtifactTableTest { + + @BeforeTest + fun startFromAnEmptyTable() { + UpcallTable.clear() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The wiring, pinned against the two artefacts this fixture fully controls. + * + * `junit:junit:4.13.2` and `:ksp-fixtures:artifact-valueclass` are both version-pinned by this + * build, so their contribution is a fixed list and `ArtifactScannerTest + * .theWholeJUnitJarYieldsExactlyTheseUnsuffixedDeclarations` pins the same seven names from the + * other end -- that test walks the jar directly in a plugin unit test, this one reads what the + * *build* produced, compiled and installed. If the two ever disagree, the wiring between them is + * what broke. + * + * `kotlin-stdlib` and Compose are deliberately **not** pinned as lists. Their contribution is a + * property of a version this repository bumps, and pinning ~250 names would turn every Kotlin or + * Compose bump into a mechanical edit of this file while catching nothing the assertions below + * do not. What is asserted about them instead is the thing that is a property of *this* code: + * that a package asked for contributed something, and that the overload rule spelled it the way + * `ArtifactScanner.disambiguateOverloads` says. + */ + @Test + fun theWalkerBoundExactlyTheJarsDeclarationsItCouldCarry() { + ArtifactTable.registerInto() + val names = UpcallTable.entries().map { it.name }.sorted() + + assertEquals( + listOf( + // `:ksp-fixtures:artifact-valueclass`, built for exactly one proof: see that + // module's `build.gradle.kts`. `Meters` is the value class's own constructor, + // bound since `e2d75100` -- this list predates that and was never updated, which + // nothing noticed because `ksp-fixtures:artifact` is not in the usual verification + // set. `ArtifactScannerTest` pins the same constructor from the plugin side. + "fixture.valueclass.Meters", + "fixture.valueclass.sumMeters", + "junit.runner.BaseTestRunner.getFilteredTrace", + "junit.runner.BaseTestRunner.getPreference__String", + "junit.runner.BaseTestRunner.getPreference__String_Int", + "junit.runner.BaseTestRunner.savePreferences", + "junit.runner.BaseTestRunner.setPreference", + "junit.runner.BaseTestRunner.truncate", + "junit.runner.Version.id", + ), + names.filter { it.startsWith("junit.runner.") || it.startsWith("fixture.valueclass.") }, + ) + + // `kotlin.text`: `kotlin-stdlib` needs no dependency of its own, and `trimIndent` is the + // top-level extension that used to be unreachable behind `StringsKt`'s multi-file facade -- + // the case `ArtifactScanner`'s KDoc names. `WalkedArtifactPythonImportTest` calls it. + assertTrue("kotlin.text.trimIndent" in names, "the multi-file facade case is missing") + // `kotlin.text.get` is `MatchGroupCollection.get(String)`, which is declared in Kotlin's + // `kotlin.text` but compiled into `kotlin/text/jdk8/` by `@file:JvmPackageName`. Binding it + // under the JVM package produced `import kotlin.text.jdk8.get`, which does not compile -- + // see `kotlinPackageNameOverrideOf`. It is here because that is now read. + assertTrue("kotlin.text.get" in names, "the @JvmPackageName case is missing") + + // Compose. `docs/design/kotlin-extensions-in-python.md` §3 measured **zero** declarations bound + // from this package; `WalkedArtifactComposeModifierTest` calls two of these from Python. + val layout = names.filter { it.startsWith("androidx.compose.foundation.layout.") } + assertTrue(layout.size >= 60, "expected androidx.compose.foundation.layout to bind; got $layout") + assertTrue("androidx.compose.foundation.layout.padding__Dp" in layout, layout.toString()) + assertTrue("androidx.compose.foundation.layout.size__Dp" in layout, layout.toString()) + assertTrue("androidx.compose.foundation.layout.fillMaxSize" in layout, layout.toString()) + + // The rule, stated as an invariant over the whole table rather than per name: a bare Kotlin + // name means exactly one declaration, and a `__`-suffixed one names a member of an overload + // set whose bare name is therefore absent. + val suffixed = names.filter { "__" in it } + assertTrue(suffixed.isNotEmpty()) + suffixed.forEach { name -> + assertFalse(name.substringBefore("__") in names, "$name coexists with its own bare name") + } + } + + /** + * The call actually reaches the jar. + * + * `"4.13.2"` is JUnit 4's own version string, compiled into `junit/runner/Version.class`. No + * generator, no table and no test fixture can produce it -- only the artefact can, which is why + * this is the one declaration the Python-side test reads back too. + */ + @Test + fun aBoundDeclarationCallsIntoTheArtefact() { + ArtifactTable.registerInto() + val id = UpcallTable.resolve("junit.runner.Version.id") + assertTrue(id.isValid, "the walker's entry is not in the table") + assertEquals("4.13.2", UpcallTable.invoke(id, emptyArray())) + } + + /** + * The join: two aggregators, one table. + * + * `FunctionTable` is what KSP built out of this module's own source; `ArtifactTable` is what the + * walker built out of the jars the build resolved. They are separate lists on purpose -- see + * `ArtifactTableRenderingTest` -- and the install site is where they meet. + */ + @Test + fun bothProducersLandInOneTable() { + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + + assertTrue( + UpcallTable.resolve("fixture.artifact.whichSideAmIFrom").isValid, + "KSP's half is missing from the joint install", + ) + assertTrue( + UpcallTable.resolve("junit.runner.Version.id").isValid, + "the walker's half is missing from the joint install", + ) + assertEquals("ksp", UpcallTable.invoke(UpcallTable.resolve("fixture.artifact.whichSideAmIFrom"), emptyArray())) + assertEquals("4.13.2", UpcallTable.invoke(UpcallTable.resolve("junit.runner.Version.id"), emptyArray())) + } + + /** + * `FunctionTable` still means what it meant. + * + * The alternative design put walked fragments into KSP's own aggregator. This is the assertion + * that would have had to change for it, and the reason it was not chosen: a consumer's table + * must not grow because they added a dependency. + */ + @Test + fun theKspAggregatorDoesNotAbsorbWalkedArtefacts() { + UpcallTable.install(FunctionTable.fragments) + assertTrue(UpcallTable.resolve("fixture.artifact.whichSideAmIFrom").isValid) + assertFalse( + UpcallTable.resolve("junit.runner.Version.id").isValid, + "FunctionTable absorbed a walked artefact; the two producers are supposed to keep separate lists", + ) + } + + /** `registerInto` adds; it does not reinstall. A second call must not double the table, which is + * `UpcallTable.register`'s own `moduleName` guard doing the work. */ + @Test + fun registeringTwiceIsANoOp() { + ArtifactTable.registerInto() + val once = UpcallTable.callableCount + ArtifactTable.registerInto() + assertEquals(once, UpcallTable.callableCount) + } +} diff --git a/ksp-fixtures/compose/build.gradle.kts b/ksp-fixtures/compose/build.gradle.kts new file mode 100644 index 000000000..9a111a31f --- /dev/null +++ b/ksp-fixtures/compose/build.gradle.kts @@ -0,0 +1,138 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/** + * The other end of `docs/archive/pythonx-adapter-design.md` §5: a real `@Composable`, called from Python, + * inside a real composition, drawing a real pixel. + * + * ### Why this is not `:ksp-fixtures:artifact` + * + * That module takes Compose as **plain Maven coordinates with no Compose plugin**, and its own + * `build.gradle.kts` says why: what makes `Modifier.padding(16.dp)` reachable is the walker's gates + * opening, not any cooperation from Compose's tooling, and a `Modifier` chain needs no composition at + * all. A composable is the opposite case in both respects. It needs a `Composer`, which exists only + * inside a composition, which needs the Compose compiler plugin to create -- so proving it here + * rather than there keeps that claim intact instead of quietly weakening it. + * + * ### What the compiler plugin is and is not used for + * + * It compiles **one hand-written function**, `PythonComposition.kt`'s `PythonComposition`. The + * generated fragment beside it calls no composable from Kotlin source and could not: a composable's + * call site is emitted as bytecode (`ComposableThunks.kt`), because a Kotlin file facade has no + * Kotlin name and the `$default` mask must be computed at run time. So the plugin's presence buys + * exactly one composer and nothing else. + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.jetpack.compose) + alias(libs.plugins.compose.compiler) + id("io.github.thisisthepy.python.multiplatform.bindings") +} + +kotlin { + jvm("desktop") { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.pythonMultiplatform) + } + } + val desktopMain by getting { + dependencies { + implementation(projects.pythonMultiplatformCompose) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + // `Icons.Default.Add` and friends (issue #37): the one place an `ImageVector` is built. + implementation(libs.compose.material.icons.core) + implementation(compose.desktop.currentOs) + } + } + val desktopTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + } +} + +pythonBindings { + role.set("app") + processor.set(projects.pythonMultiplatformKsp) + + artifactConfiguration.set("desktopCompileClasspath") + artifactSourceSet.set("desktopMain") + // Narrow on purpose, and narrower than `:ksp-fixtures:artifact`'s: binding all of Compose would + // make this fixture's compile time the cost of a proof it does not need. Each entry earns its + // place. + // + // 1. `material3` -- `Text` is a *leaf*, reachable without ever filling a function-typed slot. + // 2. `foundation.layout` -- `Column` is the container whose `content` declares no default, so it + // is the declaration that is unreachable until a Python callable can cross, which is what + // `ComposableRenderTest` renders. + // 3. `ui.graphics` -- where every *value* material3 asks for is built: `Color`, and with it + // `ImageBitmap`, `BitmapPainter` and the `vector` and `painter` subpackages, since the match + // is a namespace one. `a6742a1c` pinned `Icon` and `lightColorScheme` as unreachable and this + // line is the whole of why: both were asking for a value from a package nothing walked. See + // `IconRenderTest`. + // 4. `ui.res` -- the one thing a *constructed* value cannot be. An `ImageBitmap` with no pixels + // written into it is transparent, and writing them needs `Canvas.drawRect`, an **instance** + // method (`ArtifactScanner.kotlinCandidates` binds `ACC_STATIC` only). Loading an image is + // static all the way down -- `openResource` -> `loadImageBitmap`, and `painterResource` for + // the `Painter` overload -- so this is what makes `Icon` draw a pixel rather than merely + // compose. + artifactIncludePackages.set( + listOf( + "androidx.compose.material3", + "androidx.compose.foundation.layout", + "androidx.compose.ui.graphics", + "androidx.compose.ui.res", + "androidx.compose.ui", + // 5. `material.icons` -- `Icons.Default` and the icon properties behind it (issue #37). + "androidx.compose.material.icons", + // 6. Compose state, from Python (issue #38): `mutableStateOf` (a generic function, its `T` + // read as `kotlin.Any?`) and the `value` property of `State`/`MutableState`. Classes, not + // the package: `androidx.compose.runtime` is the whole runtime, and this is the one + // facade and two interfaces `PythonContentRenderTest` writes its root through. + "androidx.compose.runtime.SnapshotStateKt", + "androidx.compose.runtime.State", + "androidx.compose.runtime.MutableState", + // 7. `TextField(state = ...)`'s state (issue #73): the `TextFieldState` class (its constructor + // and its `text`) and its file facade (`rememberTextFieldState`, + // `setTextAndPlaceCursorAtEnd`, `clearText`). Classes, not the package, for item 6's + // reason: `androidx.compose.foundation.text.input` is the whole new text stack. + // `TextFieldStateRenderTest` is what each one is for. + "androidx.compose.foundation.text.input.TextFieldState", + "androidx.compose.foundation.text.input.TextFieldStateKt", + ), + ) + // Stubs on: `generatePythonStubs` over the real Compose jars is what `tools/stubs/check-stubs.sh` (the mypy + // check) and `.github/workflows/stubs.yml` (the published artifact) both run. Nothing depends on the + // task, so the render tests do not pay for it. + generateStubs.set(true) +} + +/** + * The same JVM `:ksp-fixtures:artifact`'s tests need: JDK 21 with the preview FFM API, and a + * `java.library.path` for the `libpython` that `manager.loadLibPython` extracts next to the working + * directory. + */ +tasks.named("desktopTest") { + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }, + ) + jvmArgs("--enable-preview", "-Djava.library.path=.") + // Skiko renders offscreen here (`ImageComposeScene`), so no window and no display is needed -- + // but AWT still initialises, and on a CI box without one that is the difference between a + // headless surface and a crash. + systemProperty("java.awt.headless", "true") +} diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/ModifierComparisons.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/ModifierComparisons.kt new file mode 100644 index 000000000..f22e3be14 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/ModifierComparisons.kt @@ -0,0 +1,17 @@ +package fixture.compose + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * What `MemberResolverComposeTest` compares a Python-built chain against. Compose's own structural + * equality, so the chain cannot match unless Compose's `padding` and `fillMaxWidth` really ran. + */ +fun equalsPaddingThenFillMaxWidth(modifier: Modifier, pad: Double): Boolean = + modifier == Modifier.padding(pad.dp).fillMaxWidth() + +/** The negative control: the same members in the other order is a different chain. */ +fun equalsFillMaxWidthThenPadding(modifier: Modifier, pad: Double): Boolean = + modifier == Modifier.fillMaxWidth().padding(pad.dp) diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonAnchoredDraggable.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonAnchoredDraggable.kt new file mode 100644 index 000000000..61807b766 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonAnchoredDraggable.kt @@ -0,0 +1,68 @@ +package fixture.compose + +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.ui.Modifier +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.types.basic.PyString + +/** + * `Modifier.anchoredDraggable`, declined for having a generic type parameter `T` that Python cannot spell + * (`docs/archive/pythonx-adapter-design.md` §9.2). The fix is to provide a wrapper for a concrete type. + * + * We choose `String` as the concrete type for `T` because swipe UI states are typically discrete + * identifiers like "start", "end", "settled", which map naturally to strings. + * + * ### What this function is + * + * It is a hand-written Kotlin function, bound by KSP as a plain top-level entry exactly the way + * [pythonPointerInput] and [pythonLayoutIdString] are. + * + * ### Lifetime -- not handled, same as [pythonDraggable] + * + * [onConfirmValueChange] is held by the [AnchoredDraggableState] and never closed here, causing a leak + * for each call to this function. This is the same unhandled lifetime issue noted in [pythonDraggable]. + * + * ### Thresholds + * + * Compose 1.11 moved `positionalThreshold`, `velocityThreshold` and the snap spec off the state and onto + * `AnchoredDraggableDefaults.flingBehavior`, which is `@Composable` and so cannot be called from this + * plain function. The state therefore takes the library defaults (positional threshold 50% of the + * distance between anchors), which is what the old explicit `totalDistance * 0.5f` already was. + */ +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) +fun pythonAnchoredDraggableString( + modifier: Modifier, + initialValue: String, + anchor1Value: String, + anchor1Offset: Float, + anchor2Value: String, + anchor2Offset: Float, + onConfirmValueChange: PyObject +): Modifier { + val anchors = DraggableAnchors { + anchor1Value at anchor1Offset + anchor2Value at anchor2Offset + } + + val state = AnchoredDraggableState( + initialValue = initialValue, + anchors = anchors, + confirmValueChange = { newValue: String -> + val pyStr = PyString.from(newValue) + try { + onConfirmValueChange(pyStr).close() + } finally { + pyStr.close() + } + true + } + ) + + return modifier.anchoredDraggable( + state = state, + orientation = Orientation.Horizontal + ) +} diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt new file mode 100644 index 000000000..0d3e0a51e --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt @@ -0,0 +1,142 @@ +package fixture.compose + +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import python.multiplatform.compose.withPythonComposer +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.types.basic.PyInt +import python.multiplatform.reflection.HandleTable +import python.native.ffi.PyLong_AsLongLong + +/** + * `Modifier.composed` -- `docs/archive/pythonx-adapter-design.md` §9.2/§9.4's open question, now answered: + * **a composer is admitted.** `factory`'s compiled shape is `Function3` (confirmed by `javap -p` on `androidx.compose.ui.ComposedModifierKt`, matching the + * lowered shape `PythonCallables` already crosses for every composable `content=` slot), and unlike + * `pythonLayoutIdProbe`'s dead end, `composed` itself is **not** `@Composable` -- `javap` shows no + * synthetic `$composer`/`$changed` on the `composed` static method -- so `BindingPolicy.isComposable` + * (which excludes only a declaration *itself* annotated `@Composable`) never sees it and does not + * exclude it. That much was inferable from the bytecode alone; what it left open is whether the + * composer `factory` receives, once Compose actually calls it, is the real thing wired into a live + * slot table or a placeholder that merely satisfies the type. [ComposedInvocationSlot] is the answer: + * a genuine [remember] inside the lambda `composed` is given persists across recompositions of the + * *same* node -- a fake composer could not make that true (`remember`'s own implementation would + * either throw against a non-functional `Composer` or hand back a fresh value every time, since it + * has no slot table to remember into) -- see `ComposedRenderTest + * .theSameCompositionReusesTheRememberedSlotAcrossRecompositions` for the measurement. + * + * ### The shape of the fix, and how it differs from [pythonPointerInput] + * + * Both are hand-written, ordinarily-compiled Kotlin bound by KSP as a plain top-level function + * (`fixture.compose.pythonComposed`), not through the artefact walker or `pythonx`'s dynamic loader -- + * the same reasoning [pythonPointerInput]'s KDoc gives for why a plain parameter, not an extension + * receiver, and why KSP rather than the walker. What differs is *why* a hand-written wrapper is + * needed at all: `pointerInput`'s block is a `suspend` lambda no Python callable can itself suspend + * as, so the fix hides a `suspend` state machine inside ordinary Kotlin. `composed`'s `factory` is not + * suspend -- it is a lowered composable lambda, exactly what `PythonCallables` already builds crossing + * wrappers for -- but nothing autogenerated can supply the **call site**: `composed`'s factory is + * invoked later, by Compose's own `materialize()`, not by generated fragment code the way a `content=` + * argument is invoked by the composable it was passed to. This function *is* that call site, written + * once, the same role [PythonComposition] plays for the one hand-written `@Composable` entry point. + * + * ### Why the crossing is hand-written rather than routed through [python.multiplatform.ffi.pythonx.PythonCallables] + * + * `PythonCallables.PythonFunction.invoke` always discards the Python callable's return value (built + * for `content=` slots, which are `Function3<..., Unit>`); `composed`'s factory returns a `Modifier`, + * so its wrapper cannot reuse that path unchanged. Rather than widen a table entry point three call + * sites do not need, this calls [factory] directly (`PyObject.invoke`, the same downcall + * [pythonPointerInput] and [pythonDraggable] already use for a synchronous callback) and reads the + * `Modifier` back off the returned proxy's `_pm_handle` attribute -- the same attribute + * `PythonxAdapter`'s `_proxy_type` stores it under for every `TypeTag.OBJECT` result. + * + * ### The composer, and the receiver, cross the same way [PythonComposition]'s does + * + * The composer is rooted and pushed onto the binding layer's composer stack for the duration of the + * call by `python-multiplatform-compose`'s `withPythonComposer` -- the same helper [PythonComposition] + * uses -- so [factory] may itself call a bound composable (the same contract + * [PythonComposition] documents). `this` -- the `Modifier` chain [factory] receives as its receiver, + * matching `@Composable Modifier.() -> Modifier` -- is rooted too and passed as a bare handle + * (`int`), which `PythonxAdapter._coerce`'s OBJECT branch already accepts as a caller-selected + * argument; a real caller threads it into e.g. `size__Dp(receiver, 70.0)` the same way any other + * walked declaration chains a `Modifier`. + * + * ### What is deliberately not solved + * + * [factory]'s one Python reference (`UpcallTrampoline.toKotlinObject`'s fresh reference, taken once + * when Python calls [pythonComposed]) is captured by the closure and never released -- there is no + * `RememberObserver` hook here the way `content=`'s [python.multiplatform.ffi.pythonx.PythonCallableScope] + * has one, and building one for a single captured `PyObject` was judged out of scope for answering + * §9.2's question. This is [pythonDraggable]'s already-documented shape: "measured, not guessed" -- + * one reference, leaked for the modifier's lifetime, bounded because [pythonComposed] is called once + * per modifier chain built. + */ +fun pythonComposed(modifier: Modifier, factory: PyObject): Modifier = modifier.composed { + val slot = remember { ComposedInvocationSlot().also { ComposedInvocationSlot.remembersAllocated++ } } + slot.invocations++ + ComposedInvocationSlot.lastCreated = slot + + val receiver = this + // The receiver is rooted *inside* the composer call: Compose forbids a try/finally around a + // composable invocation, so the release cannot wrap `withPythonComposer` from outside. + withPythonComposer { + val receiverRef = HandleTable.register(receiver) + try { + val receiverHandle = PyInt.from(receiverRef.raw) + try { + val result = factory(receiverHandle) + try { + // A proxy (`_proxy_type`'s `_pm_handle`) is the ordinary case -- `factory` + // returned something a walked declaration produced, e.g. `size__Dp(receiver, + // 70.0)`. A bare `int` is `_coerce`'s other accepted OBJECT spelling: `factory` + // handed the receiver handle straight back (an identity factory), which never + // passed through `_proxy_type` to grow a `_pm_handle` attribute. + val attr = result.getAttrOrNull("_pm_handle") + val raw = if (attr != null) { + try { + Python3.withPython { PyLong_AsLongLong(attr.pointer) } + } finally { + attr.close() + } + } else { + Python3.withPython { PyLong_AsLongLong(result.pointer) } + } + (HandleTable.resolveRaw(raw) as? Modifier) ?: receiver + } finally { + result.close() + } + } finally { + receiverHandle.close() + } + } finally { + HandleTable.release(receiverRef) + } + } +} + +/** + * Test-visible evidence that [remember] inside [pythonComposed]'s `composed { }` lambda holds a real + * slot, not a value recreated on every call. + * + * [invocations] is a per-instance counter, bumped on every call of the lambda regardless of whether + * `remember` allocated a new [ComposedInvocationSlot] or handed back an old one -- so it tells apart + * "the lambda ran N times" from "the lambda's remembered state was recreated N times", which + * [remembersAllocated] (bumped only inside `remember`'s factory) answers instead. A composer wired + * into a real composition makes [remembersAllocated] stay `1` across many recompositions of the same + * node while [invocations] keeps climbing; a placeholder composer that could not support `remember` + * would either crash or make the two track each other. + */ +class ComposedInvocationSlot { + var invocations: Int = 0 + + companion object { + var remembersAllocated: Int = 0 + var lastCreated: ComposedInvocationSlot? = null + + fun reset() { + remembersAllocated = 0 + lastCreated = null + } + } +} diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt new file mode 100644 index 000000000..28ce7a08e --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt @@ -0,0 +1,146 @@ +package fixture.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import python.multiplatform.compose.PythonCallableArena +import python.multiplatform.compose.withPythonComposer +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonCallables + +/** + * **The one hand-written `@Composable` in the whole design**, and the only thing Python cannot do + * for itself. + * + * ### What moved out of here + * + * The composer plumbing and the callable holder are no longer this fixture's: they are + * `python-multiplatform-compose`'s `withPythonComposer` and `PythonCallableArena`, which the host + * entry point `PythonContent` is built on too. What stays is executing a *source string* per pass, + * which is a test harness rather than an entry point -- a host draws a Python-declared root with + * `PythonContent` (`PythonContentRenderTest`). + * + * ### Why exactly one, and not one per component + * + * The 2024 `pythonx-compose` wrote a Kotlin wrapper per widget -- 37 files, 28 of them empty -- + * and `docs/archive/pythonx-adapter-design.md` §1 measures what that cost: `padding()` composed nothing and + * `fillMaxSize()` returned `self`, because a per-declaration wrapper is written once and then + * never again. This is O(1) in the number of composables and stays O(1) by construction: it names no + * composable, takes no composable-specific parameter, and knows nothing about the declaration Python + * is about to call. Every `@Composable` in every artefact the walker binds goes through this one + * function, because the only thing it supplies is the composer -- and there is exactly one composer, + * not one per widget. + * + * ### What it actually supplies + * + * `$composer` is not a value, it is a *position*: `currentComposer` is an intrinsic the Compose + * compiler plugin resolves to the composer parameter of the enclosing composable, so the only way to + * obtain one is to be inside a composition. Everything else about calling a composable -- which + * arguments were written, what `$default` mask that implies, what `$changed` should be -- is + * arithmetic `pythonx` does in Python (`PythonxAdapter`'s `_bind_composable`), which is why it is + * *not* here and why this stayed one function. + * + * The composer crosses as an ordinary `TypeTag.OBJECT` handle, which is `docs/design/ecosystem.md` §5b's + * "the Python wrapper passes the composer as a value" and the same shape the 2024 + * `RuntimeKt.composableWrapper` used. The handle is registered here and released here, so the + * lifetime is exactly the composition's -- `pythonx.push_composer` retains nothing and says so. + * + * ### What is deliberately not solved + * + * Recomposition. [source] is `exec`ed on every composition pass, so a Python body that is expensive + * pays for it every frame, and a `@Composable` reached this way can never be *skipped* the way one + * with stable parameters is. `docs/archive/pythonx-adapter-design.md` §5.4 item 4 names this as a property to + * measure before the shape is adopted for anything but a proof, and nothing here has measured it. + */ +@Composable +fun PythonComposition(source: String) { + // `remember`, and this is the only reason the entry point needs one. A Python `content=lambda:` + // has to outlive the call that passed it -- Compose stores it in the slot table -- so something + // must hold a Python reference for it, and the only thing whose lifetime *is* the composition's + // is a remembered value. See `python.multiplatform.compose.PythonCallableArena`. + val arena = remember { CountingCallableArena() } + withPythonComposer { + PythonCallables.withScope(arena.scope) { + Python3.exec(source) + } + } +} + +/** + * The library's [PythonCallableArena] (`python-multiplatform-compose`), counted. + * + * The holder itself -- why the composition, why `onForgotten`, why `onAbandoned` too -- is + * documented where it now lives. What stays here is test-visible counters, because "the arena never + * held anything" and "the arena held it and gave it back" produce the same reference count and must + * not produce the same verdict. + * + * [released] accumulates what `PythonCallableScope.close` *reported* rather than counting calls to + * it, which is what makes a double release visible: a second close answers `0`, so a total higher + * than the number of callables that crossed can only come from releasing something twice. + */ +class CountingCallableArena : PythonCallableArena() { + + override fun onRemembered() { + super.onRemembered() + created++ + latest = this + } + + override fun onForgotten() { + forgotten++ + val before = super.released + super.onForgotten() + Companion.released += super.released - before + } + + override fun onAbandoned() { + abandoned++ + val before = super.released + super.onAbandoned() + Companion.released += super.released - before + } + + companion object { + var created: Int = 0 + var forgotten: Int = 0 + var abandoned: Int = 0 + var released: Int = 0 + + /** + * The most recently remembered arena, so a test can read `PythonCallableScope.liveCount` + * **while the composition is still alive** -- `RecompositionAccumulationTest` is the whole + * reason this exists. Set in [onRemembered] rather than in the constructor because an arena + * that was built and then abandoned by a discarded composition never becomes the one a render + * is measuring. + */ + var latest: CountingCallableArena? = null + + fun resetCounters() { + created = 0 + forgotten = 0 + abandoned = 0 + released = 0 + latest = null + } + } +} + +/** + * The one seed `ComposableRenderTest`'s render-proof tests need that neither the artefact walker + * nor `pythonx` can supply on their own: a `Modifier` chain has to start somewhere, and the thing it + * starts from is `Modifier` the *expression* -- `androidx.compose.ui.Modifier.Companion`, an object + * instance the walker cannot hand out because it only binds functions. + * `ksp-fixtures/artifact`'s `ComposeSeed.kt` solved the exact same gap the same way, for the + * non-composable half of Compose's surface; this is that solution, in this module, so `Spacer` -- + * whose one parameter has no default and is therefore not omittable -- has something to be given. + * + * Bound by KSP, not the artefact walker: a plain top-level function in this module's own source is + * an ordinary `FunctionTable` entry, reached from Python through `PythonProxySource` under this + * module's own Kotlin package name (`fixture.compose.emptyModifier`), the same route + * `WalkedArtifactComposeModifierTest` uses for `fixture.artifact.emptyModifier`. `pythonx`'s + * `_BY_PACKAGE` dispatch -- built over `ArtifactTable` -- never sees it, and does not need to: the + * `Modifier` handle this returns is an ordinary `TypeTag.OBJECT` value, indistinguishable at the + * boundary from one a walked declaration produced, so it can be threaded into a `pythonx`-bound + * composable's `modifier` slot once built. + */ +fun emptyModifier(): Modifier = Modifier diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonContextMenuOpenDetector.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonContextMenuOpenDetector.kt new file mode 100644 index 000000000..ce8cb4072 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonContextMenuOpenDetector.kt @@ -0,0 +1,44 @@ +package fixture.compose + +import androidx.compose.foundation.contextMenuOpenDetector +import androidx.compose.ui.Modifier +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.types.basic.PyFloat + +/** + * `Modifier.contextMenuOpenDetector` -- `docs/archive/pythonx-adapter-design.md` §9's final declined declaration. + * + * It declined because its `key` parameter is typed `Any?`, and `resolveKotlinType` has no boundary type + * for a bare `kotlin.Any` parameter (no hint of which `TypeTag` an arbitrary Python value crossing there + * should marshal as). + * + * This function bypasses the rejection by taking no `key` parameter from Python and passing `Unit` + * to the underlying Kotlin declaration. Like `pythonPointerInput` and `pythonDraggable`, this is a + * hand-written Kotlin function bound by KSP as a plain top-level entry. + * + * ### Lifetime + * + * Like `pythonDraggable`, `onPointerDown` is captured by the generated event handler and is never + * closed here. It is leaked for the lifetime of the modifier. + */ +import androidx.compose.foundation.ExperimentalFoundationApi + +@OptIn(ExperimentalFoundationApi::class) +fun pythonContextMenuOpenDetector( + modifier: Modifier, + enabled: Boolean, + onPointerDown: PyObject, +): Modifier = modifier.contextMenuOpenDetector( + key = Unit, + enabled = enabled, + onOpen = { offset -> + val xArg = PyFloat.from(offset.x.toDouble()) + val yArg = PyFloat.from(offset.y.toDouble()) + try { + onPointerDown(xArg, yArg).close() + } finally { + xArg.close() + yArg.close() + } + } +) diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDragAndDropSource.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDragAndDropSource.kt new file mode 100644 index 000000000..234ea9485 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDragAndDropSource.kt @@ -0,0 +1,54 @@ +package fixture.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.draganddrop.dragAndDropSource +import androidx.compose.ui.Modifier +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.types.basic.PyFloat +import python.multiplatform.ffi.types.basic.PyString + +/** + * `Modifier.dragAndDropSource`, with Python told where a drag started. + * + * ### Why this is no longer `pythonPointerInput`'s technique + * + * Up to Compose 1.6 `dragAndDropSource` took a `suspend DragAndDropSourceScope.() -> Unit` block -- + * a `PointerInputScope` -- so this fixture forwarded every raw pointer event exactly as + * [pythonPointerInput] does. Compose 1.11 removed that overload. The only public shapes left are + * `dragAndDropSource(transferData: (Offset) -> DragAndDropTransferData?)` and the same with a + * `drawDragDecoration`; gesture detection is internal (`DragAndDropSourceDefaults.DefaultStartDetector`: + * a primary-button mouse drag, or a long press for touch). So the Python callback now sees the one + * event the library still hands out: the drag start, as `("start", x, y)`. + * + * `transferData` is only asked when the platform's `PlatformDragAndDropManager` answers + * `isRequestDragAndDropTransferRequired() == true` (the desktop window's does; `ImageComposeScene`'s default does + * not), so tests must supply such a manager -- see `DragAndDropSourceRenderTest`. + * + * ### What is exercised here, and what is not + * + * A real pointer drag, recognised by Compose's own start detector, reaches a synchronous Python + * callback. Returning `null` declines the transfer, so no OS-level drag session (`DragAndDropTransferData` + * wrapping a `java.awt.datatransfer.Transferable`) is ever started -- that remains a different, + * unmeasured claim. [drawDragDecoration] is a no-op for the same reason. + * + * ### Lifetime -- not handled, same as [pythonDraggable] + * + * The old suspend block gave a `finally` that ran on node detachment. The `transferData` lambda has no + * such hook, so [onEvent] is held by the modifier and never closed here. + */ +@OptIn(ExperimentalFoundationApi::class) +fun pythonDragAndDropSource(modifier: Modifier, onEvent: PyObject): Modifier = modifier.dragAndDropSource( + drawDragDecoration = { /* not exercised -- see this function's KDoc */ }, +) { offset -> + val typeArg = PyString.from("start") + val xArg = PyFloat.from(offset.x.toDouble()) + val yArg = PyFloat.from(offset.y.toDouble()) + try { + onEvent(typeArg, xArg, yArg).close() + } finally { + typeArg.close() + xArg.close() + yArg.close() + } + null +} diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDraggable.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDraggable.kt new file mode 100644 index 000000000..b07b50ebc --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonDraggable.kt @@ -0,0 +1,85 @@ +package fixture.compose + +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.ui.Modifier +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.types.basic.PyFloat + +/** + * `Modifier.draggable`, reached the same way `pythonPointerInput` reaches `Modifier.pointerInput` + * (`docs/archive/pythonx-adapter-design.md` §9.1) -- `draggable`'s three callback slots decline for the same + * reason `pointerInput`'s does (`ArtifactScanner`: a suspend function-typed parameter), and the same + * fix applies: nothing about Python has to suspend, because the suspension is Kotlin's, hand-written, + * ordinarily compiled, and Python is only ever asked a synchronous question. + * + * ### The three slots are two different shapes, not one + * + * `DraggableState`'s own `onDelta` (the top-level `DraggableState(onDelta: (Float) -> Unit)` factory + * this function builds one of) is **not** suspend at all -- it is an ordinary `Function1` the internal + * drag node calls directly, once per raw delta, exactly the shape every other plain callback in this + * codebase already crosses. `onDragStarted`/`onDragStopped` (`draggable`'s own parameters) are the + * `suspend CoroutineScope.(...) -> Unit` shape §9.1 opened: each is invoked once per gesture, and + * because *nothing inside either lambda body suspends*, the compiler still builds the state machine + * the calling convention demands, it just never actually parks. + * + * ### What this function is, and is not + * + * It is **not** a generated binding of `Modifier.draggable` -- `draggable` itself stays declined, + * correctly, for the reason above. It **is** a hand-written Kotlin function, bound by KSP as a plain + * top-level entry exactly the way [pythonPointerInput] is, for the same reason: `FragmentScanner + * .topLevelFunctionEntry` never reads an extension receiver, so [modifier] is an ordinary parameter. + * + * ### Lifetime -- not handled, and said so rather than guessed at + * + * Unlike [pythonPointerInput], there is no single suspend body whose `finally` covers this function's + * whole lifetime: `onDelta` is held by the `DraggableState` this function builds and is called by + * Compose's internal drag node for as long as that node stays attached, which this function has no + * hook into (no `RememberObserver` route without wrapping this in a `@Composable`, which would be a + * different fix -- see `docs/archive/pythonx-adapter-design.md` §9.2's note on `composed`). [onDelta], + * [onDragStarted] and [onDragStopped] are therefore **never closed here**: each call to this function + * takes a fresh reference to all three and leaks it. Measured consequence: `DraggableRenderTest` + * composes this exactly once per scene, so the leak is bounded (three references) for that test and + * unbounded for a composable that recomposes. Closing them prematurely (say, from inside + * `onDragStopped`, since a single gesture is the common case this proof drives) was rejected on + * purpose: a second gesture after the first would then invoke a closed `PyObject`, which is worse than + * a leak (`agent-rules` §14) -- it is a use of a reference that may have already been reassigned. + */ +fun pythonDraggable( + modifier: Modifier, + onDelta: PyObject, + onDragStarted: PyObject, + onDragStopped: PyObject, +): Modifier { + val state = DraggableState { delta -> + val arg = PyFloat.from(delta.toDouble()) + try { + onDelta(arg).close() + } finally { + arg.close() + } + } + return modifier.draggable( + state = state, + orientation = Orientation.Horizontal, + onDragStarted = { startedAt -> + val xArg = PyFloat.from(startedAt.x.toDouble()) + val yArg = PyFloat.from(startedAt.y.toDouble()) + try { + onDragStarted(xArg, yArg).close() + } finally { + xArg.close() + yArg.close() + } + }, + onDragStopped = { velocity -> + val vArg = PyFloat.from(velocity.toDouble()) + try { + onDragStopped(vArg).close() + } finally { + vArg.close() + } + }, + ) +} diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonLayoutId.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonLayoutId.kt new file mode 100644 index 000000000..55a7446f0 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonLayoutId.kt @@ -0,0 +1,30 @@ +package fixture.compose + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId + +/** + * `Modifier.layoutId`, declined for a reason unrelated to suspend + * (`docs/archive/pythonx-adapter-design.md` §9's table): its one non-`Modifier` parameter is `kotlin.Any`, and + * `resolveKotlinType` has no [python.multiplatform.reflection.TypeTag] for "whatever tag the caller + * meant" -- `layoutId` is real-world called with a bare `String` or `Int`, not a Kotlin object handle. + * One wrapper per concrete tag fixes it, the same way every other concretely-typed hand-written + * wrapper in this module does; this is the `String` one, since that is what `LayoutIdRenderTest` + * exercises. + * + * ### Why there is no `pythonLayoutIdProbe` alongside it + * + * A first version of this fix paired it with a hand-written `@Composable fun pythonLayoutIdProbe(...)` + * meant to make the tagged value observable, bound as a plain top-level function the same way + * [pythonPointerInput] is. It does not bind: `BindingPolicy.isComposable` excludes **every** + * `@Composable` top-level function from `FragmentScanner`'s plain-function path on purpose (its own + * KDoc: "a `@Composable` in the consumer's own source, which KSP cannot bind and must not try to" -- + * calling it from the generated, non-composable fragment source would not compile). `PythonComposition` + * is not a counterexample: Python never calls it, Kotlin does, from inside `ImageComposeScene`'s own + * composable content lambda. `LayoutIdRenderTest` reads `layoutId` back the same way -- a `Layout` + * built in the *Kotlin test*, wrapping `PythonComposition`, rather than a hand-written composable + * Python calls. + */ +fun pythonLayoutIdString(modifier: Modifier, id: String): Modifier = modifier.layoutId(id) + +fun pythonLayoutIdInt(modifier: Modifier, id: Int): Modifier = modifier.layoutId(id) diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonModifierLocal.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonModifierLocal.kt new file mode 100644 index 000000000..e6f0524e2 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonModifierLocal.kt @@ -0,0 +1,56 @@ +package fixture.compose + +import androidx.compose.ui.Modifier +import androidx.compose.ui.modifier.ProvidableModifierLocal +import androidx.compose.ui.modifier.modifierLocalConsumer +import androidx.compose.ui.modifier.modifierLocalOf +import androidx.compose.ui.modifier.modifierLocalProvider +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.types.basic.PyString + +/** + * `Modifier.modifierLocalProvider`, declined for having a generic type parameter `T` that Python cannot spell + * (`docs/archive/pythonx-adapter-design.md` §9.2). The fix is to provide a wrapper for a concrete type. + * + * We choose `String` as the concrete type for `T`. + * + * ### What this function is + * + * It is a hand-written Kotlin function, bound by KSP as a plain top-level entry exactly the way + * [pythonAnchoredDraggableString] and [pythonPointerInput] are. + * + * ### Lifetime -- not handled + * + * [onRead] is held by the [modifierLocalConsumer] block and never closed here, causing a leak + * for each call to this function. This is the same unhandled lifetime issue noted in [pythonDraggable]. + */ +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +fun pythonModifierLocalOfString(defaultValue: String): ProvidableModifierLocal { + return modifierLocalOf { defaultValue } +} + +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +fun pythonModifierLocalProviderString( + modifier: Modifier, + local: ProvidableModifierLocal, + value: String +): Modifier { + return modifier.modifierLocalProvider(local) { value } +} + +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +fun pythonModifierLocalConsumerString( + modifier: Modifier, + local: ProvidableModifierLocal, + onRead: PyObject +): Modifier { + return modifier.modifierLocalConsumer { + val readValue = local.current + val pyStr = PyString.from(readValue) + try { + onRead(pyStr).close() + } finally { + pyStr.close() + } + } +} diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPointerInput.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPointerInput.kt new file mode 100644 index 000000000..c2dac0a39 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPointerInput.kt @@ -0,0 +1,95 @@ +package fixture.compose + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.types.basic.PyFloat +import python.multiplatform.ffi.types.basic.PyString + +/** + * `Modifier.pointerInput` is one of the seven public top-level `Modifier` extensions + * `ArtifactScannerTest`/`FunctionSlotBindingTest` still measure as declined, and the reason is not a + * missing feature of the walker: `block` is `suspend PointerInputScope.() -> Unit`, whose *compiled* + * shape is `Function2, Any?>`. Kotlin source refuses to assign + * an ordinary `Function2` to a `suspend` function type at all, and even if it did not, a Python + * callable cannot itself answer `COROUTINE_SUSPENDED` and be resumed later -- there is no coroutine + * machinery on the CPython side of the boundary that could save and restore a JVM stack. + * + * ### What this function is, and is not + * + * It is **not** a generated binding of `Modifier.pointerInput` -- no artefact walker or KSP processor + * produces the suspend lambda `pointerInput` wants, and nothing added here changes that; `pointerInput` + * itself stays declined, correctly, for the reason above. + * + * It **is** a hand-written Kotlin `suspend` function -- compiled once, ordinarily, by `kotlinc`, the + * same way `PythonComposition` is the one hand-written `@Composable` this module needs. The + * suspension the JVM calling convention requires lives entirely in this function's own body + * (`awaitPointerEventScope { while (true) { awaitPointerEvent() } }`), which is real Kotlin source + * with a real suspension point the compiler builds a state machine for. Python is never asked to + * suspend: [onEvent] is an ordinary, **non-suspending** callable, invoked synchronously once per + * pointer event, exactly the shape `PythonCallables`/`UpcallTrampoline` already cross for every other + * plain `Function0`..`Function5` slot in this codebase. + * + * So the boundary sees an *ordinary* function -- `pythonPointerInput(modifier, onEvent)`, both + * parameters and the return type an object handle or `PyObject` -- and KSP binds it exactly as it + * binds `emptyModifier` a few lines over: a plain top-level Kotlin function, reached from Python under + * this module's own package name (`fixture.compose.pythonPointerInput`), not through `pythonx`'s + * dynamic `androidx.*` loader and not through the artefact walker. + * + * ### Why a plain parameter and not an extension receiver + * + * `fun Modifier.pythonPointerInput(...)` would read naturally as `m.pythonPointerInput(cb)`, but + * `FragmentScanner.topLevelFunctionEntry` calls a top-level declaration by its qualified name with + * `function.parameters` alone -- it never reads `KSFunctionDeclaration.extensionReceiver`, so an + * extension's receiver would silently vanish from the generated call. Nothing here changes that + * (out of scope: `FragmentScanner` is shared infrastructure), so [modifier] is an ordinary first + * parameter instead, the same shape [pythonPointerInput]'s caller already uses for + * `androidx.compose.foundation.layout.size__Dp`, which is a walked *extension* rendered with an + * explicit `` slot by a different code path (`ArtifactScanner`, not `FragmentScanner`). + * + * ### Lifetime + * + * [onEvent] crosses as a fresh Python reference (`UpcallTrampoline.toKotlinObject`'s + * `PyObject(value, borrowed = true)`), and this function is the only thing that can give it back -- + * unlike a `content=` slot there is no `PythonCallableScope`/`RememberObserver` in the loop here, so + * the reference is released in a `finally` around the whole suspend body, which runs on ordinary + * completion and on cancellation alike (Compose cancels this coroutine when the modifier leaves + * composition or `pointerInput`'s key changes). + * + * ### What is not handled + * + * - Only the first [androidx.compose.ui.input.pointer.PointerEvent.changes] entry is marshalled. + * Multi-touch is out of scope for a proof that a single Python callback receives a single real tap. + * - The `key1` `pointerInput` normally restarts on is fixed to [Unit], so this coroutine is started + * once per composition of the node and never restarted for a changed key. Nothing measured here + * needs restart semantics. + */ +fun pythonPointerInput(modifier: Modifier, onEvent: PyObject): Modifier = modifier.pointerInput(Unit) { + try { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull() ?: continue + val type = when (event.type) { + PointerEventType.Press -> "press" + PointerEventType.Release -> "release" + PointerEventType.Move -> "move" + else -> "other" + } + val typeArg = PyString.from(type) + val xArg = PyFloat.from(change.position.x.toDouble()) + val yArg = PyFloat.from(change.position.y.toDouble()) + try { + onEvent(typeArg, xArg, yArg).close() + } finally { + typeArg.close() + xArg.close() + yArg.close() + } + } + } + } finally { + onEvent.close() + } +} diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPullRefresh.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPullRefresh.kt new file mode 100644 index 000000000..d7f042280 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonPullRefresh.kt @@ -0,0 +1,108 @@ +package fixture.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.types.basic.PyFloat + +/** + * Test harness for `pullRefresh`'s callback overload — + * `androidx.compose.material.pullrefresh.PullRefreshKt.pullRefresh(Modifier, + * onPull: (Float) -> Float, onRelease: suspend (Float) -> Float, enabled: Boolean)`. + * + * `docs/archive/pythonx-adapter-design.md` §9.4 identifies this overload as requiring a **real nested-scroll + * parent/child tree**: `onPull`/`onRelease` are fired by `NestedScrollConnection` callbacks from a + * scrollable descendant's pre-scroll/pre-fling reports, not by pointer events the modifier's own + * body awaits directly. This function is that harness. + * + * ### How the callbacks are bridged + * + * `onPull: (Float) -> Float` must return the amount consumed. Per §9.6's settled shape for + * value-returning slots, **Kotlin owns the return value**: the lambda always returns `pullAvailable` + * (consuming everything offered), while calling [onPull] as a one-way notification to Python. + * + * `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 + * `pythonPointerInput`/`pythonDraggable` handle their own suspend slots. + * + * ### Why the scene is built here, not from a Python BODY string + * + * A scrollable descendant requires `rememberScrollState()` + `verticalScroll` or `LazyColumn`, + * 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 + * + * §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, + * and the callback-reaching proof needs only the former. + * + * ### Lifetime + * + * [onPull] and [onRelease] are held for the lifetime of the lambdas passed to `Modifier.pullRefresh`; + * `PullRefreshNestedScrollConnection` holds them for as long as the modifier is attached. + * They are not closed here, matching the same unfixed lifetime acknowledged in `pythonDraggable`, + * `pythonAnchoredDraggableString`, and `pythonSwipeableString`. The test that uses this harness + * closes the PyObjects itself in a `finally` block. + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun PythonPullRefreshHarness(onPull: PyObject, onRelease: PyObject, modifier: Modifier = Modifier.fillMaxSize()) { + Box( + modifier = modifier + .background(Color.White) + .pullRefresh( + onPull = { pullAvailable: Float -> + // 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 { + onPull(arg).close() // borrow: arg is borrowed by onPull; .close() is on the *result* + } finally { + arg.close() // release our own arg reference + } + pullAvailable // consume everything offered + }, + onRelease = { velocity: Float -> + // suspend slot — Kotlin suspends here; Python is a synchronous notification. + val arg = PyFloat.from(velocity.toDouble()) + try { + onRelease(arg).close() + } finally { + arg.close() + } + 0f // return value: Kotlin provides, Python does not + }, + enabled = true, + ), + ) { + // The scrollable descendant whose nested-scroll callbacks drive onPull/onRelease. + // LazyColumn is always at the top of its content on first render (no initial scroll), + // so a downward drag immediately produces an over-scroll that reaches onPull. + // Using 200 items so the column definitely overflows, ensuring scrollState.canScrollBackward + // is false when at the top. + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(200) { index -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .background(if (index % 2 == 0) Color.LightGray else Color.Gray), + ) + } + } + } +} diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonSwipeable.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonSwipeable.kt new file mode 100644 index 000000000..6656cd981 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonSwipeable.kt @@ -0,0 +1,102 @@ +package fixture.compose + +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FractionalThreshold +import androidx.compose.material.SwipeableState +import androidx.compose.material.swipeable +import androidx.compose.ui.Modifier +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.types.basic.PyString + +/** + * `Modifier.swipeable` (`androidx.compose.material.SwipeableKt.swipeable-pPrIpRY`), declined for the same + * reason `anchoredDraggable` is (`docs/archive/pythonx-adapter-design.md` §9.2, §9.5): its type parameter `T` -- + * here on both `SwipeableState` and the `anchors: Map` parameter -- has no name a cast could + * spell. Fixed the same way: one wrapper per concrete `T`. `String` was chosen for the same reason + * `anchoredDraggable`'s wrapper chose it -- the states a swipe moves between are discrete identifiers a + * caller writes literally ("start", "end"). + * + * ### `thresholds` is not crossed to Python at all -- confirmed why, not assumed + * + * `javap -p` on `SwipeableKt` (Compose Material 1.7.0 desktop jar; there is no sources jar for this + * version) shows the real signature: + * + * public static final Modifier swipeable-pPrIpRY( + * Modifier, SwipeableState, Map anchors, Orientation, + * boolean enabled, boolean reverseDirection, MutableInteractionSource, + * Function2 thresholds, + * ResistanceConfig, float velocityThreshold) + * + * `thresholds` is a **value-returning** function slot -- it must produce a `ThresholdConfig`, not + * `Unit` -- and §10 of the design doc already measured that this dispatcher rejects a Python callable + * for a value-returning slot (`aValueReturningFunctionSlotDoesNotYetAcceptAPythonCallable`, for a bare + * `() -> Float`). Substituting a `PyObject` for `thresholds` here would hit the same rejection, and even + * if the dispatcher accepted it, the callable would have to construct and return a `ThresholdConfig` -- + * another type Python cannot spell, on top of the boxed-return problem. So this wrapper does not route + * `thresholds` to Python: [thresholdFraction] is an ordinary marshalled `Float`, and Kotlin closes over + * it to build the lambda itself -- + * + * thresholds = { _, _ -> FractionalThreshold(thresholdFraction) } + * + * -- exactly what the task brief suggested ("코틀린 쪽에서 그 람다를 제공하고 파이썬에는 값만 받게 + * 하는 것"). No callable crosses the boundary for this slot at all; only a value does, which is the + * shape every other parameter in this codebase already crosses without incident. The `(T, T)` inputs + * `thresholds` is handed are discarded -- `FractionalThreshold` doesn't use them (it needs only the + * total drag distance, supplied later by `computeThreshold`'s own `Density`/from/to floats) -- so fixing + * the fraction ahead of time loses nothing `swipeable` itself would have used them for. + * + * `confirmStateChange: (T) -> Boolean` (`SwipeableState`'s own constructor parameter, not `swipeable`'s) + * is a second value-returning slot, handled the same way [pythonAnchoredDraggableString] handles + * `AnchoredDraggableState`'s `confirmValueChange`: [onValueChange] is invoked as a one-way notification + * and this function always returns `true` from the Kotlin lambda, never routing a Python return value + * back into Compose. + * + * ### What this function is + * + * A hand-written Kotlin function, bound by KSP as a plain top-level entry exactly the way + * [pythonAnchoredDraggableString] and [pythonPointerInput] are -- not a generated binding of + * `Modifier.swipeable`, which stays declined, correctly, for the reason above. + * + * ### Lifetime -- not handled, same as [pythonDraggable] and [pythonAnchoredDraggableString] + * + * [onValueChange] is held by the [SwipeableState] this function builds and is never closed here; each + * call to this function leaks that one reference. Not fixed here, on purpose -- `DraggableLeakTest` + * already pins the shape of this exact leak family and the reason interning cannot reach it. + */ +@OptIn(ExperimentalMaterialApi::class) +fun pythonSwipeableString( + modifier: Modifier, + initialValue: String, + anchor1Value: String, + anchor1Offset: Float, + anchor2Value: String, + anchor2Offset: Float, + thresholdFraction: Float, + onValueChange: PyObject, +): Modifier { + val state = SwipeableState( + initialValue = initialValue, + animationSpec = spring(), + confirmStateChange = { newValue: String -> + val pyStr = PyString.from(newValue) + try { + onValueChange(pyStr).close() + } finally { + pyStr.close() + } + true + }, + ) + + return modifier.swipeable( + state = state, + anchors = mapOf( + anchor1Offset to anchor1Value, + anchor2Offset to anchor2Value, + ), + orientation = Orientation.Horizontal, + thresholds = { _, _ -> FractionalThreshold(thresholdFraction) }, + ) +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnchoredDraggableRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnchoredDraggableRenderTest.kt new file mode 100644 index 000000000..9331271fb --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnchoredDraggableRenderTest.kt @@ -0,0 +1,155 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +class AnchoredDraggableRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + @Test + fun aRealDragReachesThePythonCallbacksThroughAnchoredDraggable() { + Python3.exec( + """ + _confirm_events = [] + + def _on_confirm(value): + _confirm_events.append(value) + return True + """.trimIndent(), + ) + + val before = pixelsOf(BODY) + assertTrue(inkOf(before) > 0, "the Text never composed, so there was nothing to drag") + + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(BODY) + } + try { + scene.render() + scene.sendPointerEvent(PointerEventType.Move, Offset(START_X, BOX_CENTRE)) + scene.sendPointerEvent(PointerEventType.Press, Offset(START_X, BOX_CENTRE)) + var x = START_X + while (x < START_X + DRAG_DISTANCE) { + x += STEP + scene.sendPointerEvent(PointerEventType.Move, Offset(x, BOX_CENTRE)) + } + scene.sendPointerEvent(PointerEventType.Release, Offset(x, BOX_CENTRE)) + scene.render() // Wait for the snap animation to settle + } finally { + scene.close() + } + + // The drag crosses the positional threshold and triggers confirmValueChange to the new anchor + Python3.exec( + "assert len(_confirm_events) > 0, 'on_confirm was never invoked'", + ) + Python3.exec( + "assert 'end' in _confirm_events, 'did not try to settle at end anchor: ' + repr(_confirm_events)", + ) + + val after = pixelsOf(BODY) + val moved = differing(before, after) + println( + "compose input: anchoredDraggable drag -> ink ${inkOf(before)} -> ${inkOf(after)} px, " + + "$moved pixels changed", + ) + assertTrue( + moved > 0, + "no pixel changed after the drag, so the component did not move", + ) + } + + @Test + fun aPressOutsideTheBoxInvokesNothing() { + Python3.exec( + """ + _confirm_events = [] + + def _on_confirm(value): + _confirm_events.append(value) + return True + """.trimIndent(), + ) + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(BODY) + } + try { + scene.render() + val at = Offset(SCENE - 2f, SCENE - 2f) + scene.sendPointerEvent(PointerEventType.Move, at) + scene.sendPointerEvent(PointerEventType.Press, at) + scene.sendPointerEvent(PointerEventType.Move, Offset(SCENE - 20f, SCENE - 2f)) + scene.sendPointerEvent(PointerEventType.Release, Offset(SCENE - 20f, SCENE - 2f)) + scene.render() + } finally { + scene.close() + } + Python3.exec( + "assert _confirm_events == [], " + + "'a press outside the box invoked anchoredDraggable: ' + repr(_confirm_events)", + ) + } + + private fun pixelsOf(body: String): IntArray { + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return IntArray(SCENE * SCENE) { bitmap.getColor(it % SCENE, it / SCENE) } + } finally { + scene.close() + } + } + + private fun inkOf(pixels: IntArray): Int = pixels.count { it != BACKGROUND } + + private fun differing(a: IntArray, b: IntArray): Int = a.indices.count { a[it] != b[it] } + + private companion object { + const val BACKGROUND = 0 + const val SCENE = 80 + + const val BOX_CENTRE = 24f + const val START_X = 4f + const val DRAG_DISTANCE = 40f + const val STEP = 8f + + val BODY = """ + from fixture.compose import emptyModifier, pythonAnchoredDraggableString + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.material3 import Text + + _m = pythonAnchoredDraggableString(size__Dp(emptyModifier(), 48.0), "start", "start", 0.0, "end", 40.0, _on_confirm) + Text(str(len(_confirm_events)), modifier=_m) + """.trimIndent() + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt new file mode 100644 index 000000000..80b873ad3 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt @@ -0,0 +1,137 @@ +package fixture.compose + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import python.multiplatform.compose.PythonContent +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Issue #69 against the real Compose runtime: a counter declared from Python as + * `mutableStateOf(0)` -- the walked generic function, its `T` read as `kotlin.Any?` -- holds a Kotlin + * `Int`, and incrementing it from Python (`counter.value = counter.value + 1`, the walked + * `MutableState.value` getter and setter) recomposes the root that read it. + * + * Modelled on [PythonContentRenderTest]: `ImageComposeScene` has no frame clock, so the test is the + * clock (`Snapshot.sendApplyNotifications()` and a render), and the root counts its own invocations so + * a frame can be attributed. + * + * ### Red before #69, and what that red says + * + * The first statement, `mutableStateOf(0)`, raised `TypeError: mutableStateOf(value: Any): an int + * cannot be stored in a Kotlin Any?: it would cross as an object handle`. A *regression* reads + * differently: a root that is not composed again after the write (the state was not a snapshot + * state, or the read was not observed), or a Kotlin value that is a `Long` rather than an `Int`. + */ +class AnySlotScalarRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + Python3.exec( + """ + for _asr_name in ('_asr_counter', '_asr_root', '_asr_seen'): + globals().pop(_asr_name, None) + """.trimIndent(), + ) + UpcallTable.clear() + } + + @Test + fun aCounterDeclaredAsMutableStateOfZeroAndIncrementedFromPythonRecomposes() { + Python3.exec( + """ + from androidx.compose.runtime import mutableStateOf + from androidx.compose.material3 import Text + + _asr_counter = mutableStateOf(0) + _asr_seen = [] + + def _asr_root(): + _asr_n = _asr_counter.value + _asr_seen.append(_asr_n) + Text('count ' + str(_asr_n)) + """.trimIndent(), + ) + assertEquals("0", pyStr("repr(_asr_counter.value)")) + assertEquals("int", pyStr("type(_asr_counter.value).__name__")) + assertEquals(0, kotlinValue(), "Kotlin does not hold the Int 0") + + val root = Python3.import("__main__").getAttr("_asr_root") + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonContent(root = root) + } + try { + scene.render() + assertEquals("[0]", pyStr("repr(_asr_seen)"), "the root was not composed once with the initial count") + + // An idle frame is not a reason to compose again: nothing here polls. + scene.render() + assertEquals("[0]", pyStr("repr(_asr_seen)"), "an idle frame re-ran the root") + + Python3.exec("_asr_counter.value = _asr_counter.value + 1") + assertEquals(1, kotlinValue(), "the increment is not a Kotlin Int 1") + nextFrame(scene) + assertEquals("[0, 1]", pyStr("repr(_asr_seen)"), "the increment did not recompose the root") + + Python3.exec("_asr_counter.value = _asr_counter.value + 1") + nextFrame(scene) + assertEquals("[0, 1, 2]", pyStr("repr(_asr_seen)"), "the second increment did not recompose the root") + assertTrue( + pyStr("all(type(_asr_x) is int for _asr_x in _asr_seen)") == "True", + "a count read during composition was not a Python int: ${pyStr("repr([type(_asr_x) for _asr_x in _asr_seen])")}", + ) + } finally { + scene.close() + root.close() + } + assertEquals(2, kotlinValue()) + } + + /** + * What the Kotlin `MutableState` behind the Python proxy holds, read on the Kotlin side: the + * issue's rule is `kotlin.Int` for a value that fits in 32 bits, and an `Int` is not equal to a + * `Long` of the same value, so this tells the two apart. + */ + private fun kotlinValue(): Any? { + val raw = pyStr("_asr_counter._pm_handle").toLong() + val state = HandleTable.resolveRaw(raw) as MutableState<*> + return state.value + } + + private fun nextFrame(scene: ImageComposeScene) { + Snapshot.sendApplyNotifications() + scene.render() + } + + private fun pyStr(expression: String): String = Python3.import("__main__").getAttr("__dict__").let { globals -> + Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString() + } + + private companion object { + /** `Py_eval_input`. */ + const val PY_EVAL_INPUT = 258 + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt new file mode 100644 index 000000000..efd432946 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt @@ -0,0 +1,477 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import java.awt.Canvas +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **A Python callback, driven by a real event** -- the third wall `ebe3365f` left standing. + * + * ### What was missing, and why it mattered + * + * Every render proof in this module so far is a *static rasterisation*: build a scene, call + * `render()`, count pixels. That proves a composable was reached and drew, and it can say nothing at + * all about a slot whose whole purpose is to be **called back** -- `Checkbox.onCheckedChange`, + * `Slider.onValueChange`, `TextField.onValueChange`. `ComposableBindingTest` proves those slots are + * *described* (`kotlin.Function1(kotlin.Boolean)->kotlin.Unit`) and `FunctionSlotBindingTest` proves + * a Python callable can be *put* in one, and nothing anywhere proved one is ever *invoked*. + * + * ### What delivers the event + * + * `ImageComposeScene.sendPointerEvent`, which is on the same class the rasterisation tests already + * use -- so this needs no new harness, no window and no display. A press and a release at the + * component's centre is what Compose turns into a click, and `Checkbox` is the smallest bound + * declaration that reacts to one with a value rather than with a `Unit`: the argument it hands back + * is the thing to assert, because a callback invoked with the wrong value and a callback invoked at + * all are different claims. + * + * ### Why the redraw is a second scene + * + * `Checkbox` is stateless: what it draws comes from its `checked` argument, which is Python's to + * hold. Clicking it invalidates Compose's own interaction state, not `PythonComposition`'s -- so the + * scene that received the click will not re-run the Python body, and asserting on its second frame + * would be asserting on nothing. A **fresh** scene over the same body reads the Python variable the + * callback wrote, which is the honest way round: the pixels prove what Python holds now, and the + * `_events` list proves how it got there. + */ +class CallbackDrivenRenderTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * **The claim.** A `lambda v: ...` written in Python, in `Checkbox`'s `onCheckedChange` slot, + * invoked by a pointer press and release, with `True` -- and the state it wrote showing up as + * different pixels the next time the same body composes. + * + * Four assertions, and each one fails for a different reason: + * + * | assertion | what it rules out | + * |---|---| + * | the unclicked control draws something | the checkbox never composed, so nothing was there to click | + * | `_events == [True]` after the click | the callback was never invoked, or was invoked with the wrong value -- a `Function1` handed a `Boolean` it forwards as `None` fails here | + * | the two renders differ | Python holds the new state but nothing can see it | + * | the untouched control does **not** differ | the difference came from the scene being built twice rather than from the click | + */ + @Test + fun clickingACheckboxInvokesThePythonCallbackAndTheStateItWroteRedraws() { + Python3.exec( + """ + _cb_events = [] + _cb_state = [False] + + def _cb_toggle(value): + _cb_events.append(value) + _cb_state[0] = value + """.trimIndent(), + ) + val unchecked = pixelsOf(CHECKBOX) + assertTrue(inkOf(unchecked) > 0, "the Checkbox never composed, so there was nothing to click") + + // The control first, and before the click: composing the same body twice must not, on its + // own, change a single pixel. + assertEquals(0, differing(unchecked, pixelsOf(CHECKBOX)), "the render is not deterministic") + + clickCentre(CHECKBOX) + // Asserted in Python, because the value to check is a Python one: the boundary carries no + // reader for an arbitrary expression, and a Kotlin-side copy of `_cb_events` would be a + // second thing that can disagree with the list the callback actually appended to. + Python3.exec("assert _cb_events == [True], repr(_cb_events)") + val checked = pixelsOf(CHECKBOX) + val moved = differing(unchecked, checked) + println( + "compose input: Checkbox click -> onCheckedChange(True), " + + "ink ${inkOf(unchecked)} -> ${inkOf(checked)} px, $moved pixels changed", + ) + assertTrue( + moved > 100, + "only $moved pixels changed after the click, so the state the callback wrote never reached Compose", + ) + } + + /** + * The negative control, stated apart so that the test above cannot pass by accident: a press and + * a release **outside** the component invoke nothing. Without this, a callback invoked by + * composition rather than by the click would satisfy every assertion above. + */ + @Test + fun aClickThatMissesTheComponentInvokesNothing() { + Python3.exec( + """ + _cb_events = [] + _cb_state = [False] + + def _cb_toggle(value): + _cb_events.append(value) + _cb_state[0] = value + """.trimIndent(), + ) + clickAt(CHECKBOX, Offset(SCENE - 2f, SCENE - 2f)) + Python3.exec("assert _cb_events == [], 'a click that missed the checkbox invoked its callback: ' + repr(_cb_events)") + } + + /** + * The same event, one declaration further on: `Switch` is a different composable with the same + * `(Boolean) -> Unit` shape, so a mechanism that only worked for `Checkbox` -- a lucky slot + * index, a `$default` mask that happened to line up -- fails here. + */ + @Test + fun theSameCallbackShapeIsDrivenOnASecondDeclaration() { + Python3.exec( + """ + _cb_events = [] + _cb_state = [False] + + def _cb_toggle(value): + _cb_events.append(value) + _cb_state[0] = value + """.trimIndent(), + ) + val before = pixelsOf(SWITCH) + assertTrue(inkOf(before) > 0, "the Switch never composed") + clickCentre(SWITCH) + Python3.exec("assert _cb_events == [True], repr(_cb_events)") + val after = pixelsOf(SWITCH) + val moved = differing(before, after) + println( + "compose input: Switch click -> onCheckedChange(True), " + + "ink ${inkOf(before)} -> ${inkOf(after)} px, $moved pixels changed", + ) + // Deliberately not an ink comparison: the thumb slides from one end of the track to the + // other, which is a large change that leaves the total almost exactly where it was. + assertTrue(moved > 100, "only $moved pixels changed, so the switch drew the same state twice") + } + + /** + * **The fourth wall, and the one `7d6c0a1` in `pythonx-compose` left standing:** a + * `(String) -> Unit` slot, driven by real key input rather than by a pointer. + * + * ### Why this needs a key event and not another `sendPointerEvent` + * + * `Checkbox` and `Switch` above are driven end to end by a click alone -- the value the callback + * receives is derived from the control's own toggled state, not from anything the test supplies. + * `TextField`'s `onValueChange` carries the *typed* string, so the only way to drive it honestly + * is to deliver characters, which is a different plumbing: `ImageComposeScene` exposes a second + * entry point for it, `sendKeyEvent`, and it was never exercised anywhere in this module. + * + * ### What makes a character actually commit, found by decompiling (no sources jar for this + * Compose version): three dead ends before this one + * + * `androidx.compose.ui.input.key.KeyEvent(key, type, codePoint, ...)` is the public factory for a + * synthetic key event, and the first three shapes tried with it all reached the focused node -- + * `scene.sendKeyEvent` returned `true` for a bare `Modifier.onKeyEvent` -- and still left + * `TextField`'s `onValueChange` uncalled, with `sendKeyEvent` itself returning `false`: + * + * 1. `KeyEventType.KeyDown` with a codepoint, on the theory that a "key down" is what typing is. + * `TextFieldKeyInput_desktopKt.isTypedEvent` (decompiled) does not look at this event's own + * `type` field at all for the character-commit path. + * 2. Explicit focus via `FocusRequester.requestFocus()` in a `SideEffect`, in case the click's + * tap-to-focus gesture was the gap. Confirmed *not* the gap: `Modifier.onFocusChanged` on the + * same field reported `isFocused` going `false -> true`, and the key was still refused. + * 3. More render passes, in case some `LaunchedEffect`-driven input-session start just hadn't run + * yet. A bare `LaunchedEffect(Unit) { }` in the same harness does run, so that theory falls too. + * + * `isTypedEvent-ZmokQxo`'s decompiled body is what actually decides it: it calls + * `AwtEvents_desktopKt.getAwtEventOrNull` on the event, and returns `false` outright if that is + * `null` or its own AWT `getID()` is not `400` (`KEY_TYPED`) -- checking the *wrapped native AWT + * event*, never the Compose-level `type` this test's first three attempts were varying. The public + * `KeyEvent(...)` factory takes a `nativeEvent: Any?` parameter defaulting to `null`, which is + * exactly the gap: every synthetic event built without one is invisible to this check, regardless + * of `type` or focus. [typedKey] supplies a real `java.awt.event.KeyEvent(..., KEY_TYPED, ..., + * keyChar)` there, which is what `sendPointerEvent`-only attempts have no equivalent gap for -- + * that boundary takes structured Compose values directly and never asks whether something upstream + * produced them. + * + * (`AwtKeyEvent(...).toComposeEvent()`, the obvious way to build that wrapped pair, does not + * compile from this module: it is `internal` to `ui-desktop`'s own compilation -- public in the + * class file, since Kotlin `internal` erases to public JVM bytecode, but rejected by the Kotlin + * compiler's module check. [typedKey] builds the same shape by hand through the public factory + * instead.) + * + * ### Why the field is clicked before it is typed into + * + * A key event has no target of its own -- `ImageComposeScene.sendKeyEvent` delivers it to whatever + * currently holds focus, the same way a real window would. `BasicTextField` (which M3's `TextField` + * wraps) requests focus on press the same way `Checkbox` and `Switch` already register clicks, so + * the press-and-release this class already uses for a toggle is reused here for exactly the same + * reason: to establish, mechanically, that this is the field the keystroke reaches, not composition + * order or the only focusable node in the scene. + * + * ### Why this drives one keystroke, not two, inside a single scene + * + * A second keystroke inside the *same* scene was tried first, on the theory that it would prove + * more (accumulation, not just a single character crossing). It fails, and not on anything this + * class's mechanism is responsible for: M3's `TextField(value: String, onValueChange, ...)` copies + * its *own* `value` parameter back onto the field's internal editable state on every recomposition + * -- `textFieldValueState.copy(text = value)`, the standard "controlled" pattern -- and + * `PythonComposition`'s [source] is only `exec`ed when *its own* composable scope recomposes. + * Nothing about a Python callback mutating a plain Python list is visible to Compose's snapshot + * system, so `PythonComposition` never recomposes on its own after `_tf_on_change` runs, and the + * `value` fed to `TextField` stays whatever it was at the scene's first composition for the scene's + * whole life. Confirmed by driving `'h'` then `'i'` in one scene: `_tf_events` comes back + * `['h', 'i']`, not `['h', 'hi']` -- each keystroke lands on the *original* empty value, because + * nothing ever told `TextField` the first one had happened. A parallel probe with a plain Kotlin + * `mutableStateOf` in place of the Python list (not committed -- built to isolate this, then + * discarded once the cause was confirmed) accumulates correctly in exactly the same scene, which is + * what places the cause in the boundary between Python state and Compose's snapshot system, not in + * key-event delivery. [twoKeystrokesAccumulateAcrossTwoFreshScenes] below shows the shape that + * *does* work: the same one this class already uses for `Checkbox` and `Switch`, extended by one + * more scene. + */ + @Test + fun typingIntoATextFieldInvokesThePythonCallbackWithTheStringAndTheNextRenderShowsIt() { + Python3.exec( + """ + _tf_events = [] + _tf_state = [""] + + def _tf_on_change(value): + _tf_events.append(value) + _tf_state[0] = value + """.trimIndent(), + ) + val empty = pixelsOf(TEXT_FIELD, width = FIELD_WIDTH, height = FIELD_HEIGHT) + assertTrue(inkOf(empty) > 0, "the TextField never composed, so there was nothing to type into") + + typeOneCharacterInAFreshScene('h') + + // Asserted here, in Python, for the same reason `_cb_events` is asserted in Python above: the + // boundary carries no reader for an arbitrary Python list, and a Kotlin-side copy would be a + // second thing that could disagree with what the callback actually appended. + Python3.exec("assert _tf_events == ['h'], repr(_tf_events)") + Python3.exec("assert _tf_state[0] == 'h', repr(_tf_state[0])") + + val typed = pixelsOf(TEXT_FIELD, width = FIELD_WIDTH, height = FIELD_HEIGHT) + val moved = differing(empty, typed) + println( + "compose input: TextField keystroke 'h' -> onValueChange, " + + "ink ${inkOf(empty)} -> ${inkOf(typed)} px, $moved pixels changed", + ) + assertTrue( + moved > 10, + "only $moved pixels changed after typing, so the string the callback wrote never reached the render", + ) + } + + /** + * The multi-character claim [typingIntoATextFieldInvokesThePythonCallbackWithTheStringAndTheNextRenderShowsIt]'s + * KDoc says a single scene cannot make: two keystrokes, each in its **own** fresh scene, the same + * unit [pixelsOf] and `clickAt` already use to observe what a callback wrote. The first scene reads + * `_tf_state[0]` as `""` and types `'h'`; the second is built *after* that scene closes, so it reads + * `_tf_state[0]` as `'h'` -- the value the first scene's callback wrote -- and types `'i'` onto it. + * If `PythonComposition`'s "re-`exec` on every composition" claim is what makes accumulation + * possible at all, this is where it has to show up: `_tf_events` accumulating to `['h', 'hi']` + * across scenes is a different claim from a single scene accumulating on its own, and the test above + * is the evidence that the second claim does not hold. + */ + @Test + fun twoKeystrokesAccumulateAcrossTwoFreshScenes() { + Python3.exec( + """ + _tf_events = [] + _tf_state = [""] + + def _tf_on_change(value): + _tf_events.append(value) + _tf_state[0] = value + """.trimIndent(), + ) + + typeOneCharacterInAFreshScene('h') + Python3.exec("assert _tf_state[0] == 'h', repr(_tf_state[0])") + + typeOneCharacterInAFreshScene('i') + + Python3.exec("assert _tf_events == ['h', 'hi'], repr(_tf_events)") + Python3.exec("assert _tf_state[0] == 'hi', repr(_tf_state[0])") + } + + /** + * One scene, built fresh from whatever `_tf_state[0]` currently holds (the same `TEXT_FIELD` source + * every `TextField` test in this class shares): focused by a click, typed into with one + * `sendKeyEvent`, then closed. + * + * Neither `_tf_state[0]` nor `_tf_events` is asserted on here -- `scene.close()` was found + * (empirically, via a real file `Python3.exec` wrote to, since `print()` writes to the + * interpreter's own fd and Gradle's test capture does not see it) to invoke `_tf_on_change` one + * more time on its way out, with the value **as it was before the just-typed character**: closing + * a scene whose `TextField` still has a focused, freshly-typed character reverts that character + * rather than confirming it, the same way a real IME cancels an uncommitted composing segment on + * focus loss. Both callers need what the keystroke actually produced, not what teardown reverted it + * to, so this function snapshots `_tf_state[0]` and `_tf_events` right after the keystroke and + * restores both right after `close()` -- which is possible because `close()`'s extra call is always + * exactly one more list append and one more list write, never more. + */ + private fun typeOneCharacterInAFreshScene(char: Char) { + val scene = ImageComposeScene(width = FIELD_WIDTH, height = FIELD_HEIGHT, density = Density(1f)) { + PythonComposition(TEXT_FIELD) + } + try { + scene.render() + // Move first, then press-and-release: the same tap shape `clickAt` uses for Checkbox and + // Switch, reused here to focus the field before anything is typed into it. + val focusAt = Offset(FIELD_WIDTH / 4f, FIELD_HEIGHT / 2f) + scene.sendPointerEvent(PointerEventType.Move, focusAt) + scene.sendPointerEvent(PointerEventType.Press, focusAt) + scene.sendPointerEvent(PointerEventType.Release, focusAt) + scene.render() + + scene.sendKeyEvent(typedKey(char)) + scene.render() + + // Saved apart from both lists because `close()` below appends/overwrites once more, and + // this function's callers both need what the keystroke actually produced. + Python3.exec("_tf_typed_value = _tf_state[0]") + Python3.exec("_tf_events_snapshot = list(_tf_events)") + } finally { + scene.close() + } + Python3.exec("_tf_state[0] = _tf_typed_value") + Python3.exec("_tf_events[:] = _tf_events_snapshot") + } + + /** A synthetic key event that commits [char] the way a real keystroke would: `type` is `Unknown` + * (what `toComposeEvent` itself maps a `KEY_TYPED` AWT event to, since `401`/`402` are the only + * ids that become `KeyDown`/`KeyUp` there) and `nativeEvent` is a real + * `java.awt.event.KeyEvent(..., KEY_TYPED, ..., char)` -- see this method's enclosing test's KDoc + * for why the `nativeEvent` is load-bearing and the other four fields are not. */ + @OptIn(InternalComposeUiApi::class) + private fun typedKey(char: Char) = KeyEvent( + key = Key(nativeKeyCode = char.code), + type = KeyEventType.Unknown, + codePoint = char.code, + nativeEvent = java.awt.event.KeyEvent( + KEY_EVENT_SOURCE, + java.awt.event.KeyEvent.KEY_TYPED, + System.currentTimeMillis(), + 0, + java.awt.event.KeyEvent.VK_UNDEFINED, + char, + ), + ) + + private fun clickCentre(body: String) = clickAt(body, Offset(COMPONENT_CENTRE, COMPONENT_CENTRE)) + + /** Builds the scene, lays it out with one render, delivers a press and a release at [at], and + * lets the scene go. The frame after the click is deliberately not measured; see this class's + * KDoc for why the redraw has to be a fresh scene. */ + private fun clickAt(body: String, at: Offset) { + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(body) + } + try { + scene.render() + // Move first: a pointer that has never been anywhere has no position for the press to be + // *in*, and Compose's tap detector wants an enter before a press. + scene.sendPointerEvent(PointerEventType.Move, at) + scene.sendPointerEvent(PointerEventType.Press, at) + scene.sendPointerEvent(PointerEventType.Release, at) + scene.render() + } finally { + scene.close() + } + } + + /** + * Every pixel of one render of [body], so that two renders can be compared **position by + * position** rather than by how much ink each contains. + * + * The difference matters for `Switch` and would have made this test a false negative: flipping it + * slides a thumb from one end of the track to the other and recolours both, which moves hundreds + * of pixels while changing the *total* ink by four. Counting ink would have called that "no + * change"; counting disagreements calls it what it is. + */ + private fun pixelsOf(body: String, width: Int = SCENE, height: Int = SCENE): IntArray { + val scene = ImageComposeScene(width = width, height = height, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return IntArray(width * height) { bitmap.getColor(it % width, it / width) } + } finally { + scene.close() + } + } + + private fun inkOf(pixels: IntArray): Int = pixels.count { it != BACKGROUND } + + private fun differing(a: IntArray, b: IntArray): Int = a.indices.count { a[it] != b[it] } + + private companion object { + /** `ImageComposeScene` clears to transparent black. */ + const val BACKGROUND = 0 + + /** Wide enough for material3's 48dp minimum interactive size at density 1, with room around + * it for [aClickThatMissesTheComponentInvokesNothing] to miss into. */ + const val SCENE = 80 + + /** The component's centre, not the scene's. Content is placed at the scene's top-left, so a + * 48dp interactive box occupies `(0,0)..(48,48)` and its middle is 24 -- measured, not + * assumed: the ink a `Checkbox` leaves in this scene spans `(14,14)..(33,33)`, which is the + * 20dp box centred on exactly that point. Clicking the *scene's* centre lands outside it and + * invokes nothing, which is what [aClickThatMissesTheComponentInvokesNothing] now asserts + * deliberately. */ + const val COMPONENT_CENTRE = 24f + + /** `checked` is read out of Python on every composition, which is what makes a fresh scene + * show what the callback wrote. */ + val CHECKBOX = """ + from androidx.compose.material3 import Checkbox + Checkbox(_cb_state[0], onCheckedChange=_cb_toggle) + """.trimIndent() + + val SWITCH = """ + from androidx.compose.material3 import Switch + Switch(_cb_state[0], onCheckedChange=_cb_toggle) + """.trimIndent() + + /** M3's `TextField` has no minimum-size default anywhere near `Checkbox`'s 48dp -- its own + * default width is intrinsic content plus padding, comfortably under 240dp -- so the shared + * [SCENE] square is too narrow to hold one without clipping the area a click needs to land in. + * Sized generously rather than measured exactly, since -- unlike [COMPONENT_CENTRE] -- nothing + * here depends on the field's bounds matching the scene to the pixel: [focusAt] only needs to + * land inside it. */ + const val FIELD_WIDTH = 240 + const val FIELD_HEIGHT = 56 + + /** `value` is read out of Python on every composition, the same as `CHECKBOX`'s `checked` -- + * what makes a fresh scene show what the callback wrote. */ + val TEXT_FIELD = """ + from androidx.compose.material3 import TextField + TextField(_tf_state[0], onValueChange=_tf_on_change) + """.trimIndent() + + /** `java.awt.event.KeyEvent`'s constructor requires a non-null source `Component`; nothing + * about this test displays it or reads any AWT-level property off it beyond `getID()` and + * `getKeyChar()`, so an unattached `Canvas` satisfies the constructor and nothing else. */ + val KEY_EVENT_SOURCE: Canvas = Canvas() + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ColorFactoryTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ColorFactoryTest.kt new file mode 100644 index 000000000..64926ef55 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ColorFactoryTest.kt @@ -0,0 +1,57 @@ +package fixture.compose + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * Compose's packed-ARGB `Color` factories, `Color(color: Long)` and `Color(color: Int)`, called from + * Python under their bound overload spellings. `toArgb` is the judge: `0xFFFF0000` is opaque red, + * `-65536` as a Kotlin `Int`, whichever factory built it. + */ +class ColorFactoryTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + @Test + fun theLongAndIntColorFactoriesAreBoundAndBuildTheSameColor() { + Python3.exec( + """ + import python_multiplatform as _cf_pm + import androidx.compose.ui.graphics as _cf_g + from androidx.compose.ui.graphics import Color__Long, Color__Int, toArgb + + assert [(_p['name'], _p['type']) for _p in _cf_pm.describe(_cf_g, 'Color__Long')['parameters']] == \ + [('color', 'kotlin.Long')], _cf_pm.describe(_cf_g, 'Color__Long') + assert [(_p['name'], _p['type']) for _p in _cf_pm.describe(_cf_g, 'Color__Int')['parameters']] == \ + [('color', 'kotlin.Int')], _cf_pm.describe(_cf_g, 'Color__Int') + + _cf_long = Color__Long(0xFFFF0000) + _cf_int = Color__Int(-65536) + assert type(_cf_long)._kotlin_type_name == 'androidx.compose.ui.graphics.Color', type(_cf_long) + assert toArgb(_cf_long) == -65536, toArgb(_cf_long) + assert toArgb(_cf_int) == -65536, toArgb(_cf_int) + assert toArgb(Color__Long(0xFF00FF00)) == -16711936, 'green through the Long factory' + # Keyword by the Kotlin parameter name. + assert toArgb(Color__Long(color=0x800000FF)) == -2147483393 + """.trimIndent(), + ) + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt new file mode 100644 index 000000000..2650f6007 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt @@ -0,0 +1,930 @@ +package fixture.compose + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Image +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The acceptance criterion, end to end: **`Text('hi')` written in Python draws pixels through + * `androidx.compose.material3.Text`.** + * + * Every layer is the real one. `material3-desktop.jar` is walked by `ArtifactScanner`; the composable + * it finds is bound with its `$composer`, `$changed` and `$default` slots exposed; its call site is a + * `.class` `ComposableThunks` emitted at build time, because a Kotlin file facade has no Kotlin name; + * `pythonx` computes the `$default` mask from which arguments the Python call wrote; the composer + * comes from [PythonComposition], the one hand-written `@Composable`; and `ImageComposeScene` runs a + * real composition and rasterises it. + * + * ### How "it drew" is decided + * + * By counting pixels that are not the background. Two scenes are rendered from the same code path + * -- one whose Python body calls `Text`, one whose body does nothing -- and the assertion is that the + * first has ink and the second has none. That is deliberately not an image comparison against a + * checked-in reference: this test has to survive a Compose version bump changing a font metric, + * while still failing if nothing composes. + * + * A second scene renders `Text('hi')` and `Text('hi hi hi hi')` and asserts the second has strictly + * more ink, which is what stops "any non-empty frame" from passing -- the text that was written has + * to be the text that was drawn. + */ +class ComposableRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + // No `FunctionTable`: this module declares nothing KSP binds (its one public declaration + // is the `@Composable` entry point, which `BindingPolicy` declines), so the walked artefacts + // are the whole table. + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** The claim, in one line of Python. */ + @Test + fun pythonDrawsARealMaterial3Text() { + val drawn = inkOf("from androidx.compose.material3 import Text\nText('hi')") + val blank = inkOf("pass") + + println("compose render: Text('hi') -> $drawn non-background pixels, empty body -> $blank") + assertEquals(0, blank, "an empty composition must draw nothing, or the measurement is not measuring") + assertTrue(drawn > 0, "Text('hi') drew nothing") + } + + /** + * The argument crossed, and not merely *an* argument: more text is more ink. + * + * Without this, a test that only asserted "something was drawn" would pass if the string never + * left Python and Compose rendered a default. + */ + @Test + fun theStringPythonWroteIsTheStringComposeDrew() { + val short = inkOf("from androidx.compose.material3 import Text\nText('hi')") + val long = inkOf("from androidx.compose.material3 import Text\nText('hi hi hi hi hi')") + + println("compose render: 'hi' -> $short px, 'hi hi hi hi hi' -> $long px") + assertTrue(long > short, "expected more ink for more text: $short vs $long") + } + + /** + * That the mask is *right* and not merely present, argued from what a wrong one would do. + * + * `Text` declares 15 or 16 defaulted parameters and `Text('hi')` writes none of them. Slot 16 + * (`style`) is a `TextStyle` and arrives as `null`; the only thing that stops that `null` from + * reaching `Text`'s body is `$default` bit 16 telling the callee to assign + * `LocalTextStyle.current` over it first. So a mask that was shifted, truncated or zero would not + * draw the wrong thing -- it would raise inside Compose. The rendering tests above are therefore + * already the assertion; this one states the shape the table has, so that a regression that + * *stops* binding composables fails here with a readable message rather than as a blank frame. + */ + @Test + fun theWalkedTableCarriesTextWithItsSyntheticSlots() { + // Asserted inside Python rather than marshalled back out, the way + // `WalkedArtifactPythonImportTest` does it: `Python3.exec` raises a Kotlin `PyException` + // carrying the Python message, so a failure arrives intact and the check costs no boundary + // crossing of its own that could be the thing that actually worked. + Python3.exec( + """ + import python_multiplatform.binding as _pm_binding + # By the *base* name, not the fully-qualified one: material3 declares four `Text`s, so + # the walker gave each an overload suffix and `_pm_binding._TABLE` has no bare + # `androidx.compose.material3.Text` key at all. `from ... import Text` still works -- + # that is `_Overloads` dispatching -- which is what the rendering tests above use. + _decls = _pm_binding._BY_PACKAGE['androidx.compose.material3']['Text'] + _decl = [_d for _d in _decls if _d.param_type_names[0] == 'kotlin.String'][0] + assert len(_decls) >= 2, 'expected Text to be an overload set: ' + repr([_d.leaf for _d in _decls]) + assert tuple(_decl.param_names[-4:]) == ('${'$'}composer', '${'$'}changed', '${'$'}changed1', '${'$'}default'), \ + repr(_decl.param_names) + assert _decl.arity == _decl.declared_arity() + 4, (_decl.arity, _decl.declared_arity()) + assert not any(_decl.param_has_default[_decl.declared_arity():]), 'a synthetic slot is never omittable' + assert _decl.param_has_default[0] is False, 'text has no default' + assert sum(1 for _f in _decl.param_has_default if _f) >= 14, _decl.param_has_default + """.trimIndent(), + ) + } + + /** + * **The container.** `Column(content=lambda: Text('hi'))` -- a Python callable filling a Kotlin + * `Function3`. + * + * ### Why this is a different claim from `Text('hi')` + * + * `Text` is a leaf: every one of its sixteen parameters is a value, so it was reachable the + * moment the `$default` mask was. `Column` is not, and not because it is harder -- because + * `content` is the one parameter it does **not** default. Until a Python callable could become a + * `Function3`, `Column` had no reachable call at all, and neither did `Row`, `Box` or `Button`. + * A UI is containers; that is why this and not another leaf. + * + * ### How "the content drew" is decided + * + * The same way [pythonDrawsARealMaterial3Text] decides it, and with the same guard against the + * weaker claim: a `Column` whose content draws is compared against a `Column` whose content is + * `pass`. So "Compose composed a Column" cannot pass this -- an empty `Column` composes exactly + * as much and draws nothing. The text is offset by a padding-free `Column`, so the ink is the + * text's own. + */ + @Test + fun pythonFillsAContainersContentSlotWithALambda() { + val drawn = inkOf( + """ + from androidx.compose.foundation.layout import Column + from androidx.compose.material3 import Text + Column(content=lambda: Text('hi')) + """.trimIndent(), + ) + val empty = inkOf( + """ + from androidx.compose.foundation.layout import Column + Column(content=lambda: None) + """.trimIndent(), + ) + + println("compose render: Column(content=lambda: Text('hi')) -> $drawn px, empty content -> $empty px") + assertEquals(0, empty, "a Column whose content draws nothing must draw nothing") + assertTrue(drawn > 0, "the content lambda never reached Compose") + } + + /** + * The content lambda is a real composition position, not a string that got concatenated + * somewhere: **two** `Text`s inside one `Column` stack, and stacking is more ink than either. + * + * Without this, a `content` that was invoked once with the wrong composer -- or a body that was + * `exec`ed at the outer position and merely happened to draw -- would be indistinguishable from + * one that composed inside the container. + */ + @Test + fun theContentComposesInsideTheContainerAndNotBesideIt() { + val one = inkOf( + """ + from androidx.compose.foundation.layout import Column + from androidx.compose.material3 import Text + Column(content=lambda: Text('hi')) + """.trimIndent(), + ) + val two = inkOf( + """ + from androidx.compose.foundation.layout import Column + from androidx.compose.material3 import Text + + def _body(): + Text('hi') + Text('hi') + + Column(content=_body) + """.trimIndent(), + ) + + println("compose render: one Text in a Column -> $one px, two -> $two px") + assertTrue(two > one, "a second child added no ink: $one vs $two") + } + + /** + * **The scope receiver, from a real `Column`.** `Column(content=lambda col: Text('hi'))`. + * + * ### Why this is a different claim again + * + * [pythonFillsAContainersContentSlotWithALambda] proves a Python callable can *be* a + * `Function3`. It says nothing about the first of the three arguments Compose invokes it with, + * which was dropped: `content` is `@Composable ColumnScope.() -> Unit`, and `ColumnScope` is + * where `Modifier.weight` lives. A container whose scope never reaches Python is a container + * whose children cannot be laid out relative to each other. + * + * ### What is asserted, and why the type name and not merely the value + * + * The ink first, so this is still a rendering claim and not only a marshalling one. Then the + * receiver's **Python type name**, because that is the whole of what makes the value useful: + * `pythonx` keys `_BY_RECEIVER` on the Kotlin type name, so a proxy built under the wrong name + * would carry the right `ColumnScope` and have none of its extensions on it. `ColumnScope` is + * an `androidx` type this test never names on the Kotlin side -- it comes out of the walked + * jar's metadata, through the slot's type name, into a Python class. + */ + @Test + fun aRealColumnHandsItsContentTheColumnScope() { + val drawn = inkOf( + """ + from androidx.compose.foundation.layout import Column + from androidx.compose.material3 import Text + _scopes = [] + + def _body(scope): + _scopes.append(type(scope).__name__) + Text('hi') + + Column(content=_body) + """.trimIndent(), + ) + + println("compose render: Column(content=lambda scope: Text('hi')) -> $drawn px, scope type ${pyStr("_scopes[0]")}") + assertTrue(drawn > 0, "a content that declares its receiver never reached Compose") + assertEquals("1", pyStr("len(_scopes)"), "the content ran without being handed a receiver") + assertEquals( + "ColumnScope", + pyStr("_scopes[0]"), + "the receiver did not arrive as a ColumnScope, so the scope's extensions cannot attach", + ) + } + + /** + * **An argument crossing out of real Compose into Python**, on the one slot a static render + * actually fires: `Text`'s `onTextLayout`, which is `(TextLayoutResult) -> Unit`. + * + * Every other value callback in `material3` -- `Slider.onValueChange`, `Checkbox.onCheckedChange` + * -- is an *event* handler, and a rendered frame delivers no events, so none of them can be shown + * to work by rasterising anything. `onTextLayout` is called by the text layout itself, which + * makes it the only one whose argument can be observed without driving input. + * + * The assertion is on the **object Compose passed**, not on the fact that something was called: + * `TextLayoutResult` is a type this module never names, it reaches Python as a proxy over a + * handle, and its `size` is a real measurement of the string that was drawn. A wrapper that + * dropped its argument would still fire this callback. + */ + @Test + fun aRealTextHandsItsLayoutCallbackTheResult() { + val drawn = inkOf( + """ + from androidx.compose.material3 import Text + _laid_out = [] + Text('hi', onTextLayout=lambda result: _laid_out.append(type(result).__name__)) + """.trimIndent(), + ) + + println("compose render: Text with onTextLayout -> $drawn px, callback saw ${pyStr("_laid_out")}") + assertTrue(drawn > 0, "the Text did not draw, so nothing can be concluded about its callback") + assertEquals("1", pyStr("len(_laid_out)"), "onTextLayout never reached Python") + assertEquals( + "TextLayoutResult", + pyStr("_laid_out[0]"), + "the layout result did not arrive as its declared type", + ) + } + + /** + * **An extension composable, called as a method on the scope that was forwarded to Python.** + * + * `Row(content=lambda row: row.NavigationBarItem(...))` -- `RowScope.NavigationBarItem` is one of + * seven `@Composable` extensions three Compose jars declare, and it was declined outright by + * `ArtifactScanner.composableCandidate` on the grounds that nobody had measured what a receiver + * does to the two numberings. It does different things to them: + * + * | | receiver | + * |---|---| + * | `$changed` | slot 0; value parameter *i* is slot *i* + 1 | + * | `$default` | **not numbered**; bit *i* is value parameter *i*, and the receiver gets bit 31 | + * + * Both read out of `javap -c androidx/compose/material3/NavigationBarKt` and stated on + * `ArtifactScanner.composableCandidate`; only the second matters, because `pythonx` passes + * `$changed` as `0` unconditionally. + * + * ### Why the frame drawing at all is the assertion + * + * The same argument [theWalkedTableCarriesTextWithItsSyntheticSlots] makes about `Text`, and + * sharper here. This call omits six of the nine value parameters, and every one of them arrives + * as a zero or a `null` that only the mask tells the callee to overwrite. Shifting every bit by + * one -- which is exactly what the uncorrected slot-index arithmetic did, since the receiver + * occupies slot 0 and no `$default` bit -- was tried, and the frame does not come out slightly + * wrong: it fails with + * + * Parameter specified as non-null is null: method + * androidx.compose.foundation.selection.SelectableKt.selectable-O2vRcR0, + * parameter ${'$'}this${'$'}selectable + * + * because `modifier` keeps the `null` its bit no longer claims. A mask off by a single bit + * cannot render a slightly wrong picture; it cannot render one at all, which is why this is a + * rendering test rather than a table one. + * + * The second scene is the guard against the weaker claim: a selected `NavigationBarItem` draws + * its own indicator, so "something was drawn" would pass with an icon that never ran. The icon is + * a nested Python callable inside a forwarded receiver, and its text is what the ink is compared + * on. + */ + @Test + fun aScopeForwardedToPythonCanCallTheExtensionComposableDeclaredOnIt() { + val withIcon = inkOf( + """ + from androidx.compose.foundation.layout import Row + from androidx.compose.material3 import Text + Row(content=lambda row: row.NavigationBarItem( + selected=True, onClick=lambda: None, icon=lambda: Text('hi hi hi'))) + """.trimIndent(), + ) + val withoutIcon = inkOf( + """ + from androidx.compose.foundation.layout import Row + Row(content=lambda row: row.NavigationBarItem( + selected=True, onClick=lambda: None, icon=lambda: None)) + """.trimIndent(), + ) + + println("compose render: RowScope.NavigationBarItem -> $withIcon px with an icon, $withoutIcon without") + assertTrue(withoutIcon > 0, "the extension composable did not compose at all") + assertTrue( + withIcon > withoutIcon, + "the icon lambda never ran inside the extension composable: $withoutIcon vs $withIcon", + ) + } + + /** + * **`docs/archive/pythonx-adapter-design.md` §6 item 1, executed:** when Compose drops the slot, the + * Python callable comes back. + * + * `sys.getrefcount` is the measurement and the callable is held in a Python global, so the only + * thing that can move the count is the Kotlin side taking a reference and giving it back. + * `RememberObserver.onForgotten` is the only hook that reports the drop, which is why + * [PythonCallableArena] is a remembered value and why this test closes the scene rather than + * merely letting it go out of scope. + * + * Three assertions, because a leak test alone would pass a double release (`agent-rules` §14): + * the count goes **up** while the composition is alive, comes **back** when it is disposed, and + * the arena reports releasing **exactly one** callable rather than releasing something twice. + */ + @Test + fun aDisposedCompositionGivesEveryPythonCallableBack() { + Python3.exec( + """ + import sys + from androidx.compose.material3 import Text + + def _content(): + Text('hi') + + _base = sys.getrefcount(_content) + """.trimIndent(), + ) + + CountingCallableArena.resetCounters() + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonComposition( + """ + from androidx.compose.foundation.layout import Column + Column(content=_content) + """.trimIndent(), + ) + } + try { + scene.render() + Python3.exec("_held = sys.getrefcount(_content)") + } finally { + scene.close() + } + + Python3.exec("_after = sys.getrefcount(_content)") + val base = pyInt("_base") + val held = pyInt("_held") + val after = pyInt("_after") + + println( + "callable lifetime: refcount base=$base held=$held disposed=$after; " + + "arenas created=${CountingCallableArena.created} forgotten=${CountingCallableArena.forgotten} " + + "released=${CountingCallableArena.released}", + ) + assertEquals(1, CountingCallableArena.created, "the arena was never remembered") + assertTrue(held > base, "nothing held a reference for the composition: $base -> $held") + assertEquals(1, CountingCallableArena.forgotten, "onForgotten never fired, so nothing could release") + // Not "at least one": `close` reports `0` for every call after the first, so a total above + // the number of callables that crossed is the double-release this cannot be allowed to pass. + assertEquals(1, CountingCallableArena.released, "callables released, expected exactly the one that crossed") + assertEquals(base, after, "the composition did not give the Python reference back") + } + + /** + * **The holder outlives a composition pass**, which is the property the whole arrangement rests + * on and the one a single render cannot show. + * + * A second pass is forced by changing the source `PythonComposition` is given, so the Python body + * runs again and a second callable crosses. Two things are then true and they are different + * claims: the arena is **the same one** -- `remember` returned it again, so `created` is still 1 + * after two passes -- and the callable from the *first* pass is still held, because nothing but + * `onForgotten` releases and that has not fired. Both are released at disposal, and both + * reference counts come back. + * + * That the first pass's callable is still held is a **cost** as much as a correctness property, + * and this test is deliberately the case interning cannot help: `_first` and `_second` are two + * different callables, so two wrappers is the right answer and the count is 2 either way. The + * case where it is *one* callable passed again -- which is what a recomposing UI does -- is + * `RecompositionAccumulationTest`, and it measured twelve wrappers for twelve passes before the + * scope started keying them. + * + * What is still unsolved is a hook that reports Compose dropping *one* slot, which is what would + * let a callable that genuinely changed be released between passes rather than at disposal. + * `RememberObserver` is not one -- a `content` is a parameter, not a remembered value. + */ + @Test + fun theHolderSurvivesARecompositionAndReleasesBothCallablesAtDisposal() { + Python3.exec( + """ + import sys + from androidx.compose.material3 import Text + + def _first(): + Text('hi') + + def _second(): + Text('hi hi hi hi hi') + + _base_first = sys.getrefcount(_first) + _base_second = sys.getrefcount(_second) + """.trimIndent(), + ) + + CountingCallableArena.resetCounters() + val body = mutableStateOf(columnCalling("_first")) + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonComposition(body.value) + } + val firstInk: Int + val secondInk: Int + try { + firstInk = inkOfImage(scene.render()) + body.value = columnCalling("_second") + Snapshot.sendApplyNotifications() + secondInk = inkOfImage(scene.render()) + Python3.exec("_held_first = sys.getrefcount(_first)") + assertEquals(1, CountingCallableArena.created, "the arena was rebuilt instead of remembered") + assertEquals(0, CountingCallableArena.released, "something released a callable before disposal") + } finally { + scene.close() + } + Python3.exec("_after_first = sys.getrefcount(_first)\n_after_second = sys.getrefcount(_second)") + + println( + "recomposition: ink $firstInk -> $secondInk; _first refcount " + + "${pyInt("_base_first")} held=${pyInt("_held_first")} disposed=${pyInt("_after_first")}; " + + "released=${CountingCallableArena.released}", + ) + assertTrue(secondInk > firstInk, "the second pass drew the first pass's content: $firstInk vs $secondInk") + assertTrue( + pyInt("_held_first") > pyInt("_base_first"), + "the first pass's callable was not still held during the second", + ) + assertEquals(2, CountingCallableArena.released, "expected both passes' callables to be released once each") + assertEquals(pyInt("_base_first"), pyInt("_after_first"), "the first pass's callable never came back") + assertEquals(pyInt("_base_second"), pyInt("_after_second"), "the second pass's callable never came back") + } + + + /** + * **`Button`.** Both of its non-defaulted slots at once: `onClick` (`Function0`, no receiver) + * and `content` (`Function3@Composable(RowScope)`), the same two shapes `ce1de0c3` and + * `a179b747` proved separately, now on the declaration the design doc singles out as "the + * closest": `ComposableBindingTest` had already confirmed `Button.onClick` is + * `kotlin.Function0` and `Button.content` is `kotlin.Function3@Composable` against the real + * `material3-desktop.jar`, but only as a synthetic-fixture claim. This renders the real + * declaration. + */ + @Test + fun buttonComposesItsClickHandlerAndItsRowScopedContent() { + val drawn = inkOf( + """ + from androidx.compose.material3 import Button, Text + Button(onClick=lambda: None, content=lambda: Text('hi')) + """.trimIndent(), + ) + val empty = inkOf( + """ + from androidx.compose.material3 import Button + Button(onClick=lambda: None, content=lambda: None) + """.trimIndent(), + ) + println("compose render: Button(content=Text('hi')) -> $drawn px, empty content -> $empty px") + assertTrue(empty > 0, "a Button with no content must still draw its own surface") + assertTrue(drawn > empty, "the content lambda never reached the button's surface: $empty vs $drawn") + } + + /** + * **`Card`, and the overload dispatcher choosing correctly.** `material3` declares two `Card`s: + * one taking only `content` (all the rest defaulted) and one also requiring `onClick`. Calling + * `Card(content=...)` alone has to resolve to the *first* -- the second's `onClick` has no + * default, so a caller supplying only `content` cannot satisfy it. This is a claim about + * `pythonx`'s `_Overloads` dispatch as much as about `Card`: picking the wrong member would + * either raise (the clickable overload demanding `onClick`) or -- if the dispatcher instead + * arbitrated rather than tried each candidate -- silently ignore `content`. + */ + @Test + fun cardResolvesToTheNonClickableOverloadAndDrawsItsContent() { + val drawn = inkOf( + """ + from androidx.compose.material3 import Card, Text + Card(content=lambda scope: Text('hi')) + """.trimIndent(), + ) + val empty = inkOf( + """ + from androidx.compose.material3 import Card + Card(content=lambda scope: None) + """.trimIndent(), + ) + println("compose render: Card(content=Text('hi')) -> $drawn px, empty content -> $empty px") + assertEquals(0, empty, "an empty Card must draw nothing, or the measurement is not measuring") + assertTrue(drawn > 0, "the content lambda never reached Card") + } + + /** + * **`MaterialTheme`, and a `content` with no scope at all.** Every container proven so far -- + * `Column`, `Row`, `Box` -- hands its content a receiver (`ColumnScope`, `RowScope`, `BoxScope`). + * `MaterialTheme.content` is `@Composable () -> Unit`: `kotlin.Function2`, no receiver, arity + * zero. Nothing before this exercised that shape, so this is not decoration -- it is the one + * case `functionSlotTypeName`'s `Function2` branch had never been asked to bind through render. + */ + @Test + fun materialThemeComposesAZeroArgumentContentLambda() { + val drawn = inkOf( + """ + from androidx.compose.material3 import MaterialTheme, Text + MaterialTheme(content=lambda: Text('hi')) + """.trimIndent(), + ) + val empty = inkOf( + """ + from androidx.compose.material3 import MaterialTheme + MaterialTheme(content=lambda: None) + """.trimIndent(), + ) + println("compose render: MaterialTheme(content=Text('hi')) -> $drawn px, empty content -> $empty px") + assertEquals(0, empty, "an empty MaterialTheme must draw nothing") + assertTrue(drawn > 0, "the content lambda never reached MaterialTheme") + } + + /** + * **`ListItem`.** `headlineContent` is the one parameter with no default (also `Function2`, + * arity zero) and it is written under its **Kotlin** parameter name, `headlineContent` -- so + * this also pins that a keyword argument reaches a real multi-word parameter by its Kotlin name, + * not just the single-word ones (`content`, `modifier`) every other test here happens to use. + * (It used to be `headline_content`: the binder snake_cased parameters, which it no longer does; + * a Pythonic spelling is pythonx-compose's to define.) + * + * The empty control is not zero, unlike `Column`'s or `Row`'s: `ListItem`, like `Button`, always + * paints its own container surface (`colors.containerColor` at `tonalElevation`) whether or not + * `headlineContent` draws anything, so "more ink with real content" is the same shape of claim + * [buttonComposesItsClickHandlerAndItsRowScopedContent] makes, not the empty-means-zero shape + * `Column`'s does. + */ + @Test + fun listItemComposesItsHeadlineContentUnderItsKotlinName() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import ListItem, Text + ListItem(headlineContent=lambda: Text('hi')) + """.trimIndent(), + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import ListItem + ListItem(headlineContent=lambda: None) + """.trimIndent(), + ) + // Ink *count* cannot tell these apart: `ListItem`'s own container surface already covers + // essentially the whole 200x60 scene (measured: 11200 non-background pixels either way), so + // text drawn on top changes which pixels are non-background, not how many are. The distinct + // *colors* present do differ -- the glyph color is not the container fill color -- which is + // the same argument [lightColorSchemeIsCallableNowThatItsColoursCanBeBuilt] makes for + // `colorScheme`, applied here to whether `headlineContent` painted anything at all. + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: ListItem(headlineContent=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "a ListItem with no headline must still draw its own container surface") + assertTrue( + drawnColors != emptyColors, + "headlineContent added no new color over the bare container: $emptyColors vs $drawnColors", + ) + } + + /** + * **`Badge`, the one declaration here with *no* required parameter at all.** Every other + * container in this file needs at least a `content` or an `onClick`; `Badge`'s every slot -- + * including `content` -- defaults, so `Badge()` alone has to draw the small dot Compose gives a + * badge with nothing in it. That is the leaf claim, on the same footing as `Text('hi')`. The + * second half is the container claim `Badge(content=...)` adds on top: more ink than the bare + * dot, the same "the content is not just present but composed" argument + * [pythonFillsAContainersContentSlotWithALambda] makes for `Column`. + */ + @Test + fun badgeDrawsItsLeafFormWithNoArgumentsAndMoreWithContent() { + val bare = inkOf( + """ + from androidx.compose.material3 import Badge + Badge() + """.trimIndent(), + ) + val withContent = inkOf( + """ + from androidx.compose.material3 import Badge, Text + Badge(content=lambda: Text('hi')) + """.trimIndent(), + ) + println("compose render: Badge() -> $bare px, Badge(content=Text('hi')) -> $withContent px") + assertTrue(bare > 0, "Badge() with every parameter defaulted must still draw its own dot") + assertTrue(withContent > bare, "the content lambda added no ink over the bare badge: $bare vs $withContent") + } + + /** + * **`BadgedBox`, two required `BoxScope` slots on one declaration.** `badge` and `content` both + * default to nothing -- both must be supplied, both are `Function3@Composable(BoxScope)`, and + * both have to compose: the control swaps a real `Badge()` for `None` in the badge slot and + * shows less ink, so "something in the badge slot ran" cannot be satisfied by a slot that was + * silently skipped. + */ + @Test + fun badgedBoxComposesBothItsBadgeAndItsContentScopes() { + val withBadge = inkOf( + """ + from androidx.compose.material3 import BadgedBox, Badge, Text + BadgedBox(badge=lambda scope: Badge(content=lambda: Text('9')), content=lambda scope: Text('hi')) + """.trimIndent(), + ) + val withoutBadge = inkOf( + """ + from androidx.compose.material3 import BadgedBox, Text + BadgedBox(badge=lambda scope: None, content=lambda scope: Text('hi')) + """.trimIndent(), + ) + println("compose render: BadgedBox with a badge -> $withBadge px, without -> $withoutBadge px") + assertTrue(withoutBadge > 0, "the content scope never composed") + assertTrue(withBadge > withoutBadge, "the badge scope never composed: $withoutBadge vs $withBadge") + } + + /** + * **`IconButton`.** `onClick` (`Function0`) and `content` (`Function2`, zero-argument, unlike + * `Button`'s `RowScope`-receiving one) both required, neither defaulted. + */ + @Test + fun iconButtonComposesItsClickHandlerAndItsContent() { + val drawn = inkOf( + """ + from androidx.compose.material3 import IconButton, Text + IconButton(onClick=lambda: None, content=lambda: Text('hi')) + """.trimIndent(), + ) + val empty = inkOf( + """ + from androidx.compose.material3 import IconButton + IconButton(onClick=lambda: None, content=lambda: None) + """.trimIndent(), + ) + println("compose render: IconButton(content=Text('hi')) -> $drawn px, empty content -> $empty px") + assertEquals(0, empty, "an IconButton with empty content must draw nothing") + assertTrue(drawn > 0, "the content lambda never reached IconButton") + } + + /** + * **`Row`, on its own** -- not riding along inside another test's `NavigationBarItem` scene. + * `aScopeForwardedToPythonCanCallTheExtensionComposableDeclaredOnIt` already renders a `Row`, + * but every pixel in it comes from the extension composable in its content; this is the direct + * counterpart to [pythonFillsAContainersContentSlotWithALambda], so `Row` has the same minimal + * proof `Column` does rather than only a proof borrowed from a harder test. + */ + @Test + fun rowFillsItsContentSlotWithALambdaJustAsColumnDoes() { + val drawn = inkOf( + """ + from androidx.compose.foundation.layout import Row + from androidx.compose.material3 import Text + Row(content=lambda row: Text('hi')) + """.trimIndent(), + ) + val empty = inkOf( + """ + from androidx.compose.foundation.layout import Row + Row(content=lambda row: None) + """.trimIndent(), + ) + println("compose render: Row(content=Text('hi')) -> $drawn px, empty content -> $empty px") + assertEquals(0, empty, "a Row whose content draws nothing must draw nothing") + assertTrue(drawn > 0, "the content lambda never reached Row") + } + + /** + * **`Box`, and the overload dispatcher again.** `foundation.layout` declares two `Box`es: a + * content-free one whose only parameter is a *non-defaulted* `Modifier` (`Box__Modifier` -- + * see [ModifierSeededRenderTest] for why that one is unreachable without a seed), and the one + * this test wants, whose `content` is the only non-defaulted slot. Calling `Box(content=...)` + * has to resolve to the second: the first has no `content` parameter to accept the keyword + * argument at all. + */ + @Test + fun boxResolvesToTheContentOverloadAndFillsItsBoxScopedSlot() { + val drawn = inkOf( + """ + from androidx.compose.foundation.layout import Box + from androidx.compose.material3 import Text + Box(content=lambda scope: Text('hi')) + """.trimIndent(), + ) + val empty = inkOf( + """ + from androidx.compose.foundation.layout import Box + Box(content=lambda scope: None) + """.trimIndent(), + ) + println("compose render: Box(content=Text('hi')) -> $drawn px, empty content -> $empty px") + assertEquals(0, empty, "a Box whose content draws nothing must draw nothing") + assertTrue(drawn > 0, "the content lambda never reached Box") + } + + /** + * **`lightColorScheme`, which `a6742a1c` pinned as having no reachable call at all -- and now + * has one.** + * + * That pin had two halves and only one of them was about `lightColorScheme`. The half that + * stands: `ArtifactScanner.MAX_OMITTABLE_PARAMETERS` caps default-omission at 6 defaulted + * parameters per declaration, `applyDefaultOmission` gives up on the *whole* declaration when a + * candidate exceeds it -- not "some of the 48 became omittable", none of them did -- so every one + * of the 48 `Color` arguments must still be written. That is asserted below, unchanged. (36 through + * Compose Multiplatform 1.6's material3; material3 1.9, which CMP 1.11 resolves, added 12 more.) + * + * The half that does not: *"there is nothing this module's included packages can put in a `Color` + * slot"*. That was a statement about `artifactIncludePackages`, not about the boundary, and + * `androidx.compose.ui.graphics` is in the walk now. `Color(255, 0, 0)` builds one -- + * `IconRenderTest.aPythonBuiltColorRoundTripsIntoAnotherWalkedCall` pins the value round trip -- + * and `lightColorScheme`'s slots are `OBJECT`, so the handles go straight in. + * + * ### Why 48 written arguments is a *render* test and not a table one + * + * Writing 48 arguments proves nothing on its own: they could all be dropped. So the scheme is + * built with every colour black except `primary`, handed to `MaterialTheme`, and a `Button` + * inside it paints its container from `colorScheme.primary` -- so the colour that comes out of + * Skia is the colour Python put in that one named slot out of 48. A scheme whose arguments were + * shifted by one, or whose keyword mapping went wrong, would paint a different slot's black. + */ + @Test + fun lightColorSchemeIsCallableNowThatItsColoursCanBeBuilt() { + Python3.exec( + """ + import python_multiplatform.binding as _pm_binding + _decl = _pm_binding._BY_PACKAGE['androidx.compose.material3']['lightColorScheme'][0] + # Every one of the 48 Color parameters declares a default in Kotlin, and none of them is + # omittable here. The cap, unchanged by anything in this commit. + assert _decl.declared_arity() == 48, _decl.declared_arity() + assert all(_decl.param_has_default[i] is False for i in range(_decl.declared_arity())), ( + "lightColorScheme became partly omittable -- ArtifactScanner.MAX_OMITTABLE_PARAMETERS " + "(6) must have grown past 48, or the omission plan changed: " + repr(_decl.param_has_default) + ) + from androidx.compose.material3 import lightColorScheme + try: + lightColorScheme() + raise AssertionError('lightColorScheme() must not be callable with zero arguments') + except TypeError as _e: + assert 'no value for primary' in str(_e), str(_e) + """.trimIndent(), + ) + + val red = distinctColorsOf(themedButton(255, 0, 0)) + val green = distinctColorsOf(themedButton(0, 255, 0)) + println("compose render: MaterialTheme(lightColorScheme(primary=red)) -> ${red.size} colors, green -> ${green.size}") + assertTrue(0xFFFF0000.toInt() in red, "the red primary never reached the Button's container: $red") + assertTrue(0xFF00FF00.toInt() in green, "the green primary never reached the Button's container: $green") + assertTrue(0xFFFF0000.toInt() !in green, "a green scheme painted red, so the argument was not read: $green") + } + + /** + * **`Icon`, which the same commit pinned as unreachable -- two of its three overloads are not.** + * + * The pin was right about the table and wrong about what that measured. Nothing in the walk + * returned an `ImageBitmap`, an `ImageVector` or a `Painter`, and the reason was + * `artifactIncludePackages`: all three are built in packages this module did not walk. Two more + * packages later, `ImageBitmap` and `Painter` both have producers and `IconRenderTest` renders + * pixels through the `Painter` overload. + * + * `ImageVector` still has none, and this is where that fact is checked over the table rather than + * inferred from a failed call -- the shape the original pin got right. + * `IconRenderTest.imageVectorIsUnreachableBecauseItsBuilderIsANestedClassTheWalkerNeverOpens` + * carries the reason. + */ + @Test + fun twoOfIconsThreeImageTypesNowHaveProducersInTheWalkAndImageVectorDoesNot() { + Python3.exec( + """ + import python_multiplatform.binding as _pm_binding + _icon_image_types = { + 'androidx.compose.ui.graphics.ImageBitmap', + 'androidx.compose.ui.graphics.vector.ImageVector', + 'androidx.compose.ui.graphics.painter.Painter', + } + _decls = _pm_binding._BY_PACKAGE['androidx.compose.material3']['Icon'] + _icon_param_types = {_d.param_type_names[0] for _d in _decls} + assert _icon_param_types == _icon_image_types, \ + "Icon's overloads no longer match what this test recorded: " + repr(_icon_param_types) + + # `materialIcon` (material-icons-core, walked since issue #37) returns an ImageVector, + # but only out of a `(ImageVector.Builder) -> ImageVector.Builder` lambda, which Python + # cannot write (`IconRenderTest.imageVectorIsUnreachable...` checks that refusal). So it is + # not a producer a Python caller can use, and it is the only one. + _produced = { + d.return_type_name + for table in _pm_binding._BY_PACKAGE.values() for decls in table.values() for d in decls + if d.return_type_name in _icon_image_types and d.kotlin_name != 'androidx.compose.material.icons.materialIcon' + } + assert _produced == { + 'androidx.compose.ui.graphics.ImageBitmap', + 'androidx.compose.ui.graphics.painter.Painter', + }, repr(_produced) + """.trimIndent(), + ) + } + + /** + * `lightColorScheme` with every slot black except `primary`, inside a `MaterialTheme`, around a + * `Button` whose container reads `colorScheme.primary`. + * + * The keyword names are read off the walked declaration rather than typed out, so the test says + * "all 48, whatever they are called" instead of pinning a Compose version's parameter list. + */ + private fun themedButton(red: Int, green: Int, blue: Int): String = + """ + import python_multiplatform.binding as _pm_binding + from androidx.compose.ui.graphics import Color__Int_Int_Int_Int as _Color + from androidx.compose.material3 import lightColorScheme, MaterialTheme, Button + + _decl = _pm_binding._BY_PACKAGE['androidx.compose.material3']['lightColorScheme'][0] + _slots = list(_decl.param_names) + _black = _Color(0, 0, 0) + _written = dict((_slot, _black) for _slot in _slots) + _written['primary'] = _Color($red, $green, $blue) + _scheme = lightColorScheme(**_written) + + MaterialTheme( + colorScheme=_scheme, + content=lambda: Button(onClick=lambda: None, content=lambda: None), + ) + """.trimIndent() + + private fun distinctColorsOf(body: String): Set = + pixelsOf(body).filter { it != BACKGROUND }.toSet() + + private fun columnCalling(name: String): String = + "from androidx.compose.foundation.layout import Column\nColumn(content=$name)" + + private fun inkOfImage(image: Image): Int { + val bitmap = Bitmap.makeFromImage(image) + var ink = 0 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (bitmap.getColor(x, y) != BACKGROUND) ink++ + } + } + return ink + } + + /** One Python `int` global, read into Kotlin. `PythonTestFixture` is `commonTest` of another + * module, so the two C API calls it wraps are repeated here rather than depended on. */ + private fun pyInt(name: String): Int = pyStr(name).toInt() + + /** [pyInt] without the conversion, for an expression whose answer is not a number. */ + private fun pyStr(expression: String): String = Python3.import("__main__").getAttr("__dict__").let { globals -> + Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString() + } + + /** + * How many pixels of a 200x60 scene differ from the background after running [body] inside a + * composition. + */ + private fun inkOf(body: String): Int = pixelsOf(body).count { it != BACKGROUND } + + private fun pixelsOf(body: String): IntArray { + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonComposition(body) + } + try { + val image: Image = scene.render() + val bitmap = Bitmap.makeFromImage(image) + val pixels = IntArray(bitmap.width * bitmap.height) + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + pixels[y * bitmap.width + x] = bitmap.getColor(x, y) + } + } + return pixels + } finally { + scene.close() + } + } + + private companion object { + /** `ImageComposeScene` clears to transparent black. */ + const val BACKGROUND = 0 + + /** CPython's `Py_eval_input`. */ + const val PY_EVAL_INPUT = 258 + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposedRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposedRenderTest.kt new file mode 100644 index 000000000..66a925678 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposedRenderTest.kt @@ -0,0 +1,184 @@ +package fixture.compose + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Density +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **`Modifier.composed` admits a real composer**, settling `docs/archive/pythonx-adapter-design.md` §9.2/§9.4's + * open question: "unverified whether `composed`'s own contract... admits a composer at all." + * `fixture.compose.pythonComposed` (`PythonComposed.kt`) is the hand-written wrapper; this proves two + * independent things about it, the way every other §9 render test in this module proves both a + * positive claim and a control rather than one alone. + * + * 1. [aFactoryProducedModifierIsAppliedAndItsSizeCrosses] -- the `Modifier` [factory] returns actually + * reaches the node Compose measures, and the *value* Python chose crosses (not a stub that always + * applies the same fixed size, and not a no-op that leaves the node at its intrinsic size). + * 2. [theSameCompositionReusesTheRememberedSlotAcrossRecompositions] -- the composer `factory` runs + * with is wired into a real, positionally-stable slot table, not a placeholder: `remember` inside + * `pythonComposed`'s `composed { }` lambda allocates once and is *reused* across recompositions of + * the same node, even though `PythonComposition` re-`exec`s the Python source (and so calls + * `pythonComposed` itself afresh, building a brand new `ComposedModifier` Kotlin object) on every + * pass. That persistence is `Modifier.composed`'s whole reason to exist (it is what lets + * `Modifier.clickable`'s internal `remember(interactionSource) { ... }` survive a modifier chain + * rebuilt from scratch every recomposition) and a fake/inert composer could not produce it. + */ +class ComposedRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + ComposedInvocationSlot.reset() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * Positive: `factory` resizes the box to 70dp, and a `Layout` wrapping [PythonComposition] (the + * same read-back [LayoutIdRenderTest] uses) measures a 70px-wide placeable at density 1. + * + * Control: `factory` returns its receiver unchanged (no resize at all) -- the placeable then + * measures at `Text("x")`'s own intrinsic width, which this asserts is neither 70 nor the other + * positive case's 33, ruling out a `pythonComposed` that ignores [factory]'s result and a + * `factory` result that is applied regardless of what it actually says. + */ + @Test + fun aFactoryProducedModifierIsAppliedAndItsSizeCrosses() { + val resized70 = measuredWidth(sizingBody(70.0)) + val resized33 = measuredWidth(sizingBody(33.0)) + val identity = measuredWidth(IDENTITY_BODY) + + println( + "compose composed: factory size 70 -> ${resized70}px, size 33 -> ${resized33}px, " + + "identity factory -> ${identity}px", + ) + + assertEquals(70, resized70, "the 70dp factory result was not applied to the measured node") + assertEquals(33, resized33, "the 33dp factory result was not applied to the measured node") + assertTrue( + identity != 70 && identity != 33, + "an identity factory (no resize) measured the same as a real resize -- " + + "the factory's return value is not what is being measured: ${identity}px", + ) + } + + /** + * Forces [PASSES] recompositions of one composition (the same `mutableStateOf` + textual-comment + * lever `RecompositionAccumulationTest.measure` uses, since `PythonComposition(source: String)` + * only recomposes when its `String` argument actually changes), and checks two counters + * [ComposedInvocationSlot] keeps: + * + * - [ComposedInvocationSlot.remembersAllocated] -- how many times `remember`'s factory itself ran. + * `1`, not [PASSES], is the claim: the slot table remembered across every later pass. + * - the surviving slot's own [ComposedInvocationSlot.invocations] -- how many times the + * `composed { }` lambda body ran at all. Equal to [PASSES]: `pythonComposed` (and so `factory`) + * really did get re-invoked, and re-materialized, on every recomposition rather than being + * skipped or cached wholesale. + * + * Both together are what a placeholder composer could not produce: either `remember` would not be + * usable at all (no slot table to remember into), or it would allocate fresh every pass (no + * position to remember *at*). + */ + @Test + fun theSameCompositionReusesTheRememberedSlotAcrossRecompositions() { + val body = mutableStateOf(sizingBody(70.0, pass = 0)) + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(body.value) + } + try { + scene.render() + for (pass in 1 until PASSES) { + body.value = sizingBody(70.0, pass = pass) + Snapshot.sendApplyNotifications() + scene.render() + } + val slot = ComposedInvocationSlot.lastCreated + ?: error("pythonComposed's composed{} lambda never ran") + println( + "compose composed: $PASSES recompositions -> " + + "remembersAllocated=${ComposedInvocationSlot.remembersAllocated} " + + "invocations=${slot.invocations}", + ) + assertEquals( + 1, ComposedInvocationSlot.remembersAllocated, + "remember inside composed{} allocated more than once across recompositions of the " + + "same node -- the slot table is not being reused, so the composer is not real", + ) + assertEquals( + PASSES, slot.invocations, + "the composed{} lambda did not run once per recomposition: ${slot.invocations} " + + "of $PASSES", + ) + } finally { + scene.close() + } + } + + private fun measuredWidth(body: String): Int { + var width = -1 + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + Layout(content = { PythonComposition(body) }) { measurables, constraints -> + val placeable = measurables.first().measure(constraints) + width = placeable.width + layout(SCENE, SCENE) { placeable.placeRelative(0, 0) } + } + } + try { + scene.render() + return width + } finally { + scene.close() + } + } + + private fun sizingBody(dp: Double, pass: Int = 0) = """ + # pass $pass + from fixture.compose import emptyModifier, pythonComposed + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.material3 import Text + + def _factory(receiver): + return size__Dp(receiver, $dp) + + _m = pythonComposed(emptyModifier(), _factory) + Text("x", modifier=_m) + """.trimIndent() + + private companion object { + const val SCENE = 200 + const val PASSES = 6 + + val IDENTITY_BODY = """ + from fixture.compose import emptyModifier, pythonComposed + from androidx.compose.material3 import Text + + def _factory(receiver): + return receiver + + _m = pythonComposed(emptyModifier(), _factory) + Text("x", modifier=_m) + """.trimIndent() + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ContextMenuOpenDetectorRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ContextMenuOpenDetectorRenderTest.kt new file mode 100644 index 000000000..e9c920e6e --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ContextMenuOpenDetectorRenderTest.kt @@ -0,0 +1,123 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerButtons +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.unit.Density +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +class ContextMenuOpenDetectorRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + @Test + fun rightClickTriggersContextMenuOpenDetector() { + Python3.exec( + """ + _events = [] + def _on_pointer_down(x, y): + _events.append((x, y)) + """.trimIndent(), + ) + + val scene = ImageComposeScene(width = 80, height = 80, density = Density(1f)) { + PythonComposition( + """ + from fixture.compose import emptyModifier, pythonContextMenuOpenDetector + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.material3 import Text + + _m = pythonContextMenuOpenDetector(size__Dp(emptyModifier(), 48.0), True, _on_pointer_down) + Text("Box", modifier=_m) + """.trimIndent() + ) + } + try { + scene.render() + val at = Offset(24f, 24f) + scene.sendPointerEvent(PointerEventType.Move, at) + scene.sendPointerEvent( + PointerEventType.Press, + at, + buttons = PointerButtons(isSecondaryPressed = true) + ) + scene.sendPointerEvent( + PointerEventType.Release, + at, + buttons = PointerButtons(isSecondaryPressed = true) + ) + scene.render() + } finally { + scene.close() + } + + Python3.exec( + """ + assert len(_events) == 1, 'on_pointer_down fired ' + str(len(_events)) + ' times' + sx, sy = _events[0] + assert sx == 24.0 and sy == 24.0, 'on_pointer_down fired with wrong coordinates' + """.trimIndent(), + ) + } + + @Test + fun leftClickDoesNotTriggerContextMenuOpenDetector() { + Python3.exec( + """ + _events = [] + def _on_pointer_down(x, y): + _events.append((x, y)) + """.trimIndent(), + ) + + val scene = ImageComposeScene(width = 80, height = 80, density = Density(1f)) { + PythonComposition( + """ + from fixture.compose import emptyModifier, pythonContextMenuOpenDetector + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.material3 import Text + + _m = pythonContextMenuOpenDetector(size__Dp(emptyModifier(), 48.0), True, _on_pointer_down) + Text("Box", modifier=_m) + """.trimIndent() + ) + } + try { + scene.render() + val at = Offset(24f, 24f) + scene.sendPointerEvent(PointerEventType.Move, at) + scene.sendPointerEvent(PointerEventType.Press, at) // Left click + scene.sendPointerEvent(PointerEventType.Release, at) + scene.render() + } finally { + scene.close() + } + + Python3.exec( + "assert len(_events) == 0, 'left click should not trigger contextMenuOpenDetector'", + ) + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DragAndDropSourceRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DragAndDropSourceRenderTest.kt new file mode 100644 index 000000000..a5bf6a778 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DragAndDropSourceRenderTest.kt @@ -0,0 +1,228 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.draganddrop.DragAndDropTransferData +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.platform.PlatformContext +import androidx.compose.ui.platform.PlatformDragAndDropManager +import androidx.compose.ui.platform.PlatformDragAndDropSource +import androidx.compose.ui.scene.CanvasLayersComposeScene +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerButtons +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Surface +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * **A real pointer drag reaches a Python callback through `Modifier.dragAndDropSource`.** + * + * Through Compose 1.6 this was a tap, forwarded raw by the modifier's suspend `PointerInputScope` + * slot exactly as `PointerInputRenderTest` does. Compose 1.11 removed that slot; the only callback + * left is `transferData(Offset)`, which Compose's own start detector invokes when a primary-button + * mouse drag begins. `PythonDragAndDropSource.kt`'s KDoc has the details. A tap no longer reaches + * Python by construction, so both cases drag. + */ +class DragAndDropSourceRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The positive claim: a primary-button drag starting inside the modifier's own 48x48 box invokes + * the Python callback with a real ("start", x, y) triple, and the digit `Text` draws changes once + * it has. + */ + @Test + fun aRealPointerDragReachesThePythonCallbackThroughDragAndDropSource() { + Python3.exec( + """ + _dnd_events = [] + + def _on_event(kind, x, y): + _dnd_events.append((kind, x, y)) + """.trimIndent(), + ) + + val before = pixelsOf(BODY) + assertTrue(inkOf(before) > 0, "the Text never composed, so there was nothing to drag") + + dragFrom(Offset(START_X, BOX_CENTRE)) + + Python3.exec( + "assert len(_dnd_events) >= 1, 'pythonDragAndDropSource never invoked the Python callback'", + ) + Python3.exec( + """ + kinds = [e[0] for e in _dnd_events] + assert 'start' in kinds, 'no drag start reached Python: ' + repr(_dnd_events) + """.trimIndent(), + ) + Python3.exec( + """ + for kind, x, y in _dnd_events: + assert 0.0 <= x <= 48.0 and 0.0 <= y <= 48.0, ( + 'event %s at (%r, %r) is outside the 48x48 target box' % (kind, x, y) + ) + """.trimIndent(), + ) + + val after = pixelsOf(BODY) + val moved = differing(before, after) + println( + "compose input: dragAndDropSource drag -> ink ${inkOf(before)} -> ${inkOf(after)} px, " + + "$moved pixels changed", + ) + assertTrue( + moved > 0, + "no pixel changed after the drag, so the count the callback wrote never reached the render", + ) + } + + /** + * The negative control: the same drag, started well outside the 48x48 box, invokes nothing. + */ + @Test + fun aDragThatStartsOutsideTheBoxInvokesNothing() { + Python3.exec( + """ + _dnd_events = [] + + def _on_event(kind, x, y): + _dnd_events.append((kind, x, y)) + """.trimIndent(), + ) + dragFrom(Offset(START_X, SCENE - 2f)) + Python3.exec( + "assert _dnd_events == [], 'a drag outside the box invoked the callback: ' + repr(_dnd_events)", + ) + } + + /** + * Drives a real pointer drag through a scene whose platform *requests* drag-and-drop transfers. + * + * `ImageComposeScene` cannot be used here: its platform context carries Compose's default + * `PlatformDragAndDropManager`, whose `isRequestDragAndDropTransferRequired()` is `false`, so + * `DragAndDropSourceNode.onAttach` never installs its start detector and `transferData` is never + * asked -- the platform is expected to start the drag itself. Only the desktop window's + * `AwtDragAndDropManager` answers `true`. This builds the same scene `ImageComposeScene` builds + * (`CanvasLayersComposeScene`), with a manager that answers `true` and, on a request, hands + * `PlatformDragAndDropSource` a scope that records the transfer instead of starting an OS drag. + * Compose's own start detector, and the fixture's `transferData` lambda, run unmodified. + */ + @OptIn(InternalComposeUiApi::class) + private fun dragFrom(start: Offset) { + val manager = object : PlatformDragAndDropManager { + override val isRequestDragAndDropTransferRequired: Boolean get() = true + + override fun requestDragAndDropTransfer(source: PlatformDragAndDropSource, offset: Offset) { + source.apply { + val scope = object : PlatformDragAndDropSource.StartTransferScope { + override fun startDragAndDropTransfer( + transferData: DragAndDropTransferData, + decorationSize: Size, + drawDragDecoration: DrawScope.() -> Unit, + ): Boolean = false + } + scope.startDragAndDropTransfer(offset) { false } + } + } + } + val platformContext = object : PlatformContext by PlatformContext.Empty() { + override val dragAndDropManager: PlatformDragAndDropManager get() = manager + } + val scene = CanvasLayersComposeScene( + density = Density(1f), + size = IntSize(SCENE, SCENE), + platformContext = platformContext, + ) + val surface = Surface.makeRasterN32Premul(SCENE, SCENE) + try { + scene.setContent { PythonComposition(BODY) } + var nanos = 0L + fun frame() { scene.render(surface.canvas.asComposeCanvas(), nanos); nanos += FRAME_NANOS } + // Let the modifier's pointer-input coroutines start and reach their first awaitPointerEvent + // before the press. + repeat(SETTLE_FRAMES) { frame() } + val held = PointerButtons(isPrimaryPressed = true) + scene.sendPointerEvent(PointerEventType.Move, start) + scene.sendPointerEvent(PointerEventType.Press, start, buttons = held, button = PointerButton.Primary) + // Several move steps, well past the touch slop, so the start detector sees a drag. + var x = start.x + while (x < start.x + DRAG_DISTANCE) { + x += STEP + scene.sendPointerEvent(PointerEventType.Move, Offset(x, start.y), buttons = held) + frame() + } + scene.sendPointerEvent(PointerEventType.Release, Offset(x, start.y), button = PointerButton.Primary) + frame() + } finally { + scene.close() + surface.close() + } + } + + private fun pixelsOf(body: String): IntArray { + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return IntArray(SCENE * SCENE) { bitmap.getColor(it % SCENE, it / SCENE) } + } finally { + scene.close() + } + } + + private fun inkOf(pixels: IntArray): Int = pixels.count { it != BACKGROUND } + + private fun differing(a: IntArray, b: IntArray): Int = a.indices.count { a[it] != b[it] } + + private companion object { + const val BACKGROUND = 0 + const val SCENE = 80 + const val BOX_CENTRE = 24f + const val START_X = 4f + const val DRAG_DISTANCE = 40f + const val STEP = 8f + const val SETTLE_FRAMES = 5 + const val FRAME_NANOS = 16_000_000L + + val BODY = """ + from fixture.compose import emptyModifier, pythonDragAndDropSource + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.material3 import Text + + _m = pythonDragAndDropSource(size__Dp(emptyModifier(), 48.0), _on_event) + Text(str(len(_dnd_events)), modifier=_m) + """.trimIndent() + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableLeakTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableLeakTest.kt new file mode 100644 index 000000000..38ff5cda1 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableLeakTest.kt @@ -0,0 +1,120 @@ +package fixture.compose + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class DraggableLeakTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * **The leak `PythonDraggable`'s own documentation admits, measured instead of estimated.** + * + * Twelve recomposition passes hold twelve references to each of the three callables -- exactly + * one per pass, linear and unbounded, released only when the composition is disposed. The + * numbers are asserted as they are rather than as they should be: this test pins the current + * behaviour, so when the lifetime is fixed it fails and has to be updated deliberately. + * + * **Interning does not reach this case, and the reason is structural.** A wrapper is interned by + * a key Python computes, and the substitution that computes it happens for slots the artefact + * scanner rewrites. `pythonDraggable` is hand-written and takes its callables as raw `PyObject`s, + * so Python passes them straight through with no key -- there is nothing for the scope to intern + * by. Registering them keylessly would not help either: they would accumulate exactly as + * measured here, which is the shape `RecompositionAccumulationTest` already pins for the general + * case. + * + * Closing it needs the release sweep that the interning work deliberately deferred -- give back + * every wrapper a pass neither built nor re-used -- and that sweep is still waiting on the one + * measurement it needs: a slot that ignores re-supply and holds, the shape a keyed effect has, + * where a sweep would hand back something still in use. Releasing at the end of a gesture was + * considered and rejected earlier for the same class of reason: the second gesture would use it + * after close, which is worse than leaking. + */ + @Test + fun aRecomposedDraggableHoldsOneReferencePerPassPerCallback() { + Python3.exec( + """ + import sys + from androidx.compose.material3 import Text + + def _acc_on_delta(delta): + pass + def _acc_on_drag_started(x, y): + pass + def _acc_on_drag_stopped(velocity): + pass + + _acc_base_delta = sys.getrefcount(_acc_on_delta) + _acc_base_started = sys.getrefcount(_acc_on_drag_started) + _acc_base_stopped = sys.getrefcount(_acc_on_drag_stopped) + """.trimIndent(), + ) + val passes = 12 + val body = mutableStateOf(0) + val scene = ImageComposeScene(width = 80, height = 80, density = Density(1f)) { + PythonComposition( + """ + from fixture.compose import emptyModifier, pythonDraggable + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.material3 import Text + + _m = pythonDraggable(size__Dp(emptyModifier(), 48.0), _acc_on_delta, _acc_on_drag_started, _acc_on_drag_stopped) + Text(str(${body.value}), modifier=_m) + """.trimIndent() + ) + } + + try { + scene.render() + for (pass in 1 until passes) { + body.value = pass + Snapshot.sendApplyNotifications() + scene.render() + } + } finally { + scene.close() + } + + fun pyInt(expr: String): Int = Python3.import("__main__").getAttr("__dict__").let { globals -> + Python3.eval(expr, 258, globals, globals).toString().toInt() + } + + val refsDelta = pyInt("sys.getrefcount(_acc_on_delta)") - pyInt("_acc_base_delta") + val refsStarted = pyInt("sys.getrefcount(_acc_on_drag_started)") - pyInt("_acc_base_started") + val refsStopped = pyInt("sys.getrefcount(_acc_on_drag_stopped)") - pyInt("_acc_base_stopped") + + println("Draggable leak test: refsDelta=$refsDelta, refsStarted=$refsStarted, refsStopped=$refsStopped after $passes passes") + + // Pinned as measured, not as wanted. If these start coming back lower, the lifetime was + // fixed -- see this test's doc for what that would take -- and the numbers here move with it. + assertEquals(passes, refsDelta, "onDelta should hold one reference per pass until this is fixed") + assertEquals(passes, refsStarted, "onDragStarted should hold one reference per pass until this is fixed") + assertEquals(passes, refsStopped, "onDragStopped should hold one reference per pass until this is fixed") + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableRenderTest.kt new file mode 100644 index 000000000..8a75c834f --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/DraggableRenderTest.kt @@ -0,0 +1,227 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **A real drag gesture reaches Python through `Modifier.draggable`'s suspend slots** -- one of the + * three `docs/archive/pythonx-adapter-design.md` §9.2 judges "the same suspend-lambda shape as `pointerInput`" + * without attempting. `fixture.compose.pythonDraggable` (`PythonDraggable.kt`) is the same technique + * §9.1 already proved for `pointerInput`, applied to `draggable`'s three callback slots: a real + * `DraggableState` and two real `suspend CoroutineScope.(...) -> Unit` lambdas, hand-written and + * ordinarily compiled, each calling a plain non-suspending Python callback synchronously. + * + * `draggable`'s three slots are not one suspend shape but two: `onDelta` (the [DraggableState] + * factory's parameter) is a plain, non-suspend `(Float) -> Unit` called many times per gesture, while + * `onDragStarted`/`onDragStopped` are the `suspend CoroutineScope.(...) -> Unit` shape `pointerInput`'s + * technique targets, called once each per gesture. All three are exercised here. + */ +class DraggableRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The positive claim: a real horizontal drag across the modifier's own box invokes + * `onDragStarted` with a real position, `on_delta` one or more times with real (non-zero, not + * stub) values that sum to the direction actually dragged, and `onDragStopped` exactly once -- + * and the accumulated total Python held shows up as a different digit in a fresh scene. + */ + @Test + fun aRealDragReachesThePythonCallbacksThroughDraggable() { + Python3.exec( + """ + _drag_total = [0.0] + _delta_events = [] + _started_events = [] + _stopped_events = [] + + def _on_delta(delta): + _delta_events.append(delta) + _drag_total[0] += delta + + def _on_drag_started(x, y): + _started_events.append((x, y)) + + def _on_drag_stopped(velocity): + _stopped_events.append(velocity) + """.trimIndent(), + ) + + val before = pixelsOf(BODY) + assertTrue(inkOf(before) > 0, "the Text never composed, so there was nothing to drag") + + dragAcrossBox() + + Python3.exec( + "assert len(_started_events) == 1, 'onDragStarted fired ' + str(len(_started_events)) + ' times'", + ) + // The started position must be a real coordinate inside the 48x48 box the drag began in, not + // a stub value like (0.0, 0.0). + Python3.exec( + """ + sx, sy = _started_events[0] + assert 0.0 <= sx <= 48.0 and 0.0 <= sy <= 48.0, ( + 'onDragStarted fired with (%r, %r), outside the 48x48 box' % (sx, sy) + ) + """.trimIndent(), + ) + Python3.exec( + "assert len(_delta_events) >= 1, 'on_delta was never invoked'", + ) + // A drag that moved strictly rightward must accumulate a strictly positive total; a stub that + // always reported 0.0 would leave this at zero. + Python3.exec( + "assert _drag_total[0] > 0.0, 'accumulated delta was not positive: ' + repr(_drag_total[0])", + ) + Python3.exec( + "assert len(_stopped_events) == 1, 'onDragStopped fired ' + str(len(_stopped_events)) + ' times'", + ) + Python3.exec( + "assert isinstance(_stopped_events[0], float), 'onDragStopped got a non-float: ' + repr(_stopped_events[0])", + ) + + val after = pixelsOf(BODY) + val moved = differing(before, after) + println( + "compose input: draggable drag -> ink ${inkOf(before)} -> ${inkOf(after)} px, " + + "$moved pixels changed", + ) + assertTrue( + moved > 0, + "no pixel changed after the drag, so the total the callback wrote never reached the render", + ) + } + + /** + * The negative control: a press and release **outside** the draggable's own box, with no + * movement inside it at all, invokes none of the three callbacks -- the same "off target invokes + * nothing" shape `PointerInputRenderTest` uses for `pointerInput`. + */ + @Test + fun aPressOutsideTheBoxInvokesNothing() { + Python3.exec( + """ + _drag_total = [0.0] + _delta_events = [] + _started_events = [] + _stopped_events = [] + + def _on_delta(delta): + _delta_events.append(delta) + _drag_total[0] += delta + + def _on_drag_started(x, y): + _started_events.append((x, y)) + + def _on_drag_stopped(velocity): + _stopped_events.append(velocity) + """.trimIndent(), + ) + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(BODY) + } + try { + scene.render() + val at = Offset(SCENE - 2f, SCENE - 2f) + scene.sendPointerEvent(PointerEventType.Move, at) + scene.sendPointerEvent(PointerEventType.Press, at) + scene.sendPointerEvent(PointerEventType.Move, Offset(SCENE - 20f, SCENE - 2f)) + scene.sendPointerEvent(PointerEventType.Release, Offset(SCENE - 20f, SCENE - 2f)) + scene.render() + } finally { + scene.close() + } + Python3.exec( + "assert _started_events == [] and _delta_events == [] and _stopped_events == [], " + + "'a press outside the box invoked draggable: ' + repr((_started_events, _delta_events, _stopped_events))", + ) + } + + private fun dragAcrossBox() { + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(BODY) + } + try { + scene.render() + scene.sendPointerEvent(PointerEventType.Move, Offset(START_X, BOX_CENTRE)) + scene.sendPointerEvent(PointerEventType.Press, Offset(START_X, BOX_CENTRE)) + // Several move steps, well past the default touch slop, so the gesture is recognised as + // a drag rather than swallowed as a click. + var x = START_X + while (x < START_X + DRAG_DISTANCE) { + x += STEP + scene.sendPointerEvent(PointerEventType.Move, Offset(x, BOX_CENTRE)) + } + scene.sendPointerEvent(PointerEventType.Release, Offset(x, BOX_CENTRE)) + scene.render() + } finally { + scene.close() + } + } + + private fun pixelsOf(body: String): IntArray { + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return IntArray(SCENE * SCENE) { bitmap.getColor(it % SCENE, it / SCENE) } + } finally { + scene.close() + } + } + + private fun inkOf(pixels: IntArray): Int = pixels.count { it != BACKGROUND } + + private fun differing(a: IntArray, b: IntArray): Int = a.indices.count { a[it] != b[it] } + + private companion object { + const val BACKGROUND = 0 + const val SCENE = 80 + + /** `size__Dp(..., 48.0)` gives the drag target a fixed 48x48 box; its vertical centre is 24. */ + const val BOX_CENTRE = 24f + const val START_X = 4f + const val DRAG_DISTANCE = 40f + const val STEP = 8f + + /** `Text` reads `_drag_total`'s (truncated) value on every composition, which is what makes a + * fresh scene show what the callback wrote -- the same shape `PointerInputRenderTest` uses. */ + val BODY = """ + from fixture.compose import emptyModifier, pythonDraggable + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.material3 import Text + + _m = pythonDraggable(size__Dp(emptyModifier(), 48.0), _on_delta, _on_drag_started, _on_drag_stopped) + Text(str(int(_drag_total[0])), modifier=_m) + """.trimIndent() + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt new file mode 100644 index 000000000..ea984fb04 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt @@ -0,0 +1,682 @@ +package fixture.compose + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Image +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **`Icon`, drawing pixels from Python** -- the declaration `a6742a1c` pinned as unreachable, and + * the layer that pin was really about. + * + * ### What the old pin actually measured + * + * `a6742a1c`'s `iconHasNoReachableSourceForAnyOfItsThreeRequiredImageTypes` asserted, over + * the walked table rather than over a guess, that nothing in it returns an `ImageBitmap`, an + * `ImageVector` or a `Painter`. That was true, and it was true for a reason that had nothing to do + * with `Icon`: `artifactIncludePackages` named two packages and neither was where any of the three + * is built. The pin measured this module's `build.gradle.kts`. + * + * Two more packages -- `androidx.compose.ui.graphics` and `androidx.compose.ui.res` -- take the walk + * from 230 bindings to 320, and with them two of `Icon`'s three overloads become reachable. The + * third does not, and *why* is the useful half; see + * [imageVectorIsUnreachableBecauseItsBuilderIsANestedClassTheWalkerNeverOpens]. + * + * ### Why a loaded image and not a constructed one + * + * `ImageBitmap(width, height)` is bound and Python can call it, and it is **useless for an ink + * proof**: the pixels it allocates are zero. `Icon` draws its image through `ColorFilter.tint`, + * which is `BlendMode.SrcIn` -- it replaces colour and *keeps alpha* -- so a zeroed bitmap draws + * nothing however correctly every layer below worked. Measured, both ways: `hasAlpha = false` (which + * tags the surface `OPAQUE`) draws `0` too. + * + * Writing pixels into one needs `Canvas.drawRect`, and that is an **instance** method. + * `ArtifactScanner.kotlinCandidates` filters `ownerNode.methods` on `ACC_PUBLIC and ACC_STATIC`, so + * no instance method of any class is bound anywhere in this table -- the walker's whole surface is + * top-level functions and extensions. Loading an image is static all the way down, which is why + * `androidx.compose.ui.res` is in the walk and why the ink proof below goes through + * `painterResource` rather than through the constructor. + * + * The `ImageBitmap` overload still gets a proof of its own, and it is positional rather than + * inked -- exactly the shape `ModifierSeededRenderTest` uses for `Spacer`, and for the same reason. + * See [pythonBuildsAnImageBitmapAndComposeLaysTheIconOutAtThatWidth]. + */ +class IconRenderTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The control, and a Kotlin one on purpose: the *same* resource through the *same* two + * declarations, with no boundary anywhere in it. Two different things produce a `0` from the + * Python test below -- the crossing failed, or the image was invisible -- and this is what tells + * them apart. + */ + @Test + fun theSameResourceDrawsThroughKotlinWithNoBoundaryInvolved() { + val ink = inkOfScene { Icon(painterResource(RESOURCE), contentDescription = "a square") } + println("compose render: Kotlin painterResource('$RESOURCE') through Icon -> $ink px") + assertEquals(SQUARE, ink, "the control image is not the 24x24 opaque square this file assumes") + } + + /** + * **The claim.** `Icon(painterResource('opaque-square.png'), ...)` written in Python, drawing + * pixels through `androidx.compose.material3.Icon`. + * + * Four things had to be true at once and none of them was: + * + * 1. the walk had to reach `androidx.compose.ui.res`, which is `build.gradle.kts`; + * 2. `painterResource` is itself a `@Composable`, so its `$composer` slot has to be filled from + * the same `_COMPOSER` stack the composable being *rendered* uses -- a walked composable + * called for its **value** rather than for its emission, which nothing had done before; + * 3. that value has to cross back as an owned `TypeTag.OBJECT` handle, and then cross **forward + * again** into another walked composable's slot in the same Python statement. That round trip + * is what `fbab1a68`/`ce1de0c3`/`a179b747` opened and what nothing had yet driven with a + * walked artefact on both ends; + * 4. `painterResource`'s declared return type is exactly `androidx.compose.ui.graphics.painter + * .Painter`, which is what let `_coerce` match it to `Icon`'s `Painter` slot back when only + * the exact name could match -- see + * [aBitmapPainterIsAcceptedByIconsPainterSlotNowThatTheTableCarriesItsAncestry] for the rule + * that no longer needs it to be exact. + * + * The assertion is the exact pixel count and not `> 0`: 24x24 opaque, the same number the Kotlin + * control produces. A painter that failed to attach and left an empty box, or one stretched over + * the whole 200x60 scene, both fail this and neither would fail an "it drew something" test. + */ + @Test + fun pythonLoadsAPainterAndIconDrawsIt() { + val drawn = inkOf( + """ + from androidx.compose.ui.res import painterResource + from androidx.compose.material3 import Icon + Icon(painterResource('$RESOURCE'), contentDescription='a square') + """.trimIndent(), + ) + val blank = inkOf("pass") + println("compose render: Icon(painterResource('$RESOURCE')) -> $drawn px, empty body -> $blank px") + assertEquals(0, blank, "an empty composition must draw nothing, or the measurement is not measuring") + assertEquals(SQUARE, drawn, "Icon did not draw the 24x24 square the resource holds") + } + + /** + * **`Color`, opened by the same widening -- and it is a value, not a number.** + * + * `Color` is a `@JvmInline value class` over `ULong`, and the going-in assumption was that it + * would therefore cross as a packed number the way `Dp` crosses as a `Float`, needing + * `pythonx.allow_raw_primitive` before Python could carry one anywhere. It does not, and the + * reason is **one level further down than `Color`**: `resolveKotlinType` opens `Color` happily + * (its constructor and its `value` accessor are both public) and then cannot open the `ULong` + * underneath, whose own constructor and `data` property are `internal`. So the descent stops + * there and the boundary carries a **`kotlin.ULong` instance**, under the declared name + * `androidx.compose.ui.graphics.Color` -- `Color(255, 0, 0)` comes back as an owned handle, which + * is a better answer than a raw number: nothing has to be trusted not to reinterpret it. + * + * Which class the handle really holds is not a curiosity. It is what + * `ArtifactScanner.composableDeclaredSlot` records as the slot's *carrier* and what the generated + * thunk `CHECKCAST`s before calling `unbox-impl` -- see + * [aComposablesColorSlotAcceptsAPythonBuiltColorAndTintsWithIt], which is the same value going the + * other way. + * + * `toArgb` is the round trip, and the assertion is the exact number rather than "it did not + * raise": `Color(255, 0, 0)` is opaque red and `toArgb` is `0xFFFF0000`, which as a Kotlin `Int` + * is `-65536`. A handle that reached `toArgb` as some *other* colour, or a `Color` factory that + * packed its arguments in the wrong order, both fail this. + */ + @Test + fun aPythonBuiltColorRoundTripsIntoAnotherWalkedCall() { + Python3.exec( + """ + from androidx.compose.ui.graphics import Color__Int_Int_Int_Int, toArgb + + _red = Color__Int_Int_Int_Int(255, 0, 0) + assert type(_red).__name__ != 'int', 'Color came back as a raw number, not an owned value' + assert type(_red)._kotlin_type_name == 'androidx.compose.ui.graphics.Color', \ + type(_red)._kotlin_type_name + assert toArgb(_red) == -65536, ('0xFFFF0000 as a Kotlin Int', toArgb(_red)) + """.trimIndent(), + ) + } + + /** + * **The wall that same value used to hit, now drawn through.** + * + * `ebe3365f` pinned this as a refusal: `Icon`'s `tint` was tagged `INT` while + * `lightColorScheme`'s 36 `Color` parameters -- same type, same jar -- were tagged `OBJECT`, so a + * `Color` handle could be carried into any ordinary declaration and into no composable. Two code + * paths in `ArtifactScanner` with two different amounts of information: an ordinary declaration + * typed from `@Metadata`, which knows `Color` is a value class; a composable typed from the + * **JVM descriptor**, where the same `Color` is the letter `J`. + * + * The composable path reads `@Metadata` for its declared slots now + * (`ArtifactScanner.composableDeclaredSlot`), keeping the descriptor for the + * `$composer`/`$changed`/`$default` slots that only exist there -- so both tags below are + * `OBJECT`, and this is a render test rather than a pin. + * + * ### Why the assertion is the colour and not the pixel count + * + * `Icon` tints through `ColorFilter.tint`, which is `BlendMode.SrcIn`: it replaces colour and + * keeps alpha. The resource is opaque **white**, so the tint is the only thing deciding what + * comes out, and a tint that never reached Compose draws the same 576 pixels in + * `LocalContentColor`'s black. Counting ink would pass either way; counting *red* ink cannot. + */ + @Test + fun aComposablesColorSlotAcceptsAPythonBuiltColorAndTintsWithIt() { + Python3.exec( + """ + import python_multiplatform.binding as _pm_binding + + _icon = _pm_binding._BY_PACKAGE['androidx.compose.material3']['Icon'] + # Read `tint` by name: material3 1.9 (CMP 1.11) added `Icon(painter, tint: ColorProducer?, + # contentDescription, modifier)`, whose slot 3 is `modifier` and whose tint is a lambda, + # not a Color. The Color-tinted overloads are the three this test is about. + _tints = { + (d.param_tags[d.param_names.index('tint')], d.param_type_names[d.param_names.index('tint')]) + for d in _icon + } + assert _tints == { + ('OBJECT', 'androidx.compose.ui.graphics.Color'), + ('OBJECT', 'androidx.compose.ui.graphics.ColorProducer'), + }, repr(_tints) + + _scheme = _pm_binding._BY_PACKAGE['androidx.compose.material3']['lightColorScheme'][0] + assert _scheme.param_tags[0] == 'OBJECT', _scheme.param_tags[0] + assert _scheme.param_type_names[0] == 'androidx.compose.ui.graphics.Color', \ + _scheme.param_type_names[0] + """.trimIndent(), + ) + val tinted = redOf( + """ + from androidx.compose.ui.graphics import Color__Int_Int_Int_Int + from androidx.compose.ui.res import painterResource + from androidx.compose.material3 import Icon + Icon(painterResource('$RESOURCE'), contentDescription='a square', + tint=Color__Int_Int_Int_Int(255, 0, 0)) + """.trimIndent(), + ) + // The control, and the one that makes the number mean something: the *same* call with the + // tint left out. `tint` declares a default, so this also drives the other half of the change + // -- an omitted OBJECT slot arrives as `None` and the thunk's unwrapper turns it into a zero + // the callee overwrites, rather than into a NullPointerException. + val untinted = redOf( + """ + from androidx.compose.ui.res import painterResource + from androidx.compose.material3 import Icon + Icon(painterResource('$RESOURCE'), contentDescription='a square') + """.trimIndent(), + ) + println("compose render: Icon(tint=Color(255,0,0)) -> $tinted red px, no tint -> $untinted red px") + assertEquals(SQUARE, tinted, "the icon did not come out opaque red") + assertEquals(0, untinted, "the default tint is red, so the tinted count proves nothing") + } + + /** + * **The negative control the old pin used to be.** No `Icon` overload defaults its image, so a + * call that writes only `contentDescription` has to be refused by all three rather than draw + * something. + */ + @Test + fun iconStillCannotBeCalledWithoutAnImage() { + Python3.exec( + """ + from androidx.compose.material3 import Icon + try: + Icon(contentDescription='nothing') + raise AssertionError('Icon must not be callable without an image') + except TypeError: + pass + """.trimIndent(), + ) + } + + /** + * **The `ImageBitmap` overload, proven positionally.** + * + * `ImageBitmap(width, height)` crosses and `Icon` accepts it -- the dispatcher picks the + * `ImageBitmap` overload out of three on the owned value's declared type name alone. What it + * cannot do is draw, because nothing bound can write a pixel into it (this class's KDoc). So the + * proof is the one `Spacer` uses: `Icon` sizes itself from its painter's intrinsic size, which is + * the bitmap's own extent, so a wider bitmap pushes the `Text` after it further right. A `width` + * that never reached Compose -- dropped, or replaced by the 24dp fallback `Icon` uses for a + * painter with no intrinsic size -- leaves that edge in the same place for both. + */ + @Test + fun pythonBuildsAnImageBitmapAndComposeLaysTheIconOutAtThatWidth() { + val narrow = rightmostInk(iconInARow(8)) + val wide = rightmostInk(iconInARow(64)) + println("compose render: Icon(ImageBitmap(8, 12)) -> rightmost ink x=$narrow, ImageBitmap(64, 12) -> x=$wide") + assertTrue(narrow > 0, "nothing drew at all -- the Text beside the Icon never composed") + assertTrue( + wide > narrow + 50, + "a 56px wider bitmap moved the trailing Text by less than 50px: $narrow -> $wide", + ) + } + + /** + * The other half of that, stated so it cannot rot into a silent assumption: the bitmap `Icon` + * lays out draws **nothing**, and that is a property of the walker's surface rather than of the + * boundary. The day an instance method or a `ColorPainter` becomes reachable, this fails and the + * positional proof above can become an inked one. + */ + @Test + fun anImageBitmapWithNoPixelsWrittenIntoItDrawsNothingBecauseNoBoundCallCanFillOne() { + val drawn = inkOf( + """ + from androidx.compose.ui.graphics import ImageBitmap + from androidx.compose.material3 import Icon + Icon(ImageBitmap(24, 24), contentDescription='an empty square') + """.trimIndent(), + ) + println("compose render: Icon(ImageBitmap(24, 24)) -> $drawn px") + assertEquals( + 0, drawn, + "a constructed ImageBitmap now has pixels in it -- something bound can fill one, so the " + + "ImageBitmap overload deserves an ink proof rather than a positional one", + ) + Python3.exec( + """ + import python_multiplatform.binding as _pm_binding + _all = [d for table in _pm_binding._BY_PACKAGE.values() for decls in table.values() for d in decls] + # The mechanism, not the symptom: nothing the walker emits can *call* into an existing + # object. Functions are top-level or extensions, because `kotlinCandidates` filters on + # ACC_STATIC, and static getters read a singleton's value without taking a receiver. + # `Canvas.drawRect` is an instance method, and so is every other way to put a pixel in a + # bitmap. The day an instance method becomes reachable, this fails and the positional + # proof above can become an inked one. + _kinds = sorted({d.kind for d in _all}) + assert set(_kinds) <= {'FUNCTION', 'STATIC_GETTER'}, \ + 'the walker now binds a kind that may take a receiver: ' + repr(_kinds) + """.trimIndent(), + ) + } + + /** + * **The nominal type check, opened.** `ebe3365f` pinned this as a refusal: `_coerce` compared an + * owned value's declared type name against the slot's for **equality**, and the string + * `BitmapPainter` is not the string `Painter`. `pythonx` had no hierarchy to consult, because the + * table carried names and nothing else. + * + * It carries the ancestry now, on the *value* side rather than the slot side -- a slot accepts + * unboundedly many subtypes, a produced value has one finite chain -- and `ArtifactScanner` reads + * it from the same class files it is already walking. See `ReturnSupertypeTest` for what that + * costs the table. + * + * ### Why the proof is positional and not inked + * + * The same reason [pythonBuildsAnImageBitmapAndComposeLaysTheIconOutAtThatWidth] gives: nothing + * bound can write a pixel into an `ImageBitmap`, so a `BitmapPainter` over one draws nothing + * however well every layer beneath it worked. What it *does* have is an intrinsic size, which + * `Icon` sizes itself from -- so a 64-wide painter pushes the trailing `Text` right and an 8-wide + * one does not. An argument that was silently dropped, or a painter that never reached Compose, + * leaves that edge in one place for both. + */ + @Test + fun aBitmapPainterIsAcceptedByIconsPainterSlotNowThatTheTableCarriesItsAncestry() { + Python3.exec( + """ + import python_multiplatform.binding as _pm_binding + from androidx.compose.ui.graphics import ImageBitmap + from androidx.compose.ui.graphics.painter import BitmapPainter + + _painter = BitmapPainter(ImageBitmap(24, 24)) + # The proxy still names the type the declaration returns, not its base: the ancestry is a + # separate fact and must not overwrite the identity. + assert type(_painter)._kotlin_type_name == 'androidx.compose.ui.graphics.painter.BitmapPainter', \ + type(_painter)._kotlin_type_name + assert 'androidx.compose.ui.graphics.painter.Painter' in \ + _pm_binding._SUPERTYPES.get('androidx.compose.ui.graphics.painter.BitmapPainter', ()), \ + repr(_pm_binding._SUPERTYPES.get('androidx.compose.ui.graphics.painter.BitmapPainter')) + # And it is not a licence to accept anything: an ImageBitmap is not a Painter. + assert 'androidx.compose.ui.graphics.painter.Painter' not in \ + _pm_binding._SUPERTYPES.get('androidx.compose.ui.graphics.ImageBitmap', ()) + """.trimIndent(), + ) + val narrow = rightmostInk(bitmapPainterInARow(8)) + val wide = rightmostInk(bitmapPainterInARow(64)) + println("compose render: Icon(BitmapPainter(ImageBitmap(8, 12))) -> x=$narrow, 64 wide -> x=$wide") + assertTrue(narrow > 0, "nothing drew at all -- the Text beside the Icon never composed") + assertTrue( + wide > narrow + 50, + "a 56px wider BitmapPainter moved the trailing Text by less than 50px: $narrow -> $wide", + ) + } + + /** + * The other side of the same rule, so that "accepts a subtype" cannot quietly become "accepts + * anything": an `ImageBitmap` is not a `Painter`, and the overload that takes one is selected by + * its own type rather than by falling through. Both spellings of the refusal are covered -- a + * value whose ancestry does not contain the slot's type, and a value with no ancestry at all. + */ + @Test + fun aValueWhoseAncestryDoesNotReachTheSlotIsStillRefused() { + Python3.exec( + """ + from androidx.compose.ui.graphics import ImageBitmap, Color__Int_Int_Int_Int + from androidx.compose.ui.graphics.painter import BitmapPainter + from androidx.compose.material3 import Icon + + try: + BitmapPainter(Color__Int_Int_Int_Int(255, 0, 0)) + raise AssertionError('BitmapPainter accepted a Color for its ImageBitmap parameter') + except TypeError: + pass + + try: + Icon(Color__Int_Int_Int_Int(255, 0, 0), contentDescription='not an image') + raise AssertionError('Icon accepted a Color as its image') + except TypeError as _e: + assert 'no overload of Icon accepts these arguments' in str(_e), str(_e) + """.trimIndent(), + ) + } + + private fun bitmapPainterInARow(width: Int): String = + """ + from androidx.compose.foundation.layout import Row + from androidx.compose.ui.graphics import ImageBitmap + from androidx.compose.ui.graphics.painter import BitmapPainter + from androidx.compose.material3 import Icon, Text + Row(content=lambda row: ( + Icon(BitmapPainter(ImageBitmap($width, 12)), contentDescription='a square'), + Text('hi'), + )) + """.trimIndent() + + /** + * **`ImageVector`, the third overload, still unreachable -- with the reason moved.** + * + * `a6742a1c` recorded two reasons and neither survives as stated. The package is walked now. And + * the builder is not blocked for being *stateful*: [pythonLoadsAPainterAndIconDrawsIt] carries an + * owned handle out of one walked call and into another already, which is the whole of what a + * builder needs. + * + * It is blocked because `ImageVector.Builder` is a **nested class**. `ArtifactScanner.scan` + * skips every class whose binary name contains `$` before it reads a single member, on the + * grounds that a nested class's binary name is not how Kotlin spells its qualified name -- so the + * builder's constructor and its `build()` are not declined on their merits, they are never seen. + * Two further doors are shut behind it, and both are worth naming because either would open + * `ImageVector` without the nested-class question being answered at all: + * + * - `ImageVector.Builder`'s members would still need `CallableKind.METHOD`, which the walker + * never emits (`ACC_STATIC` only); + * - `loadXmlImageVector` *is* static and takes an `org.xml.sax.InputSource`, a JDK type that is + * not on the artefact classpath, so `resolveKotlinType` cannot find a public class for it and + * declines the whole declaration. That is the same reason `loadImageBitmap(InputStream)` and + * `openResource(String)` are absent from the walk while `painterResource(String)` is present. + * + * What *is* bound is `ImageVectorKt.path`, the top-level extension **on** that builder -- a + * receiver whose only possible source is the class that was skipped. That asymmetry is what this + * asserts, because it is the thing that changes first. + */ + @Test + fun imageVectorIsUnreachableBecauseItsBuilderIsANestedClassTheWalkerNeverOpens() { + Python3.exec( + """ + import python_multiplatform.binding as _pm_binding + _vector = 'androidx.compose.ui.graphics.vector.ImageVector' + _builder = _vector + '.Builder' + _all = [d for table in _pm_binding._BY_PACKAGE.values() for decls in table.values() for d in decls] + + _producers = [d.kotlin_name for d in _all if d.return_type_name == _vector] + # material-icons-core (issue #37) brings one: `materialIcon(name, ..., imageVectorBuilder)`. + # It builds through a `(ImageVector.Builder) -> ImageVector.Builder` lambda, and a Python + # callable cannot stand in for a lambda that has to give something back -- refused below, + # so it opens nothing. Anything else returning an ImageVector still fails this. + assert _producers == ['androidx.compose.material.icons.materialIcon'], \ + 'something else now returns an ImageVector -- the builder may be reachable: ' + repr(_producers) + import androidx.compose.material.icons as _mi + _mi_decl = [d for d in _all if d.kotlin_name == 'androidx.compose.material.icons.materialIcon'][0] + _mi_slot = [n for n, t in zip(_mi_decl.param_names, _mi_decl.param_type_names) + if t.startswith('kotlin.Function')][0] + try: + _mi.materialIcon('x', **{_mi_slot: lambda builder: builder}) + raise AssertionError('materialIcon accepted a Python builder lambda') + except TypeError as _mi_e: + assert 'cannot be written in Python yet' in str(_mi_e), str(_mi_e) + + # The extension *on* the builder is bound; the type that would produce one is not. That + # gap is the nested-class skip and nothing else. + _on_builder = [d.kotlin_name for d in _all if d.receiver_type_name == _builder] + assert _on_builder != [], \ + 'nothing is bound on ImageVector.Builder any more, so this no longer measures what it says' + _builds = [d.kotlin_name for d in _all if d.return_type_name == _builder and not d.is_extension] + assert _builds == [], 'a Builder became constructible: ' + repr(_builds) + + # And the static route, declined for its parameter rather than for being nested. + assert 'loadXmlImageVector' not in _pm_binding._BY_PACKAGE.get('androidx.compose.ui.res', {}), \ + 'loadXmlImageVector is bound now -- org.xml.sax.InputSource became resolvable' + """.trimIndent(), + ) + } + + /** + * **Who owns the values a render builds.** `a179b747`'s rule on the two walked results that are + * now on the critical path of a render: Kotlin registers the handle and does **not** release it; + * the Python proxy's `__del__` does. + */ + @Test + fun aPythonHeldImageBitmapRootsExactlyOneHandleAndGivesItBack() { + val baseline = settledBaseline() + Python3.exec( + """ + from androidx.compose.ui.graphics import ImageBitmap + _bmp = ImageBitmap(24, 24) + assert type(_bmp).__name__ != 'int', 'a walked OBJECT result must come back owned, not as a bare handle' + """.trimIndent(), + ) + assertEquals( + baseline + 1, HandleTable.liveCount, + "ImageBitmap() must root exactly one handle while Python still holds it", + ) + + Python3.exec("import gc\n_bmp = None\ngc.collect()") + assertEquals(baseline, HandleTable.liveCount, "the bitmap's handle never came back") + } + + /** + * The double release, stated apart from the leak, because a leak test alone passes one + * (`agent-rules` §14): a second owned handle takes the slot the first freed, and repeating the + * first's release must not free it. + */ + @Test + fun releasingABitmapTwiceDoesNotFreeTheSlotItsSuccessorTookOver() { + val baseline = settledBaseline() + + Python3.exec("from androidx.compose.ui.graphics import ImageBitmap\n_bmp = ImageBitmap(24, 24)") + assertEquals(baseline + 1, HandleTable.liveCount) + + Python3.exec("_bmp.__del__()") + assertEquals(baseline, HandleTable.liveCount, "the explicit release must have done the work") + + Python3.exec("_bmp_b = ImageBitmap(8, 8)") + assertEquals(baseline + 1, HandleTable.liveCount) + + Python3.exec("_bmp.__del__()") + assertEquals( + baseline + 1, HandleTable.liveCount, + "the second release of an already-released handle must be a no-op, not free the new owner's slot", + ) + + Python3.exec("import gc\n_bmp = None\n_bmp_b = None\ngc.collect()") + assertEquals(baseline, HandleTable.liveCount) + } + + /** + * The chain, which is what a builder pattern is: two owned handles alive at once and the second + * derived from the first. `BitmapPainter(ImageBitmap(...))` is that shape with a real walked call + * on each end, and both have to come back -- a chain that released the intermediate early would + * hand `BitmapPainter` a freed slot, and one that never released it would leak per link. + */ + @Test + fun aChainOfTwoOwnedResultsRootsTwoHandlesAndGivesBothBack() { + val baseline = settledBaseline() + Python3.exec( + """ + from androidx.compose.ui.graphics import ImageBitmap + from androidx.compose.ui.graphics.painter import BitmapPainter + _bmp = ImageBitmap(24, 24) + _painter = BitmapPainter(_bmp) + """.trimIndent(), + ) + assertEquals( + baseline + 2, HandleTable.liveCount, + "a two-link chain must root exactly one handle per link", + ) + Python3.exec("import gc\n_bmp = None\ngc.collect()") + assertEquals( + baseline + 1, HandleTable.liveCount, + "dropping the intermediate must free exactly its own handle and not the painter's", + ) + Python3.exec("import gc\n_painter = None\ngc.collect()") + assertEquals(baseline, HandleTable.liveCount, "the painter's handle never came back") + } + + /** + * The render path's own lifetime, and the one that would be invisible in a single frame: the + * painter is loaded *inside* the composition body, so every composition pass creates one and + * nothing in Compose's slot table hands it back. `liveCount` has to return to where it started + * once Python drops its name. + */ + @Test + fun aRenderThatLoadsItsOwnPainterLeavesNoHandleBehind() { + val baseline = settledBaseline() + val drawn = inkOf( + """ + from androidx.compose.ui.res import painterResource + from androidx.compose.material3 import Icon + _held = painterResource('$RESOURCE') + Icon(_held, contentDescription='a square') + """.trimIndent(), + ) + assertEquals(SQUARE, drawn, "the icon did not draw, so nothing can be concluded about its handles") + Python3.exec("import gc\n_held = None\ngc.collect()") + assertEquals( + baseline, HandleTable.liveCount, + "the composition kept a handle Python had already dropped", + ) + } + + private fun iconInARow(width: Int): String = + """ + from androidx.compose.foundation.layout import Row + from androidx.compose.ui.graphics import ImageBitmap + from androidx.compose.material3 import Icon, Text + Row(content=lambda row: ( + Icon(ImageBitmap($width, 12), contentDescription='a square'), + Text('hi'), + )) + """.trimIndent() + + /** [HandleTable.liveCount] with anything an earlier test left as garbage already collected. */ + private fun settledBaseline(): Int { + Python3.exec( + "import gc\n" + + "for _n in ('_bmp', '_bmp_b', '_held', '_painter'):\n" + + " globals().pop(_n, None)\n" + + "gc.collect()", + ) + return HandleTable.liveCount + } + + private fun inkOf(body: String): Int = inkOfScene { PythonComposition(body) } + + /** How many pixels came out **opaque red**, which is what a tint that arrived produces and what + * one that was dropped cannot: `Icon`'s `SrcIn` filter replaces the resource's white. */ + private fun redOf(body: String): Int { + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + var red = 0 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (bitmap.getColor(x, y) == OPAQUE_RED) red++ + } + } + return red + } finally { + scene.close() + } + } + + private fun rightmostInk(body: String): Int { + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + var rightmost = -1 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (bitmap.getColor(x, y) != BACKGROUND && x > rightmost) rightmost = x + } + } + return rightmost + } finally { + scene.close() + } + } + + private fun inkOfScene(content: @Composable () -> Unit): Int { + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f), content = content) + try { + return inkOfImage(scene.render()) + } finally { + scene.close() + } + } + + private fun inkOfImage(image: Image): Int { + val bitmap = Bitmap.makeFromImage(image) + var ink = 0 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (bitmap.getColor(x, y) != BACKGROUND) ink++ + } + } + return ink + } + + private companion object { + /** `ImageComposeScene` clears to transparent black. */ + const val BACKGROUND = 0 + + /** + * 24x24, every pixel opaque white, checked in beside this file. Opaque because `Icon` tints + * with `SrcIn` and keeps alpha; white so that the tint is the only thing deciding the colour + * that comes out. + */ + const val RESOURCE = "opaque-square.png" + + /** What a 24dp square covers at density 1. */ + const val SQUARE = 24 * 24 + + /** `Color(255, 0, 0)` as `Bitmap.getColor` reports it: `0xFFFF0000`. */ + const val OPAQUE_RED = 0xFFFF0000.toInt() + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt new file mode 100644 index 000000000..ee2b4f920 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt @@ -0,0 +1,154 @@ +package fixture.compose + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The signature metadata a Pythonic layer (pythonx-compose) reads off the binder, on real Compose. + * + * pythonx-compose re-exposes Kotlin-named modules Pythonically by **one rule** applied to every + * module it lists, with no per-widget wrapper. That rule needs, for each function: its Kotlin + * parameter names, which have defaults, the overload set behind a base name, which parameters are + * value classes, and which one is the trailing `@Composable` content lambda. This pins that the + * binder publishes exactly that -- through `inspect.signature` and `python_multiplatform.describe` + * (`KotlinSurface`'s KDoc is the contract) -- for a real composable (`Checkbox`) and a real + * overload set (`padding`), whichever installer put the name there. + */ +class KotlinSignatureMetadataTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * `Checkbox(checked, onCheckedChange, modifier = Modifier, ...)`, as Kotlin declares it. Compose 1.11 + * has two `Checkbox` overloads (the second adds `checkmarkStroke`/`outlineStroke`), so the base name + * answers `(*args, **kwargs)` and the signature lives on the specific overload callable. + */ + @Test + fun checkboxCarriesItsKotlinParameterNamesAndMarksItsDefaults() { + for (order in listOf("adapter", "adapter-then-proxy", "proxy-then-adapter")) { + when (order) { + "adapter" -> PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + "adapter-then-proxy" -> { PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES); PythonProxySource.install() } + "proxy-then-adapter" -> { PythonProxySource.install(); PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) } + } + Python3.exec( + """ + import inspect as _ksm_inspect + import python_multiplatform as _ksm_pm + import androidx.compose.material3 as _ksm_m3 + # material3 1.9 (Compose 1.11) overloads Checkbox: the base name is the overload set, + # and each overload is its own callable named by its parameter types. + _ksm_base = _ksm_m3.Checkbox + _ksm_checkbox = _ksm_m3.Checkbox__Boolean_Unit_Modifier_Boolean_CheckboxColors_MutableInteractionSource + _ksm_params = list(_ksm_inspect.signature(_ksm_checkbox).parameters.values()) + _ksm_d = _ksm_pm.describe(_ksm_checkbox) + _ksm_all = _ksm_pm.describe(_ksm_base) + _ksm = { + 'base_sig': str(_ksm_inspect.signature(_ksm_base)), + 'all_names': sorted(_q['name'] for _q in _ksm_all), + 'first': [ + (_p.name, _p.default is _ksm_pm.KOTLIN_DEFAULT, _p.annotation) + for _p in _ksm_params[:3] + ], + 'synthetic': [_p.name for _p in _ksm_params if not _p.name.isidentifier()], + 'count': len(_ksm_d), + 'name': _ksm_d[0]['name'], + 'composable': _ksm_d[0]['composable'], + 'callback': _ksm_d[0]['parameters'][1]['type'].startswith('kotlin.Function1'), + 'described': [_q['name'] for _q in _ksm_d[0]['parameters']] == + [_p.name for _p in _ksm_params], + } + """.trimIndent(), + ) + assertEquals( + "[('checked', False, 'kotlin.Boolean'), ('onCheckedChange', False, " + + "${eval("repr(_ksm_d[0]['parameters'][1]['type'])")}), " + + "('modifier', True, 'androidx.compose.ui.Modifier')]", + eval("repr(_ksm['first'])"), + order, + ) + assertEquals("[]", eval("repr(_ksm['synthetic'])"), "$order: \$composer/\$changed/\$default must not appear") + assertEquals("1", eval("_ksm['count']"), order) + assertEquals("(*args, **kwargs)", eval("_ksm['base_sig']"), "$order: an overload set has no single signature") + assertEquals( + "['androidx.compose.material3.Checkbox__Boolean_Unit_Modifier_Boolean_CheckboxColors_MutableInteractionSource', " + + "'androidx.compose.material3.Checkbox__Boolean_Unit_Stroke_Stroke_Modifier_Boolean_CheckboxColors_MutableInteractionSource']", + eval("repr(_ksm['all_names'])"), + "$order: describe(base) lists every overload", + ) + assertEquals( + "androidx.compose.material3.Checkbox__Boolean_Unit_Modifier_Boolean_CheckboxColors_MutableInteractionSource", + eval("_ksm['name']"), + order, + ) + assertEquals("True", eval("_ksm['composable']"), order) + assertEquals("True", eval("_ksm['callback']"), order) + assertEquals("True", eval("_ksm['described']"), "$order: describe and inspect.signature must agree") + } + } + + /** The overload set behind `padding`, with each candidate's value-class parameters marked. */ + @Test + fun paddingDescribesItsWholeOverloadSet() { + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + Python3.exec( + """ + import inspect as _ksm_inspect + import python_multiplatform as _ksm_pm + import androidx.compose.foundation.layout as _ksm_layout + _ksm_d = _ksm_pm.describe(_ksm_layout.padding) + _ksm = { + 'names': sorted(_d['name'].rpartition('.')[2] for _d in _ksm_d), + 'receivers': sorted(set(_d['receiver'] for _d in _ksm_d)), + 'dp_value_class': all( + _q['value_class'] for _d in _ksm_d for _q in _d['parameters'] + if _q['type'] == 'androidx.compose.ui.unit.Dp' + ), + 'generic': str(_ksm_inspect.signature(_ksm_layout.padding)), + 'one': str(_ksm_inspect.signature(_ksm_layout.padding__Dp_Dp)), + } + """.trimIndent(), + ) + val names = eval("repr(_ksm['names'])") + assertTrue("'padding__Dp'" in names && "'padding__Dp_Dp'" in names && "'padding__Dp_Dp_Dp_Dp'" in names, names) + assertEquals("['androidx.compose.ui.Modifier']", eval("repr(_ksm['receivers'])")) + assertEquals("True", eval("_ksm['dp_value_class']")) + assertEquals("(*args, **kwargs)", eval("_ksm['generic']"), "an overload set has no single signature") + assertEquals( + "(receiver: 'androidx.compose.ui.Modifier', /, " + + "horizontal: 'androidx.compose.ui.unit.Dp' = , " + + "vertical: 'androidx.compose.ui.unit.Dp' = ) -> 'androidx.compose.ui.Modifier'", + eval("_ksm['one']"), + ) + } + + private fun eval(expression: String): String = + Python3.import("__main__").getAttr("__dict__").let { globals -> + Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString() + } + + private companion object { + /** CPython's `Py_eval_input`, as `RetainedSlotSweepPreconditionTest` spells it. */ + const val PY_EVAL_INPUT = 258 + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/LayoutIdRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/LayoutIdRenderTest.kt new file mode 100644 index 000000000..246260421 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/LayoutIdRenderTest.kt @@ -0,0 +1,169 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * **A real string value, tagged from Python, reaches `Measurable.layoutId`** -- `layoutId` declines + * for a reason unrelated to suspend (`docs/archive/pythonx-adapter-design.md` §9's table: its one non-`Modifier` + * parameter is `kotlin.Any`, which `resolveKotlinType` has no `TypeTag` for). `fixture.compose + * .pythonLayoutIdString` (`PythonLayoutId.kt`) fixes the parameter's tag the same way every other + * concretely-typed hand-written wrapper in this module does. + * + * ### Why the probe is a `Layout` built in this test, not a Python-callable composable + * + * The boundary carries no reader for [androidx.compose.ui.layout.Measurable.layoutId] itself, so + * something has to *act* on it to make it observable. `PythonLayoutId.kt`'s KDoc records why that + * something cannot be a hand-written `@Composable` Python calls (`BindingPolicy.isComposable` excludes + * every `@Composable` top-level function from KSP's plain-function path, confirmed by a first attempt + * failing to bind at all: `cannot import name 'pythonLayoutIdProbe'`). It can be a `Layout` in *this* + * file instead, wrapping [PythonComposition] the same way `ImageComposeScene`'s own content lambda + * already does in every other render test here -- `PythonComposition`'s Python body composes exactly + * one tagged `Text`, so that `Layout`'s single measurable carries the `layoutId` Python's call to + * `pythonLayoutIdString` produced. + * + * It places that one child at x=0 if its `layoutId` equals [expectTag] (an argument *of this Kotlin + * test*, not visible to Python) or at x=60 -- outside a 20dp child's reach at x=0..20 -- if not. A stub + * that always tagged with a fixed string, or dropped the string Python actually sent, would place the + * ink at the wrong x for at least one of the two cases below. + */ +class LayoutIdRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** The positive claim: tagging with `"python-tag"` from Python and probing for the same string + * places the ink at the left (x in [0,20)). */ + @Test + fun aMatchingLayoutIdPlacesTheChildAtTheLeft() { + val pixels = pixelsOf(taggedBody("python-tag"), expectTag = "python-tag") + assertTrue(inkAt(pixels, 0, 20), "no ink in the left region -- the matching tag was not placed there") + assertTrue(!inkAt(pixels, 60, 80), "ink in the right region even though the tag matched") + } + + /** The negative control: tagging with one string and probing for a **different** one places the + * ink at the right (x in [60,80)) instead -- ruling out a stub that always reports a match, or one + * that silently drops the string Python actually sent. */ + @Test + fun aMismatchedLayoutIdPlacesTheChildAtTheRight() { + val pixels = pixelsOf(taggedBody("python-tag"), expectTag = "a-different-tag") + assertTrue(inkAt(pixels, 60, 80), "no ink in the right region -- a mismatched tag was not detected") + assertTrue(!inkAt(pixels, 0, 20), "ink in the left region even though the tag did not match") + } + + private fun taggedBody(tag: String) = """ + from fixture.compose import emptyModifier, pythonLayoutIdString + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.material3 import Text + + _m = pythonLayoutIdString(size__Dp(emptyModifier(), 20.0), "$tag") + Text("X", modifier=_m) + """.trimIndent() + + /** + * [expectTag] is read by *this* Kotlin measure policy, never crossed to Python -- it is what + * makes the mismatch case in [aMismatchedLayoutIdPlacesTheChildAtTheRight] a control rather than + * a second copy of the same claim. + */ + private fun pixelsOf(body: String, expectTag: String): IntArray { + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + Layout(content = { PythonComposition(body) }) { measurables, constraints -> + val measurable = measurables.first() + val placeable = measurable.measure(constraints) + val x = if (measurable.layoutId == expectTag) 0 else 60 + layout(SCENE, SCENE) { + placeable.placeRelative(x, 0) + } + } + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return IntArray(SCENE * SCENE) { bitmap.getColor(it % SCENE, it / SCENE) } + } finally { + scene.close() + } + } + + /** The positive claim: tagging with an integer from Python and probing for the same int + * places the ink at the left (x in [0,20)). */ + @Test + fun aMatchingLayoutIdIntPlacesTheChildAtTheLeft() { + val pixels = pixelsOfInt(taggedBodyInt(42), expectTag = 42) + assertTrue(inkAt(pixels, 0, 20), "no ink in the left region -- the matching tag was not placed there") + assertTrue(!inkAt(pixels, 60, 80), "ink in the right region even though the tag matched") + } + + /** The negative control for int tag. */ + @Test + fun aMismatchedLayoutIdIntPlacesTheChildAtTheRight() { + val pixels = pixelsOfInt(taggedBodyInt(42), expectTag = 99) + assertTrue(inkAt(pixels, 60, 80), "no ink in the right region -- a mismatched tag was not detected") + assertTrue(!inkAt(pixels, 0, 20), "ink in the left region even though the tag did not match") + } + + private fun taggedBodyInt(tag: Int) = """ + from fixture.compose import emptyModifier, pythonLayoutIdInt + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.material3 import Text + + _m = pythonLayoutIdInt(size__Dp(emptyModifier(), 20.0), $tag) + Text("X", modifier=_m) + """.trimIndent() + + private fun pixelsOfInt(body: String, expectTag: Int): IntArray { + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + Layout(content = { PythonComposition(body) }) { measurables, constraints -> + val measurable = measurables.first() + val placeable = measurable.measure(constraints) + val x = if (measurable.layoutId == expectTag) 0 else 60 + layout(SCENE, SCENE) { + placeable.placeRelative(x, 0) + } + } + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return IntArray(SCENE * SCENE) { bitmap.getColor(it % SCENE, it / SCENE) } + } finally { + scene.close() + } + } + + /** Whether any pixel in the vertical band `[xStart, xEnd)` is not background -- the child is + * placed at y=0, so any row in that band suffices. */ + private fun inkAt(pixels: IntArray, xStart: Int, xEnd: Int): Boolean = + (0 until SCENE).any { y -> + (xStart until xEnd).any { x -> pixels[y * SCENE + x] != BACKGROUND } + } + + private companion object { + const val BACKGROUND = 0 + const val SCENE = 80 + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt new file mode 100644 index 000000000..45967c713 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt @@ -0,0 +1,855 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **Proof pass for the 27 zero-byte material3 stubs.** + * + * The adaptation layer's artefact table produces declarations from the walked jar; these tests + * establish whether each component actually renders pixels when called from Python, using the same + * evidence model as [ComposableRenderTest]: + * + * - A scene whose Python body calls the component has ink (non-background pixels > 0). + * - A scene whose Python body is `pass` or an empty equivalent has none (or fewer). + * - Where the component fills the entire scene with its own surface (FAB, TopAppBar, Scaffold, + * TabRow), distinct-colour counting is used instead of ink counting — the same approach + * [ComposableRenderTest.listItemComposesItsHeadlineContentUnderItsKotlinName] uses for ListItem. + * + * 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 + * 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) + */ +class M3ProofRenderTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + // ── 1. HorizontalDivider ────────────────────────────────────────────────── + + /** + * HorizontalDivider — leaf, every parameter defaulted. Must draw a visible line on a 200x10 + * scene; pass must draw nothing. + */ + @Test + fun horizontalDividerDrawsALineWithNoArguments() { + val drawn = inkOf( + """ + from androidx.compose.material3 import HorizontalDivider + HorizontalDivider() + """.trimIndent(), + width = 200, height = 10, + ) + val blank = inkOf("pass", width = 200, height = 10) + println("compose render: HorizontalDivider() -> $drawn non-background pixels, pass -> $blank") + assertEquals(0, blank, "empty composition must draw nothing") + assertTrue(drawn > 0, "HorizontalDivider() drew nothing") + } + + // ── 2. RadioButton ─────────────────────────────────────────────────────── + + /** + * 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 + fun radioButtonRendersItsSelectedAndDeselectedStates() { + val selected = inkOf( + """ + from androidx.compose.material3 import RadioButton + RadioButton(selected=True, onClick=lambda: None) + """.trimIndent(), + ) + val deselected = inkOf( + """ + from androidx.compose.material3 import RadioButton + RadioButton(selected=False, onClick=lambda: None) + """.trimIndent(), + ) + println("compose render: RadioButton selected=$selected px, deselected=$deselected px") + assertTrue(selected > 0, "RadioButton(selected=True) drew nothing") + assertTrue(deselected > 0, "RadioButton(selected=False) drew nothing") + assertTrue( + selected > deselected, + "selected RadioButton should have more ink (filled centre dot) than deselected: $deselected vs $selected", + ) + } + + // ── 3. LinearProgressIndicator ─────────────────────────────────────────── + + /** + * `LinearProgressIndicator(progress=...)` -- the *determinate* overload, and the argument-taking + * one, which is why it is the one worth binding. + * + * The zero-argument overload cannot be tested through this harness at all, and that is a + * property of the component rather than of the binding: an indeterminate indicator runs an + * infinite transition, so the composition never goes idle and [ImageComposeScene.render] never + * returns. Found by thread dump after a suite hung for thirty-seven minutes with seven seconds + * of CPU -- blocked, not slow. Anything else that animates forever will do the same here. + * + * The determinate form is reached with a plain float, not a lambda, and that is itself a + * finding: the walked table has three overloads, and the current one takes `progress` as + * `() -> Float`. Passing a Python callable for it is rejected by the overload dispatcher -- + * *"no overload of LinearProgressIndicator accepts these arguments"* -- even though a callable + * is accepted for `onClick`, which is `Function0`. So a value-returning function slot + * does not accept a Python callable today, while a Unit-returning one does. Pinned by + * [aValueReturningFunctionSlotDoesNotYetAcceptAPythonCallable] rather than left as a note. + * + * The float overload this uses is the deprecated one. It is what the binding can reach, and + * reaching it still proves the component renders what it is given. + */ + @Test + fun linearProgressIndicatorDrawsAtItsGivenProgress() { + val drawn = inkOf( + """ + from androidx.compose.material3 import LinearProgressIndicator + LinearProgressIndicator(progress=0.75) + """.trimIndent(), + width = 200, height = 8, + ) + val blank = inkOf("pass", width = 200, height = 8) + println("compose render: LinearProgressIndicator(progress=0.75) -> $drawn px, pass -> $blank") + assertEquals(0, blank, "empty composition must draw nothing") + assertTrue(drawn > 0, "LinearProgressIndicator(progress=0.75) drew nothing") + } + + // ── 4. CircularProgressIndicator ───────────────────────────────────────── + + /** + * `CircularProgressIndicator(progress=...)`, determinate for the same reason the linear one is: + * the indeterminate overload never lets the composition go idle. See that test for what that + * costs anyone who tries it. + */ + @Test + fun circularProgressIndicatorDrawsAtItsGivenProgress() { + val drawn = inkOf( + """ + from androidx.compose.material3 import CircularProgressIndicator + CircularProgressIndicator(progress=0.75) + """.trimIndent(), + ) + val blank = inkOf("pass") + println("compose render: CircularProgressIndicator(progress=0.75) -> $drawn px, pass -> $blank") + assertEquals(0, blank, "empty composition must draw nothing") + assertTrue(drawn > 0, "CircularProgressIndicator(progress=0.75) drew nothing") + } + + /** + * The gap [linearProgressIndicatorDrawsAtItsGivenProgress] found, pinned so it cannot be fixed + * silently: a slot typed `() -> Float` refuses a Python callable, and the dispatcher says so by + * listing every candidate. When this test starts failing, the dispatcher has learned to coerce + * a callable into a value-returning function slot -- and the two progress tests above should + * move to the non-deprecated overload on the same commit. + */ + @Test + fun aValueReturningFunctionSlotDoesNotYetAcceptAPythonCallable() { + val error = try { + inkOf( + """ + from androidx.compose.material3 import LinearProgressIndicator + LinearProgressIndicator(progress=lambda: 0.75) + """.trimIndent(), + width = 200, height = 8, + ) + null + } catch (e: Throwable) { + e.message ?: "" + } + println("compose render: LinearProgressIndicator(progress=lambda) -> ${error?.take(80)}") + assertTrue(error != null, "a callable is now accepted for `() -> Float` -- see this test's doc") + assertTrue( + error.contains("no overload of LinearProgressIndicator accepts these arguments"), + "expected the dispatcher's own rejection, got: $error", + ) + } + + // ── 5. Surface ─────────────────────────────────────────────────────────── + + /** + * 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. + */ + @Test + fun surfaceComposesItsContentLambda() { + val drawn = inkOf( + """ + from androidx.compose.material3 import Surface, Text + Surface(content=lambda: Text('hi')) + """.trimIndent(), + ) + val empty = inkOf( + """ + from androidx.compose.material3 import Surface + Surface(content=lambda: None) + """.trimIndent(), + ) + println("compose render: Surface(content=Text('hi')) -> $drawn px, empty -> $empty px") + assertTrue(drawn > 0, "Surface with Text('hi') drew nothing") + assertTrue(drawn > empty, "Surface content lambda never reached Compose: $empty vs $drawn") + } + + // ── 6. Scaffold ────────────────────────────────────────────────────────── + + /** + * 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. + */ + @Test + fun scaffoldComposesItsContentWithPaddingValues() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import Scaffold, Text + Scaffold(content=lambda padding: Text('hi')) + """.trimIndent(), + width = 200, height = 120, + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import Scaffold + Scaffold(content=lambda padding: None) + """.trimIndent(), + width = 200, height = 120, + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: Scaffold(content=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "Scaffold with empty content must still draw its container") + assertTrue( + drawnColors != emptyColors, + "Scaffold content lambda added no new colour: $emptyColors vs $drawnColors", + ) + } + + // ── 7. Slider ──────────────────────────────────────────────────────────── + + /** + * 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 + * [ComposableRenderTest.theStringPythonWroteIsTheStringComposeDrew] uses for Text length. + */ + @Test + fun sliderDrawsAtItsGivenValue() { + val left = pixelsOf( + """ + from androidx.compose.material3 import Slider + Slider(0.0, onValueChange=lambda v: None) + """.trimIndent(), + width = 200, height = 48, + ) + val right = pixelsOf( + """ + from androidx.compose.material3 import Slider + Slider(1.0, onValueChange=lambda v: None) + """.trimIndent(), + width = 200, height = 48, + ) + val ink = left.count { it != BACKGROUND } + println("compose render: Slider(0.0) -> $ink px; pixel sets differ: ${!left.contentEquals(right)}") + 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", + ) + } + + // ── 8. TopAppBar ───────────────────────────────────────────────────────── + + /** + * 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. + */ + @Test + fun topAppBarComposesItsTitleLambda() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import TopAppBar, Text + TopAppBar(title=lambda: Text('hi')) + """.trimIndent(), + width = 200, height = 64, + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import TopAppBar + TopAppBar(title=lambda: None) + """.trimIndent(), + width = 200, height = 64, + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: TopAppBar(title=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "TopAppBar with empty title must still draw its container") + assertTrue( + drawnColors != emptyColors, + "TopAppBar title lambda added no new colour: $emptyColors vs $drawnColors", + ) + } + + // ── 9. FloatingActionButton ─────────────────────────────────────────────── + + /** + * 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. + */ + @Test + fun floatingActionButtonComposesItsClickHandlerAndContent() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import FloatingActionButton, Text + FloatingActionButton(onClick=lambda: None, content=lambda: Text('hi')) + """.trimIndent(), + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import FloatingActionButton + FloatingActionButton(onClick=lambda: None, content=lambda: None) + """.trimIndent(), + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: FAB(content=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "FloatingActionButton must draw its container even with no content") + assertTrue( + drawnColors != emptyColors, + "FAB content lambda added no new colour over the bare container: $emptyColors vs $drawnColors", + ) + } + + // ── 10. Tab + TabRow ────────────────────────────────────────────────────── + + /** + * 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. + */ + @Test + fun tabRowComposesItsTabsAndTabRendersItsTextSlot() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import TabRow, Tab, Text + TabRow( + selectedTabIndex=0, + tabs=lambda: Tab( + selected=True, + onClick=lambda: None, + text=lambda: Text('Hi'), + ), + ) + """.trimIndent(), + width = 200, height = 48, + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import TabRow, Tab + TabRow( + selectedTabIndex=0, + tabs=lambda: Tab( + selected=True, + onClick=lambda: None, + text=lambda: None, + ), + ) + """.trimIndent(), + width = 200, height = 48, + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: TabRow(Tab(text=Text('Hi'))) -> ${drawnColors.size} distinct colors, " + + "empty text -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "TabRow with empty Tab must still draw its indicator and container") + assertTrue( + drawnColors != emptyColors, + "Tab text lambda added no new colour over the bare tab/row: $emptyColors vs $drawnColors", + ) + } + + // ── 11. Checkbox ────────────────────────────────────────────────────────── + + @Test + fun checkboxRendersItsCheckedAndUncheckedStates() { + val checked = inkOf( + """ + from androidx.compose.material3 import Checkbox + Checkbox(checked=True, onCheckedChange=lambda v: None) + """.trimIndent(), + ) + val unchecked = inkOf( + """ + from androidx.compose.material3 import Checkbox + Checkbox(checked=False, onCheckedChange=lambda v: None) + """.trimIndent(), + ) + println("compose render: Checkbox checked=$checked px, unchecked=$unchecked px") + assertTrue(checked > 0, "Checkbox(checked=True) drew nothing") + assertTrue(unchecked > 0, "Checkbox(checked=False) drew nothing") + assertTrue( + checked > unchecked, + "checked Checkbox should have more ink than unchecked: $unchecked vs $checked", + ) + } + + // ── 12. Switch ──────────────────────────────────────────────────────────── + + /** + * Compared pixel by pixel, not by ink count. Through material3 1.2 the two states happened to + * cover different areas; material3 1.9 (CMP 1.11) paints the unchecked track filled and outlined + * over exactly the checked track's footprint, so both states ink the same number of pixels and a + * count cannot tell them apart. Which pixels, and in what colour, still can. + */ + @Test + fun switchRendersItsCheckedAndUncheckedStates() { + val checked = pixelsOf( + """ + from androidx.compose.material3 import Switch + Switch(checked=True, onCheckedChange=lambda v: None) + """.trimIndent(), + ) + val unchecked = pixelsOf( + """ + from androidx.compose.material3 import Switch + Switch(checked=False, onCheckedChange=lambda v: None) + """.trimIndent(), + ) + val checkedInk = checked.count { it != BACKGROUND } + val uncheckedInk = unchecked.count { it != BACKGROUND } + val differing = checked.indices.count { checked[it] != unchecked[it] } + println("compose render: Switch checked=$checkedInk px, unchecked=$uncheckedInk px, $differing px differ") + assertTrue(checkedInk > 0, "Switch(checked=True) drew nothing") + assertTrue(uncheckedInk > 0, "Switch(checked=False) drew nothing") + assertTrue(differing > 0, "checked Switch rendered pixel-identical to unchecked") + } + + // ── 13. BottomAppBar ────────────────────────────────────────────────────── + + @Test + fun bottomAppBarComposesItsContentLambda() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import BottomAppBar, Text + BottomAppBar(content=lambda *args: Text('hi')) + """.trimIndent(), + width = 200, height = 64, + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import BottomAppBar + BottomAppBar(content=lambda *args: None) + """.trimIndent(), + width = 200, height = 64, + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: BottomAppBar(content=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "BottomAppBar with empty content must still draw its container") + assertTrue( + drawnColors != emptyColors, + "BottomAppBar content lambda added no new colour: $emptyColors vs $drawnColors", + ) + } + + // ── 14. NavigationBar ────────────────────────────────────────────────────── + + @Test + fun navigationBarComposesItsContentLambda() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import NavigationBar, Text + NavigationBar(content=lambda *args: Text('hi')) + """.trimIndent(), + width = 200, height = 64, + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import NavigationBar + NavigationBar(content=lambda *args: None) + """.trimIndent(), + width = 200, height = 64, + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: NavigationBar(content=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "NavigationBar with empty content must still draw its container") + assertTrue( + drawnColors != emptyColors, + "NavigationBar content lambda added no new colour: $emptyColors vs $drawnColors", + ) + } + + // ── 15. NavigationRail ──────────────────────────────────────────────────── + + @Test + fun navigationRailComposesItsContentLambda() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import NavigationRail, Text + NavigationRail(content=lambda *args: Text('hi')) + """.trimIndent(), + width = 80, height = 200, + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import NavigationRail + NavigationRail(content=lambda *args: None) + """.trimIndent(), + width = 80, height = 200, + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: NavigationRail(content=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "NavigationRail with empty content must still draw") + assertTrue( + drawnColors != emptyColors, + "NavigationRail content lambda added no new colour", + ) + } + + // ── 16. ExtendedFloatingActionButton ─────────────────────────────────────── + + @Test + fun extendedFloatingActionButtonComposesItsContent() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import ExtendedFloatingActionButton, Text + ExtendedFloatingActionButton(onClick=lambda: None, text=lambda: Text('hi'), icon=lambda: None) + """.trimIndent(), + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import ExtendedFloatingActionButton + ExtendedFloatingActionButton(onClick=lambda: None, text=lambda: None, icon=lambda: None) + """.trimIndent(), + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: ExtendedFAB(content=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "ExtendedFAB must draw its container") + assertTrue( + drawnColors != emptyColors, + "ExtendedFAB content lambda added no new colour", + ) + } + + // ── 17. Snackbar ────────────────────────────────────────────────────────── + + @Test + fun snackbarComposesItsContent() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import Snackbar, Text + Snackbar(content=lambda: Text('hi')) + """.trimIndent(), + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import Snackbar, Text + Snackbar(content=lambda: Text('')) + """.trimIndent(), + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: Snackbar(content=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "Snackbar must draw its container") + assertTrue( + drawnColors != emptyColors, + "Snackbar content lambda added no new colour", + ) + } + + // ── 18. AlertDialog ─────────────────────────────────────────────────────── + + @Test + fun alertDialogComposesItsButtonsAndTitle() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import AlertDialog, Text + AlertDialog(onDismissRequest=lambda: None, confirmButton=lambda: Text('OK'), title=lambda: Text('hi')) + """.trimIndent(), + width = 200, height = 200, + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import AlertDialog + AlertDialog(onDismissRequest=lambda: None, confirmButton=lambda: None, title=lambda: None) + """.trimIndent(), + width = 200, height = 200, + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: AlertDialog(content) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "AlertDialog must draw its container") + assertTrue( + drawnColors != emptyColors, + "AlertDialog content added no new colour", + ) + } + + // ── 19. NavigationDrawerItem ────────────────────────────────────────────── + + @Test + fun navigationDrawerItemComposesItsLabel() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import NavigationDrawerItem, Text + NavigationDrawerItem(label=lambda: Text('hi'), selected=True, onClick=lambda: None) + """.trimIndent(), + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import NavigationDrawerItem + NavigationDrawerItem(label=lambda: None, selected=True, onClick=lambda: None) + """.trimIndent(), + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: NavigationDrawerItem(label=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "NavigationDrawerItem must draw its container") + assertTrue( + drawnColors != emptyColors, + "NavigationDrawerItem label added no new colour", + ) + } + + // ── 21. SearchBar ───────────────────────────────────────────────────────── + + @Test + fun searchBarComposesItsContent() { + val drawnPixels = pixelsOf( + """ + from androidx.compose.material3 import SearchBar, Text + SearchBar(query="hi", onQueryChange=lambda _: None, onSearch=lambda _: None, active=False, onActiveChange=lambda _: None, content=lambda *args: Text('hi')) + """.trimIndent(), + width = 200, height = 200, + ) + val emptyPixels = pixelsOf( + """ + from androidx.compose.material3 import SearchBar + SearchBar(query="", onQueryChange=lambda _: None, onSearch=lambda _: None, active=False, onActiveChange=lambda _: None, content=lambda *args: None) + """.trimIndent(), + width = 200, height = 200, + ) + val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() + val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() + println( + "compose render: SearchBar(content=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "empty -> ${emptyColors.size} distinct colors", + ) + assertTrue(emptyColors.isNotEmpty(), "SearchBar must draw its container") + assertTrue( + drawnColors != emptyColors, + "SearchBar content added no new colour", + ) + } + + // ── 22. Typography and Shapes ───────────────────────────────────────────── + + @Test + fun stateObjectsArePassedToComposables() { + // 2. DatePicker + val datePickerInk = inkOf( + """ + from androidx.compose.material3 import DatePicker, rememberDatePickerState + DatePicker(state=rememberDatePickerState()) + """.trimIndent(), + width = 400, height = 400, + ) + assertTrue(datePickerInk > 0, "DatePicker should render") + + // 3. TimePicker + val timePickerInk = inkOf( + """ + from androidx.compose.material3 import TimePicker, rememberTimePickerState + TimePicker(state=rememberTimePickerState()) + """.trimIndent(), + width = 400, height = 400, + ) + assertTrue(timePickerInk > 0, "TimePicker should render") + + val swipeInk = inkOf( + """ + from androidx.compose.material3 import SwipeToDismissBox, rememberSwipeToDismissBoxState, Text + SwipeToDismissBox( + state=rememberSwipeToDismissBoxState(), + backgroundContent=lambda: Text("bg"), + content=lambda: Text("fg") + ) + """.trimIndent(), + width = 200, height = 100, + ) + assertTrue(swipeInk > 0, "SwipeToDismissBox should render") + } + + @Test + fun typographyChangesFontRender() { + val baseInk = pixelsOf( + """ + from androidx.compose.material3 import MaterialTheme, Text + MaterialTheme(content=lambda: Text("A")) + """.trimIndent(), + width = 50, height = 50 + ) + val customInk = pixelsOf( + """ + from androidx.compose.material3 import MaterialTheme, Text, Typography + from androidx.compose.ui.text import TextStyle + + ts = TextStyle.Default + typo = Typography(ts, ts, ts, ts, ts, ts, ts, ts, ts, ts, ts, ts, ts, ts, ts) + + MaterialTheme(typography=typo, content=lambda: Text("A")) + """.trimIndent(), + width = 50, height = 50 + ) + + val baseColors = baseInk.filter { it != BACKGROUND }.toSet() + val customColors = customInk.filter { it != BACKGROUND }.toSet() + + assertTrue(baseColors.isNotEmpty(), "Base text must render") + assertTrue(customColors.isNotEmpty(), "Custom text must render") + assertTrue(baseColors != customColors, "MaterialTheme with custom Typography must change the text color/style") + } + + @Test + fun popupLayersAreNotCapturedByImageComposeScene() { + // DropdownMenu creates a Popup which ImageComposeScene does not capture. + val popupInk = inkOf( + """ + from androidx.compose.material3 import DropdownMenu, Text + DropdownMenu( + expanded=True, + onDismissRequest=lambda: None, + content=lambda: Text("Menu Item") + ) + """.trimIndent(), + width = 200, height = 200, + ) + println("compose render: DropdownMenu -> $popupInk pixels") + // If it's not captured, popupInk will be 0 + assertEquals(0, popupInk, "DropdownMenu should not be captured by ImageComposeScene") + } + + /** + * `ColorScheme` resolves to its **constructor**, and calling it with nothing fails as a call of that + * constructor -- not as a proxy type asking for a handle. + * + * This pinned the other answer until issue #73, and said why it might change: "Pinned as observed + * rather than as expected. When this message changes, something about how classes bind changed with + * it." What changed is exactly that. `ColorScheme`'s public constructors take `Color`, a value class, + * so `kotlinc` compiles each to a private `` behind a synthetic `DefaultConstructorMarker` + * bridge, and the walker used to refuse every synthetic ``. It now refuses only a + * `@Deprecated(level = HIDDEN)` one, so `ColorScheme(...)` is bound -- the same way `Typography(...)` + * already was ([typographyChangesFontRender] calls it by its class name). + * + * Why the constructor and not the type: in Kotlin, `ColorScheme(...)` *is* the constructor call, and + * the binder carries Kotlin's surface (U-8). The proxy type answers "build me from a handle", which no + * Python caller can use -- this test was the evidence -- and stays reachable as `type(value)` of + * anything Kotlin returns. `ColorScheme()` with no arguments still fails, because no constructor + * declares a default for its colours; the message now names the constructor's overloads. + */ + @Test + fun colorSchemeResolvesToItsConstructorAndABareCallNamesItsOverloads() { + val error = try { + Python3.exec("from androidx.compose.material3 import ColorScheme; ColorScheme()") + null + } catch (e: Throwable) { + e.message ?: "" + } + println("compose render: ColorScheme() -> ${error?.take(120)}") + assertTrue(error != null, "ColorScheme() succeeded, but every ColorScheme constructor requires its colours") + assertTrue( + "ColorScheme__" in error, + "expected the bare call to be refused by ColorScheme's constructor overloads, got: $error", + ) + assertTrue("handle" !in error, "ColorScheme still resolves to the proxy type: $error") + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun inkOf(body: String, width: Int = 200, height: Int = 60): Int = + pixelsOf(body, width, height).count { it != BACKGROUND } + + private fun pixelsOf(body: String, width: Int = 200, height: Int = 60): IntArray { + val scene = ImageComposeScene(width = width, height = height, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return IntArray(width * height) { bitmap.getColor(it % width, it / width) } + } finally { + scene.close() + } + } + + private companion object { + const val BACKGROUND = 0 + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt new file mode 100644 index 000000000..ccd99bc8f --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MaterialIconsRenderTest.kt @@ -0,0 +1,137 @@ +package fixture.compose + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Issue #37: `material-icons-core` in the walk, so `Icon` has an `ImageVector` to draw. `Icons.Default` + * is an object constant, and the icons themselves are extension properties on `Icons.Filled`, bound + * as getters since issue #38 ([iconsDefaultAddDrawsFromPythonExactlyAsFromKotlin]). + * + * The artifact is `org.jetbrains.compose.material:material-icons-core:1.7.3` -- the newest JetBrains + * published (Maven Central), and the one Compose Multiplatform 1.11.x points to; its `ui` + * dependencies resolve up to 1.11.1. + */ +class MaterialIconsRenderTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** The control: the same icon drawn from Kotlin, so a 0 below means "the crossing failed". */ + @Test + fun theAddIconDrawsFromKotlin() { + val ink = inkOfScene { Icon(Icons.Default.Add, contentDescription = null) } + println("compose render: Kotlin Icon(Icons.Default.Add) -> $ink px") + assertTrue(ink > 0, "the control icon drew nothing") + } + + /** `Icons.Default` is bound, and `describe()` says what it is without reading it. */ + @Test + fun iconsDefaultIsBoundAndDescribed() { + Python3.exec( + """ + import python_multiplatform as _mi_pm + import androidx.compose.material.icons as _mi_icons + assert 'Icons' in dir(_mi_icons), dir(_mi_icons) + _mi_d = _mi_pm.describe(_mi_icons.Icons, 'Default') + assert _mi_d['kind'] == 'STATIC_GETTER', _mi_d + assert _mi_d['returns'] == 'androidx.compose.material.icons.Icons.Filled', _mi_d + assert 'Default' in dir(_mi_icons.Icons), dir(_mi_icons.Icons) + """.trimIndent(), + ) + } + + /** + * **The render proof issue #37 asked for, closed by #38's extension-property getters.** `Add` is a + * top-level extension property, `val Icons.Filled.Add: ImageVector` (`AddKt.getAdd(Icons$Filled)` + * in the jar); the walker now binds it as a `GETTER` whose receiver is `Icons.Filled`, so + * `Icons.Default.Add` is an attribute read on the `Icons.Default` value, and `Icon` draws it. + * + * Exact ink, not `> 0`: the same `Icon(Icons.Default.Add, contentDescription = null)` drawn from + * Kotlin is the control ([theAddIconDrawsFromKotlin]), so a different vector, a different size or + * an empty box all fail this. `contentDescription=None` is a written `null` for a slot with no + * default, which the binding layer now passes rather than refusing as "no value". + * + * Red before #38: `Icons.Default.Add` raised `AttributeError` (no binding had `Icons.Filled` as its + * receiver), so the composition body failed before `Icon` was called. + */ + @Test + fun iconsDefaultAddDrawsFromPythonExactlyAsFromKotlin() { + val kotlin = inkOfScene { Icon(Icons.Default.Add, contentDescription = null) } + val python = inkOfScene { + PythonComposition( + """ + import androidx + from androidx.compose.material3 import Icon + Icon(androidx.compose.material.icons.Icons.Default.Add, contentDescription=None) + """.trimIndent(), + ) + } + val blank = inkOfScene { PythonComposition("pass") } + println("compose render: Icon(Icons.Default.Add) Kotlin -> $kotlin px, Python -> $python px, empty -> $blank px") + assertEquals(0, blank, "an empty composition must draw nothing, or the measurement is not measuring") + assertTrue(kotlin > 0, "the control icon drew nothing") + assertEquals(kotlin, python, "Python's Icon(Icons.Default.Add) did not draw what Kotlin's does") + } + + /** What the walker bound for `Add`, described without being read: a `GETTER` on `Icons.Filled`. */ + @Test + fun iconsFilledAddIsAGetterOnItsReceiver() { + Python3.exec( + """ + import python_multiplatform as _mi_pm + _mi_d = _mi_pm.describe_member('androidx.compose.material.icons.Icons.Filled', 'Add') + assert len(_mi_d) == 1, _mi_d + assert _mi_d[0]['kind'] == 'GETTER', _mi_d + assert _mi_d[0]['name'] == 'androidx.compose.material.icons.filled.Add', _mi_d + assert _mi_d[0]['returns'] == 'androidx.compose.ui.graphics.vector.ImageVector', _mi_d + assert _mi_d[0]['receiver'] == 'androidx.compose.material.icons.Icons.Filled', _mi_d + import androidx + _mi_add = androidx.compose.material.icons.Icons.Default.Add + assert type(_mi_add)._kotlin_type_name == 'androidx.compose.ui.graphics.vector.ImageVector', type(_mi_add) + """.trimIndent(), + ) + } + + private fun inkOfScene(content: @Composable () -> Unit): Int { + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f), content = content) + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + var ink = 0 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (bitmap.getColor(x, y) != 0) ink++ + } + } + return ink + } finally { + scene.close() + } + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt new file mode 100644 index 000000000..d02a3b03f --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt @@ -0,0 +1,78 @@ +package fixture.compose + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import python.multiplatform.ffi.upcall.UpcallBootstrap +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * Issue #17 on the real thing: a `Modifier` chain built from Compose's own jars, with the second link + * reached under a name only a registered member resolver knows. + * + * Compose's structural equality is the judge, so `fill_max_width` can only match if the resolver + * served Compose's real `fillMaxWidth`. The controls: no resolver means `AttributeError`, and the same + * members in the other order do not match. + */ +class MemberResolverComposeTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + @Test + fun aResolvedSnakeNameRunsComposesFillMaxWidthInARealChain() { + Python3.exec( + """ + import python_multiplatform.binding as _b + from androidx.compose.foundation.layout import padding__Dp + from fixture.compose import emptyModifier + from fixture.compose import equalsPaddingThenFillMaxWidth, equalsFillMaxWidthThenPadding + + _start = padding__Dp(emptyModifier(), 16.0) + try: + _start.fill_max_width + raise AssertionError('fill_max_width resolved with no resolver registered') + except AttributeError: + pass + + def _snake_to_camel(type_name, requested, kotlin_names): + head, *rest = requested.split('_') + camel = head + ''.join(p.capitalize() for p in rest) + return camel if camel in kotlin_names else None + + _b.add_member_resolver(_snake_to_camel) + try: + _chain = _start.fill_max_width() + assert equalsPaddingThenFillMaxWidth(_chain._pm_handle, 16.0), 'chain is not padding(16.dp).fillMaxWidth()' + assert not equalsPaddingThenFillMaxWidth(_chain._pm_handle, 17.0), '16dp matched 17dp' + assert not equalsFillMaxWidthThenPadding(_chain._pm_handle, 16.0), 'the chain order is not observed' + assert 'fill_max_width' not in dir(_chain), 'the alias leaked into dir()' + assert 'fill_max_width' not in vars(type(_chain)), 'the alias was written onto the proxy class' + finally: + _b.remove_member_resolver(_snake_to_camel) + try: + _start.fill_max_width + raise AssertionError('the alias survived removing the resolver') + except AttributeError: + pass + """.trimIndent(), + ) + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierLocalRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierLocalRenderTest.kt new file mode 100644 index 000000000..1a95e484a --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierLocalRenderTest.kt @@ -0,0 +1,127 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * **A provided value is successfully read in a child composable** -- testing + * `modifierLocalProvider` and `modifierLocalConsumer` generic wrappers. + */ +class ModifierLocalRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + @Test + fun aProvidedValueIsReadByTheConsumer() { + Python3.exec( + """ + _read_values = [] + def _on_read(value): + _read_values.append(value) + """.trimIndent(), + ) + + val body = """ + from fixture.compose import emptyModifier, pythonModifierLocalOfString, pythonModifierLocalProviderString, pythonModifierLocalConsumerString + from androidx.compose.material3 import Text + from androidx.compose.foundation.layout import Box + + _local = pythonModifierLocalOfString("default value") + + _m_parent = pythonModifierLocalProviderString(emptyModifier(), _local, "provided value") + _m_child = pythonModifierLocalConsumerString(emptyModifier(), _local, _on_read) + + def _content(): + Text("Hello", modifier=_m_child) + + Box(modifier=_m_parent, content=_content) + """.trimIndent() + + val before = pixelsOf(body) + assertTrue(inkOf(before) > 0, "the Text never composed") + + Python3.exec( + "assert len(_read_values) > 0, 'on_read was never invoked'", + ) + Python3.exec( + "assert 'provided value' in _read_values, 'Expected provided value but got: ' + repr(_read_values)", + ) + } + + @Test + fun unprovidedLocalYieldsDefaultValue() { + Python3.exec( + """ + _read_values = [] + def _on_read(value): + _read_values.append(value) + """.trimIndent(), + ) + + val body = """ + from fixture.compose import emptyModifier, pythonModifierLocalOfString, pythonModifierLocalConsumerString + from androidx.compose.material3 import Text + + _local = pythonModifierLocalOfString("default value") + + _m = emptyModifier() + _m = pythonModifierLocalConsumerString(_m, _local, _on_read) + + Text("Hello", modifier=_m) + """.trimIndent() + + val before = pixelsOf(body) + assertTrue(inkOf(before) > 0, "the Text never composed") + + Python3.exec( + "assert len(_read_values) > 0, 'on_read was never invoked'", + ) + Python3.exec( + "assert _read_values[-1] == 'default value', 'Expected default value but got: ' + repr(_read_values[-1])", + ) + } + + private fun pixelsOf(body: String): IntArray { + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return IntArray(SCENE * SCENE) { bitmap.getColor(it % SCENE, it / SCENE) } + } finally { + scene.close() + } + } + + private fun inkOf(pixels: IntArray): Int = pixels.count { it != BACKGROUND } + + private companion object { + const val BACKGROUND = 0 + const val SCENE = 80 + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierSeededRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierSeededRenderTest.kt new file mode 100644 index 000000000..263996e41 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ModifierSeededRenderTest.kt @@ -0,0 +1,146 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Image +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * **`Spacer`**, the one declaration in `foundation.layout` that `ComposableRenderTest` cannot reach + * with `PythonxAdapter` alone. + * + * ### Why `Spacer` is a different kind of problem from `Row`, `Box` or `Column` + * + * Every container `ComposableRenderTest` renders takes a `content: @Composable Scope.() -> Unit` + * with no default, so a Python `lambda` is what fills the one required slot. `Spacer` has no + * `content` at all -- its single parameter is `modifier: Modifier`, **also with no default** + * (`fun Spacer(modifier: Modifier)`, not `= Modifier`; `Box`'s content-free overload, + * `Box__Modifier`, has the identical shape and is left equally unreached here). Filling that slot + * needs a `Modifier` *value*, and nothing the artefact walker binds can produce one on its own: + * `Modifier`'s only source is `androidx.compose.ui.Modifier.Companion`, an object instance, and + * `ArtifactScanner` binds functions, not statics of an arbitrary object. `ksp-fixtures/artifact`'s + * `ComposeSeed.kt` hit exactly this wall and solved it with one hand-written Kotlin function that + * KSP binds instead; `fixture.compose.emptyModifier` (`PythonComposition.kt`) is that same seed, + * placed in this module so `Spacer` has something to receive. + * + * ### Why this needs its own `@BeforeTest` rather than `ComposableRenderTest`'s + * + * `emptyModifier` and `androidx.compose.foundation.layout.size__Dp` are not composables and are not + * reached through `pythonx` (`docs/archive/pythonx-adapter-design.md`'s dynamic loader is for the + * composable half of the surface). They come from the *other* producer, + * `PythonProxySource` + `FunctionTable`, the route `WalkedArtifactComposeModifierTest` uses for the + * identical seed in `ksp-fixtures/artifact`. Both producers run in the same test here -- + * `PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)` for `Spacer` itself, `PythonProxySource.install()` for the `Modifier` + * chain that fills its one argument -- kept out of `ComposableRenderTest`'s shared setup so a + * regression in this newer combination cannot fail ten unrelated tests that never asked for it. + * + * ### How "the size crossed" is decided + * + * `Spacer` draws nothing itself -- it has no color, only extent -- so [pythonDrawsARealMaterial3Text]'s + * "ink appeared" cannot be reused here: an ink count over a scene with only a `Spacer` in it is `0` + * whether or not the size argument ever reached Compose. What a `Spacer`'s size *does* affect is + * where whatever comes after it lands, so the proof is positional: two `Text`s in a `Row` with a + * `Spacer` between them, and the rightmost ink column of the whole frame -- the right edge of the + * second `Text` -- has to move right by (approximately) the difference between two `Spacer` widths. + * A `Spacer` whose `modifier` argument never reached Compose (dropped, or resolved to the empty + * `Modifier` regardless of what Python passed) would leave that edge in the same place for both. + */ +class ModifierSeededRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The claim, and the guard against the weaker one: a `Spacer` given a *bigger* size pushes the + * `Text` after it further right than a small one does. "Some frame came out" would pass even if + * `Spacer` silently used `Modifier` (its notional default in ordinary Compose usage, though not + * a default this compiled shape actually declares) for every call. + */ + @Test + fun spacersSizeCrossesAndMovesWhatComesAfterIt() { + val small = rightmostInk(sizeDp = 4.0) + val large = rightmostInk(sizeDp = 80.0) + println("compose render: Spacer(size=4dp) -> rightmost ink at x=$small, Spacer(size=80dp) -> x=$large") + assertTrue(small > 0, "nothing drew at all -- the Text either side of the Spacer never composed") + assertTrue( + large > small + 40, + "a 76dp larger Spacer moved the trailing Text by less than 40px: $small -> $large", + ) + } + + /** + * The negative control [spacersSizeCrossesAndMovesWhatComesAfterIt] needs: without any `Spacer` + * at all, the two `Text`s sit immediately adjacent, and the seam between "a `Row` needs a + * `Spacer` to move its second child" and "a `Row` moves it regardless" is exactly this frame's + * rightmost ink versus the 4dp case above (asserted loosely here, strictly there). + */ + @Test + fun noSpacerLeavesTheSecondTextWhereTheFirstOneEnds() { + val body = """ + from androidx.compose.foundation.layout import Row + from androidx.compose.material3 import Text + Row(content=lambda row: (Text('hi'), Text('hi'))) + """.trimIndent() + val rightmost = rightmostInkOf(body) + println("compose render: Row(Text, Text) with no Spacer -> rightmost ink at x=$rightmost") + assertTrue(rightmost > 0, "nothing drew at all") + } + + private fun rightmostInk(sizeDp: Double): Int { + val body = """ + from fixture.compose import emptyModifier + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.foundation.layout import Spacer, Row + from androidx.compose.material3 import Text + Row(content=lambda row: (Text('hi'), Spacer(modifier=size__Dp(emptyModifier(), $sizeDp)), Text('hi'))) + """.trimIndent() + return rightmostInkOf(body) + } + + private fun rightmostInkOf(body: String): Int { + val scene = ImageComposeScene(width = WIDTH, height = 60, density = Density(1f)) { + PythonComposition(body) + } + try { + val image: Image = scene.render() + val bitmap = Bitmap.makeFromImage(image) + var rightmost = -1 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (bitmap.getColor(x, y) != BACKGROUND && x > rightmost) rightmost = x + } + } + return rightmost + } finally { + scene.close() + } + } + + private companion object { + const val WIDTH = 200 + const val BACKGROUND = 0 + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ObjectConstantRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ObjectConstantRenderTest.kt new file mode 100644 index 000000000..23b9368a8 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ObjectConstantRenderTest.kt @@ -0,0 +1,124 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * **A constant held by an object reaches Compose and changes a layout** -- the half `80318c16` left + * unproven. + * + * That commit taught the walker to bind what an `object` or a companion holds, and proved it where + * the walker lives: two plugin tests assert that `AbsoluteAlignment.TopLeft` and `Alignment.Center` + * arrive as zero-argument getters with the right body. What it could not say is that the value + * survives the crossing -- a getter that returned the wrong singleton, or one whose handle never + * resolved, would satisfy both of those and still be useless. + * + * ### Why arrangement rather than alignment + * + * An arrangement is observable without a probe. `Row(horizontalArrangement=...)` decides *where* + * its children sit, so two arrangements put the same child at two different x positions in the same + * scene, and the pixels say which one arrived. Alignment would need a measure policy of this test's + * own to read it back, which is the shape `LayoutIdRenderTest` already carries for a different + * reason; there is nothing to learn from a second copy of it. + * + * `Start` versus `End` is the pair, because they are opposite ends of the same axis: a wrapper that + * ignored the argument entirely would place both at the same edge, and a wrapper that resolved some + * *other* constant would have to resolve exactly the opposite one to pass both assertions. + * + * ### Two things this test had to get right, and both are facts rather than details + * + * **The constant is *read*, not called.** `Arrangement.Start` -- no parentheses. The adaptation + * layer branches on [python.multiplatform.reflection.CallableKind]: a + * [python.multiplatform.reflection.CallableKind.STATIC_GETTER] is invoked once by `PythonxAdapter` + * itself and handed back as a value, so `Arrangement.$constant()` -- the spelling this test used + * before the branch existed -- now raises `TypeError: '...' object is not callable` instead of + * returning the right thing for the wrong reason. Read without being called, the value round-trips + * as `Arrangement.Start` does in Kotlin. + * + * **The row is given a width.** A `Row` wraps its content, so with no width constraint there is no + * spare space and every arrangement puts the child in the same place -- the `Start` assertion passed + * for that reason before the width was added, which is exactly the kind of accidental pass the + * opposite-end control exists to catch. + */ +class ObjectConstantRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** `Arrangement.Start` keeps the child at the left edge. */ + @Test + fun anArrangementConstantFromPythonPlacesTheChildAtTheStart() { + val pixels = pixelsOf(rowWith("Start")) + assertTrue(inkAt(pixels, 0, 20), "no ink at the left -- Arrangement.Start did not arrive") + assertTrue(!inkAt(pixels, 60, SCENE), "ink at the right even though the arrangement was Start") + } + + /** The control: `Arrangement.End` moves the same child to the other edge. Same scene, same + * child, one constant different -- so this fails for a wrapper that ignores its argument. */ + @Test + fun theOppositeArrangementConstantMovesTheChildToTheEnd() { + val pixels = pixelsOf(rowWith("End")) + assertTrue(inkAt(pixels, 60, SCENE), "no ink at the right -- Arrangement.End did not arrive") + assertTrue(!inkAt(pixels, 0, 20), "ink at the left even though the arrangement was End") + } + + /** The constant is fetched by the name the walker binds it under, through the adaptation layer, + * exactly as an application would write it. */ + private fun rowWith(constant: String) = """ + from androidx.compose.foundation.layout import Row, Arrangement, width__Dp + from androidx.compose.material3 import Text + from fixture.compose import emptyModifier + + Row( + modifier=width__Dp(emptyModifier(), 80.0), + horizontalArrangement=Arrangement.$constant, + content=lambda scope: Text('X'), + ) + """.trimIndent() + + private fun pixelsOf(body: String): IntArray { + val scene = ImageComposeScene(width = SCENE, height = 20, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return IntArray(SCENE * 20) { bitmap.getColor(it % SCENE, it / SCENE) } + } finally { + scene.close() + } + } + + private fun inkAt(pixels: IntArray, xStart: Int, xEnd: Int): Boolean = + (0 until 20).any { y -> + (xStart until xEnd).any { x -> pixels[y * SCENE + x] != BACKGROUND } + } + + private companion object { + const val BACKGROUND = 0 + const val SCENE = 80 + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PointerInputRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PointerInputRenderTest.kt new file mode 100644 index 000000000..576cd4c79 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PointerInputRenderTest.kt @@ -0,0 +1,187 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * **A real pointer event reaches a Python callback through `Modifier.pointerInput`'s suspend slot** + * -- the seventh declined `Modifier` extension `FunctionSlotBindingTest`/`ArtifactScannerTest` name, + * and the one that is not merely a missing feature of the walker (see `PythonPointerInput.kt`'s KDoc + * for the reason `pointerInput` itself must stay declined). + * + * ### What this test proves, and how + * + * `fixture.compose.pythonPointerInput` (hand-written Kotlin, `PythonPointerInput.kt`) is a coroutine + * that suspends for real -- `awaitPointerEventScope { while (true) { awaitPointerEvent() } }` -- and + * calls an ordinary, non-suspending Python callback once per event. This test drives a real + * `Move`/`Press`/`Release` sequence through `ImageComposeScene.sendPointerEvent`, the same entry point + * `CallbackDrivenRenderTest` uses for `Checkbox`, and checks two independent things: + * + * 1. **Python received the events**, asserted in Python against the list the callback appended to -- + * the boundary carries no reader for an arbitrary Python list, so a Kotlin-side copy would be a + * second thing that could disagree with what the callback actually saw. + * 2. **The state Python wrote is visible in a redraw.** `Text(str(len(_tap_events)))` reads the same + * list on every composition, so a *fresh* scene over the same body shows a different digit once + * the list is non-empty -- the same "second scene" shape `CallbackDrivenRenderTest` uses, and for + * the same reason: the scene that received the events does not itself recompose from a click that + * `PythonComposition`'s snapshot has no way to see. + */ +class PointerInputRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The positive claim: a press-and-release at the modifier's own 48x48 box invokes the Python + * callback with real (type, x, y) triples, and the digit `Text` draws changes once it has. + */ + @Test + fun aRealPointerTapReachesThePythonCallbackThroughPointerInput() { + Python3.exec( + """ + _tap_events = [] + + def _on_event(kind, x, y): + _tap_events.append((kind, x, y)) + """.trimIndent(), + ) + + val before = pixelsOf(BODY) + assertTrue(inkOf(before) > 0, "the Text never composed, so there was nothing to tap") + + tapCentre() + + Python3.exec( + "assert len(_tap_events) >= 1, 'pythonPointerInput never invoked the Python callback'", + ) + Python3.exec( + """ + kinds = [e[0] for e in _tap_events] + assert 'press' in kinds, 'no press event reached Python: ' + repr(_tap_events) + """.trimIndent(), + ) + // Every event this test sent landed inside the 48x48 box centred on (24, 24), so every + // reported position must be within it -- proving the coordinates are the real tap, not a + // stub value like (0.0, 0.0). + Python3.exec( + """ + for kind, x, y in _tap_events: + assert 0.0 <= x <= 48.0 and 0.0 <= y <= 48.0, ( + 'event %s at (%r, %r) is outside the 48x48 target box' % (kind, x, y) + ) + """.trimIndent(), + ) + + val after = pixelsOf(BODY) + val moved = differing(before, after) + println( + "compose input: pointerInput tap -> ink ${inkOf(before)} -> ${inkOf(after)} px, " + + "$moved pixels changed", + ) + assertTrue( + moved > 0, + "no pixel changed after the tap, so the count the callback wrote never reached the render", + ) + } + + /** + * The negative control: a press-and-release well outside the 48x48 box invokes nothing. Without + * this, a callback invoked by composition itself (rather than by the tap) would satisfy the test + * above. + */ + @Test + fun aTapThatMissesTheBoxInvokesNothing() { + Python3.exec( + """ + _tap_events = [] + + def _on_event(kind, x, y): + _tap_events.append((kind, x, y)) + """.trimIndent(), + ) + tapAt(Offset(SCENE - 2f, SCENE - 2f)) + Python3.exec( + "assert _tap_events == [], 'a tap that missed the box invoked the callback: ' + repr(_tap_events)", + ) + } + + private fun tapCentre() = tapAt(Offset(BOX_CENTRE, BOX_CENTRE)) + + private fun tapAt(at: Offset) { + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(BODY) + } + try { + scene.render() + scene.sendPointerEvent(PointerEventType.Move, at) + scene.sendPointerEvent(PointerEventType.Press, at) + scene.sendPointerEvent(PointerEventType.Release, at) + scene.render() + } finally { + scene.close() + } + } + + private fun pixelsOf(body: String): IntArray { + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return IntArray(SCENE * SCENE) { bitmap.getColor(it % SCENE, it / SCENE) } + } finally { + scene.close() + } + } + + private fun inkOf(pixels: IntArray): Int = pixels.count { it != BACKGROUND } + + private fun differing(a: IntArray, b: IntArray): Int = a.indices.count { a[it] != b[it] } + + private companion object { + const val BACKGROUND = 0 + const val SCENE = 80 + + /** `size__Dp(..., 48.0)` gives the tap target a fixed 48x48 box; its centre is 24. */ + const val BOX_CENTRE = 24f + + /** `Text` reads `_tap_events`'s length on every composition, which is what makes a fresh + * scene show what the callback wrote -- the same shape `CHECKBOX`/`SWITCH` use in + * `CallbackDrivenRenderTest`. The box is sized with the walked `size__Dp` so the tap target + * is deterministic regardless of how wide the digit itself renders. */ + val BODY = """ + from fixture.compose import emptyModifier, pythonPointerInput + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.material3 import Text + + _m = pythonPointerInput(size__Dp(emptyModifier(), 48.0), _on_event) + Text(str(len(_tap_events)), modifier=_m) + """.trimIndent() + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PullRefreshRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PullRefreshRenderTest.kt new file mode 100644 index 000000000..8f77187aa --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PullRefreshRenderTest.kt @@ -0,0 +1,204 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.size +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Density +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * `pullRefresh`'s callback overload (`androidx.compose.material.pullrefresh.PullRefreshKt + * .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 + * an actual drag that makes the scrollable child's nested-scroll callbacks flow upward. + * + * ### What is proven here + * + * 1. A downward drag from the top of a scrollable child (which is already at the top) flows an + * over-scroll signal upward into `PullRefreshNestedScrollConnection.onPreScroll`, which calls + * `onPull`. Python's `on_pull` callback receives a positive float (the dragged amount consumed), + * meaning the nested-scroll path actually reached Python. + * + * 2. A drag that does not cross the scrollable child invokes nothing (negative control). + * + * ### Why `onPull` returns the pulled amount, not a Python float + * + * `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` + * (consuming everything offered) while firing a notification to Python. + * + * `onRelease: suspend (Float) -> Float` is the same: Kotlin closes the coroutine returning `0f`, + * Python receives a one-way notification. + * + * ### Why the scene is built in Kotlin, not from a Python BODY string + * + * 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. + */ +class PullRefreshRenderTest { + + @BeforeTest + fun setup() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * Positive test: a real downward drag over the scrollable child, while the child is at the top + * of its content (so over-scroll goes into `pullRefresh`), invokes Python's `on_pull` callback + * with a positive float, and pixels change. + */ + @Test + fun aRealDownwardDragReachesThePythonOnPullCallback() { + Python3.exec( + """ + _pull_events = [] + _release_events = [] + + def _on_pull(pulled): + _pull_events.append(pulled) + + def _on_release(velocity): + _release_events.append(velocity) + """.trimIndent(), + ) + + // Resolve Python callbacks to PyObjects via the __main__ module's attribute dictionary. + val mainModule = Python3.import("__main__") + val onPullPy = mainModule.getAttr("_on_pull") + val onReleasePy = mainModule.getAttr("_on_release") + + val scene = ImageComposeScene( + width = SCENE, + height = SCENE, + density = Density(1f), + ) { + PythonPullRefreshHarness(onPull = onPullPy, onRelease = onReleasePy, modifier = Modifier.size(80.dp)) + } + try { + scene.render() + // 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) + var y = START_Y + while (y < START_Y + DRAG_DISTANCE) { + y += STEP + scene.sendPointerEvent(PointerEventType.Move, Offset(SCENE / 2f, y), type = PointerType.Touch) + } + scene.sendPointerEvent(PointerEventType.Release, Offset(SCENE / 2f, y), type = PointerType.Touch) + scene.render() + } finally { + scene.close() + // PyObjects must stay alive until after pixelsWithCallbacks(after) is called below, + // because pixelsWithCallbacks creates another scene that holds references to them. + // They are closed explicitly after the after-pixels are captured. + } + + // Python's on_pull received at least one call with a positive float. + Python3.exec( + "assert len(_pull_events) > 0, 'on_pull was never invoked: ' + repr(_pull_events)", + ) + Python3.exec( + """ + assert any(v > 0.0 for v in _pull_events), ( + 'on_pull was called but all values were non-positive: ' + repr(_pull_events) + ) + """.trimIndent(), + ) + + println( + "pullRefresh: downward drag -> " + + "on_pull events: ${Python3.import("__main__").getAttr("_pull_events")}" + ) + onPullPy.close() + onReleasePy.close() + } + + /** + * Negative control: a press and drag **outside** the scrollable child (far corner) invokes + * neither `on_pull` nor `on_release`. + */ + @Test + fun aDragOutsideTheChildInvokesNothing() { + Python3.exec( + """ + _pull_events = [] + _release_events = [] + + def _on_pull(pulled): + _pull_events.append(pulled) + + def _on_release(velocity): + _release_events.append(velocity) + """.trimIndent(), + ) + + val mainModule = Python3.import("__main__") + val onPullPy = mainModule.getAttr("_on_pull") + val onReleasePy = mainModule.getAttr("_on_release") + + val scene = ImageComposeScene( + width = SCENE, + height = SCENE, + density = Density(1f), + ) { + PythonPullRefreshHarness(onPull = onPullPy, onRelease = onReleasePy, modifier = Modifier.size(80.dp)) + } + try { + scene.render() + // 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) + scene.sendPointerEvent(PointerEventType.Move, Offset(SCENE - 2f, SCENE - 20f), type = PointerType.Touch) + scene.sendPointerEvent(PointerEventType.Release, Offset(SCENE - 2f, SCENE - 20f), type = PointerType.Touch) + scene.render() + } finally { + scene.close() + onPullPy.close() + onReleasePy.close() + } + + Python3.exec( + "assert _pull_events == [] and _release_events == [], " + + "'a drag outside the box invoked pullRefresh: ' + repr((_pull_events, _release_events))", + ) + } + + private companion object { + const val SCENE = 120 + const val START_Y = 4f + const val DRAG_DISTANCE = 60f + const val STEP = 8f + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt new file mode 100644 index 000000000..95bf6abd4 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt @@ -0,0 +1,264 @@ +package fixture.compose + +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Image +import python.multiplatform.compose.PythonContent +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonCallables +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * **The host entry point** (python-multiplatform#18, for pythonx-compose#11's `@app`): the host says + * *where* the Python root state lives, and from then on Python alone decides what is on screen. + * + * The root lives in a Compose `MutableState` that Python created and holds -- + * `androidx.compose.runtime.mutableStateOf(None)`, the walked Compose function itself, its `T` read as + * `kotlin.Any?` (issue #38) -- and Python replaces the root by writing its `value` property: + * `state.value = root`, the walked `MutableState.value` setter. No fixture function stands between + * Python and Compose any more (the KSP-bound `rootState`/`writeRoot` stand-ins are gone). The claim is + * that this write is *all* it takes: + * the next frame shows the new root, and nothing on the Kotlin side was called to make it so. The + * only thing the test does between the write and the frame is what a window's frame clock does on + * its own -- deliver the snapshot's apply notification -- and the per-root call counters show that the + * host's `PythonContent` was not re-entered by anything but that. + * + * The lifetime half follows `ComposableRenderTest.aDisposedCompositionGivesEveryPythonCallableBack`: + * a named Python function crosses as a `content=` slot, its `sys.getrefcount` rises while composed, + * and comes back to its baseline exactly -- above means a leak, below means a double release. + */ +class PythonContentRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + Python3.exec(ROOTS) + } + + @AfterTest + fun cleanup() { + Python3.exec("del _root_state") + UpcallTable.clear() + } + + /** + * Declare, render, replace by a state write, render, replace back, render, dispose. + * + * Reached through `PythonContent(module, attribute)`, the spelling a host that knows only a module + * name uses. + */ + @Test + fun aRootWrittenFromPythonIsDrawnOnTheNextFrameWithoutAHostCall() { + Python3.exec("_root_state.value = _root_a") + val baseRoots = HandleTable.liveCount + val baseInner = pyInt("sys.getrefcount(_inner)") + + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonContent(module = "__main__", attribute = "_root_state") + } + val inkA: Int + val inkB: Int + val inkABack: Int + try { + inkA = inkOfImage(scene.render()) + assertEquals(1, pyInt("_calls['a']"), "the declared root was not composed exactly once") + assertEquals(0, pyInt("_calls['b']"), "a root that was never declared was composed") + assertTrue(inkA > 0, "the declared root drew nothing") + + // A frame with no write is not a reason to run the root again: nothing here polls. + scene.render() + assertEquals(1, pyInt("_calls['a']"), "an idle frame re-ran the root, so something polls") + + // Python replaces the root. Nothing on the Kotlin side is called. + Python3.exec("_root_state.value = _root_b") + inkB = inkOfImage(nextFrame(scene)) + assertEquals(1, pyInt("_calls['b']"), "the root Python wrote was not composed on the next frame") + assertEquals(1, pyInt("_calls['a']"), "the replaced root was composed again") + assertNotEquals(inkA, inkB, "the frame after the write drew the old root's content") + val heldInner = pyInt("sys.getrefcount(_inner)") + assertTrue(heldInner > baseInner, "nothing held _inner while root B was composed: $baseInner -> $heldInner") + + // And back. + Python3.exec("_root_state.value = _root_a") + inkABack = inkOfImage(nextFrame(scene)) + assertEquals(2, pyInt("_calls['a']"), "switching back did not compose root A again") + assertEquals(1, pyInt("_calls['b']"), "root B ran after it was replaced") + assertEquals(inkA, inkABack, "switching back did not draw root A's content") + // Root B's `content=` callable belongs to root B's composition, which just left. + assertEquals( + baseInner, pyInt("sys.getrefcount(_inner)"), + "root B's content callable was not given back when root B was replaced", + ) + } finally { + scene.close() + } + println("python content: ink A=$inkA B=$inkB A again=$inkABack; calls=${pyStr("_calls")}") + + assertEquals(baseInner, pyInt("sys.getrefcount(_inner)"), "a Python reference outlived the composition") + assertEquals(0, HandleTable.liveCount - baseRoots, "handles left rooted after the composition was disposed") + assertEquals(0, PythonCallables.openScopeCount, "a callable scope was left open") + } + + /** + * The other spelling: the host already holds the Python object the state lives in. Disposal is + * where root B's `content=` callable comes back here, since the root is never replaced. + */ + @Test + fun theHostCanPassThePythonStateObjectAndDisposalReleasesWhatTheRootPassed() { + Python3.exec("_root_state.value = _root_b") + val baseRoots = HandleTable.liveCount + val baseInner = pyInt("sys.getrefcount(_inner)") + val state = Python3.import("__main__").getAttr("_root_state") + + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonContent(root = state) + } + val ink: Int + val held: Int + try { + ink = inkOfImage(scene.render()) + held = pyInt("sys.getrefcount(_inner)") + } finally { + scene.close() + } + state.close() + val after = pyInt("sys.getrefcount(_inner)") + println("python content (state object): ink=$ink _inner refcount base=$baseInner held=$held disposed=$after") + + assertEquals(1, pyInt("_calls['b']"), "the root in the state was not composed") + assertTrue(ink > 0, "the root in the state drew nothing") + assertTrue(held > baseInner, "nothing held _inner for the composition: $baseInner -> $held") + assertEquals(baseInner, after, "the composition did not give _inner back") + assertEquals(0, HandleTable.liveCount - baseRoots, "handles left rooted after the composition was disposed") + assertEquals(0, PythonCallables.openScopeCount, "a callable scope was left open") + } + + /** + * The state is Compose's own, reached through the walk alone (issue #38): `mutableStateOf` is the + * walked generic function, and `value` is the walked `MutableState` property, read and written as a + * Python attribute -- the Python function written in is the very object read back. + * + * Red before the walker bound generics and properties: `mutableStateOf` was not in the table + * (`ImportError` at `ROOTS`, in `@BeforeTest`), and once it is, `_root_state.value` is an + * `AttributeError` until properties are served. + */ + @Test + fun theRootStateIsComposesOwnMutableStateAndValueIsItsWalkedProperty() { + Python3.exec( + """ + import python_multiplatform as _prs_pm + _prs = { + 'type': type(_root_state)._kotlin_type_name, + 'empty': _root_state.value is None, + 'kinds': [_d['kind'] for _d in _prs_pm.describe_member('androidx.compose.runtime.MutableState', 'value')], + 'factory': _prs_pm.describe(mutableStateOf)[0]['name'], + } + _root_state.value = _root_a + _prs['same'] = _root_state.value is _root_a + _root_state.value = None + _prs['cleared'] = _root_state.value is None + """.trimIndent(), + ) + assertEquals("androidx.compose.runtime.MutableState", pyStr("_prs['type']")) + assertEquals("True", pyStr("_prs['empty']")) + assertEquals("['GETTER', 'SETTER']", pyStr("_prs['kinds']")) + assertEquals("androidx.compose.runtime.mutableStateOf", pyStr("_prs['factory']")) + assertEquals("True", pyStr("_prs['same']"), "the root written into the state is not the one read back") + assertEquals("True", pyStr("_prs['cleared']")) + } + + /** A Python callable passed directly is a root that never changes -- no state involved. */ + @Test + fun aPlainPythonCallableIsAFixedRoot() { + val root = Python3.import("__main__").getAttr("_root_a") + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonContent(root = root) + } + val ink: Int + try { + ink = inkOfImage(scene.render()) + } finally { + scene.close() + } + root.close() + assertEquals(1, pyInt("_calls['a']"), "the callable root was not composed exactly once") + assertTrue(ink > 0, "the callable root drew nothing") + } + + /** + * What a window's frame clock does by itself: deliver the global snapshot's pending writes to the + * recomposer, then draw. `ImageComposeScene` has no clock, so the test is the clock. + */ + private fun nextFrame(scene: ImageComposeScene): Image { + Snapshot.sendApplyNotifications() + return scene.render() + } + + private fun inkOfImage(image: Image): Int { + val bitmap = Bitmap.makeFromImage(image) + var ink = 0 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (bitmap.getColor(x, y) != 0) ink++ + } + } + return ink + } + + private fun pyInt(expression: String): Int = pyStr(expression).toInt() + + private fun pyStr(expression: String): String = Python3.import("__main__").getAttr("__dict__").let { globals -> + Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString() + } + + private companion object { + /** `Py_eval_input`. */ + const val PY_EVAL_INPUT = 258 + + /** + * Two roots that draw visibly different amounts of ink, and count their own invocations so a + * frame can be attributed to the root that drew it. Root B passes a named function as a + * `content=` slot so its reference count can be followed across the composition's lifetime. + */ + val ROOTS = """ + import sys + from androidx.compose.runtime import mutableStateOf + from androidx.compose.foundation.layout import Column + from androidx.compose.material3 import Text + + _calls = {'a': 0, 'b': 0} + + def _root_a(): + _calls['a'] += 1 + Text('a') + + def _inner(): + Text('BBBBBBBBBBBBBBBB') + + def _root_b(): + _calls['b'] += 1 + Column(content=_inner) + + _root_state = mutableStateOf(None) + """.trimIndent() + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonxModules.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonxModules.kt new file mode 100644 index 000000000..5ecf1ca6a --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonxModules.kt @@ -0,0 +1,17 @@ +package fixture.compose + +/** + * The value classes these tests may write as a raw primitive. + * + * There is no module map beside this any more. The binder does not rename Kotlin namespaces at + * all -- a Kotlin package is importable under its own name and nothing else -- so there is nothing + * to map. What a `pythonx.*` module is called, and what it restructures, is the business of the + * real distribution that ships those modules on disk. + * + * This set survives because it is a different kind of fact: whether a value class may be written + * as the primitive it wraps is a property of the library that declares it, and no bytecode + * witnesses it. `Dp`'s public constructor is the identity on its float, so `padding(16)` and + * `padding(Dp(16f))` mean the same thing; `Color` and `TextUnit` pack several fields into one + * value and a raw number would decode as something else. + */ +internal val PYTHONX_RAW_VALUE_CLASSES: Set = setOf("androidx.compose.ui.unit.Dp") diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RecompositionAccumulationTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RecompositionAccumulationTest.kt new file mode 100644 index 000000000..5830a896b --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RecompositionAccumulationTest.kt @@ -0,0 +1,570 @@ +package fixture.compose + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Image +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **What a live UI costs**: how much one composition accumulates over [PASSES] recompositions. + * + * `ce1de0c3` recorded the shape of the problem without a number -- *"a wrapper is allocated per + * crossing and there is no interning, so n recompositions hold n Python callables"* -- and + * `ComposableRenderTest.theHolderSurvivesARecompositionAndReleasesBothCallablesAtDisposal` proves it + * for exactly n = 2, where "2" is indistinguishable from "the two different callables that were + * deliberately passed". This measures the case that matters instead: **the same callable, passed + * again on every pass**, which is what a UI that recomposes several times a second actually does. + * + * ### The three things counted, and why none of them alone is the answer + * + * | counter | what it moves for | + * |---|---| + * | [PythonCallableScope.liveCount] | one per wrapper the scope is holding open | + * | `HandleTable.liveCount` | one per wrapper root, plus whatever an invocation left rooted | + * | `sys.getrefcount` of the Python callable | one per **thunk** built over it, which is the reference Kotlin is holding | + * + * The refcount is the honest one -- it is Python's own accounting and cannot be fooled by a Kotlin + * table that forgot to grow -- but it moves by one for a *thunk*, and `_callable_thunk` hands a plain + * slot's callable over unwrapped, so for those it moves for the callable itself. The scope count is + * the one to fix; the other two are there so that "the scope stopped growing" cannot pass while the + * cost merely moved somewhere else. + * + * ### What counts as the same callable + * + * Not `is`. A `lambda:` written inline is a **new object on every pass** (`docs/archive/pythonx-adapter-design.md` + * records this), so an identity test on the callable interns nothing at all for the spelling that + * appears in every example in this module. The key is structural and is described where it is + * computed, in `PythonxAdapter`'s `_intern_key`; what this file asserts is the consequence: + * `Column(content=lambda: Text('hi'))` composed [PASSES] times holds **one** wrapper, and + * `Column(content=_named)` holds one too. + * + * [aCapturedValueThatChangesIsADifferentCallableAndIsNotShared] is the other half and is not a + * failure: two lambdas that close over different values do different things, and giving them one + * wrapper would render the first pass's value forever. + */ +class RecompositionAccumulationTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * **A named Python function in a composable `content` slot, [PASSES] passes.** + * + * The easiest case there is: `_content` is one object for the whole test, so even an `id()`-keyed + * cache would answer here. It is stated first because a failure here and a failure in + * [anInlineLambdaRecomposedManyTimesHoldsOneWrapper] mean different things -- this one says the + * cache is not consulted at all, that one says the key is wrong for the spelling everyone writes. + */ + @Test + fun aNamedCallableRecomposedManyTimesHoldsOneWrapper() { + Python3.exec( + """ + import sys + from androidx.compose.material3 import Text + + def _acc_named(): + Text('hi') + + _acc_base = sys.getrefcount(_acc_named) + """.trimIndent(), + ) + val measured = measure("_acc_named") { pass -> columnCalling("_acc_named", pass) } + report("named function", measured) + + assertEquals( + 1, measured.wrappers, + "the scope holds one wrapper per pass: ${measured.wrappers} after $PASSES recompositions", + ) + assertEquals( + 1, measured.heldRefs, + "Kotlin holds one Python reference per pass: ${measured.heldRefs} after $PASSES recompositions", + ) + assertReusedTheRest(measured, crossings = PASSES) + assertEveryPassDrew(measured) + assertReleasedExactlyOnce(measured) + } + + /** + * **The spelling every example in this module uses**, and the one an `id()` key cannot serve: + * `content=lambda: Text('hi')` builds a fresh function object on every pass. + * + * What it does *not* build fresh is the code object -- CPython stores it in the enclosing code's + * constants, so `lambda`s from one source position share one `__code__` for the life of the + * module. That, and an empty closure, is the whole of why this can be interned. + */ + @Test + fun anInlineLambdaRecomposedManyTimesHoldsOneWrapper() { + Python3.exec( + """ + import sys + from androidx.compose.material3 import Text + """.trimIndent(), + ) + val measured = measure(null) { pass -> + "# pass $pass\n" + + "from androidx.compose.foundation.layout import Column\n" + + "from androidx.compose.material3 import Text\n" + + "Column(content=lambda: Text('hi'))" + } + report("inline lambda", measured) + + assertEquals( + 1, measured.wrappers, + "an inline lambda is a new object per pass and was not interned: ${measured.wrappers} wrappers", + ) + assertReusedTheRest(measured, crossings = PASSES) + assertEveryPassDrew(measured) + assertReleasedExactlyOnce(measured) + } + + /** + * **A plain (non-composable) callback slot**, which takes a different route through + * `_callable_thunk`: a `(Boolean) -> Unit` whose callable already accepts its one argument is + * handed to Kotlin *unwrapped*, so the reference Kotlin holds is to the callable itself. + * + * Stated separately because that branch is the one where a wrapper-level cache could look like it + * worked while the refcount kept climbing -- there is no thunk in between to hide behind. + */ + @Test + fun aPlainCallbackSlotRecomposedManyTimesHoldsOneWrapper() { + Python3.exec( + """ + import sys + + _acc_seen = [] + + def _acc_toggle(value): + _acc_seen.append(value) + + _acc_base = sys.getrefcount(_acc_toggle) + """.trimIndent(), + ) + val measured = measure("_acc_toggle") { pass -> + "# pass $pass\n" + + "from androidx.compose.material3 import Checkbox\n" + + "Checkbox(False, onCheckedChange=_acc_toggle)" + } + report("plain callback", measured) + + assertEquals( + 1, measured.wrappers, + "the scope holds one wrapper per pass: ${measured.wrappers} after $PASSES recompositions", + ) + assertEquals( + 1, measured.heldRefs, + "Kotlin holds one Python reference per pass: ${measured.heldRefs} after $PASSES recompositions", + ) + assertReusedTheRest(measured, crossings = PASSES) + assertEveryPassDrew(measured) + assertReleasedExactlyOnce(measured) + } + + /** + * **The limit, asserted rather than hoped for.** A lambda that closes over a value which changes + * between passes is a *different* callable each time and must get a different wrapper: sharing one + * would leave Compose calling the first pass's capture forever, which is a wrong frame rather than + * a leak. + * + * So this test asserts accumulation -- one wrapper per distinct capture -- and that is the honest + * statement of what interning does not solve. `docs`-level discussion of a release point that + * would solve it belongs with the report; what belongs here is the number. + */ + @Test + fun aCapturedValueThatChangesIsADifferentCallableAndIsNotShared() { + Python3.exec( + """ + import sys + from androidx.compose.material3 import Text + + def _acc_make(n): + return lambda: Text('hi' * n) + """.trimIndent(), + ) + val measured = measure(null) { pass -> + "# pass $pass\n" + + "from androidx.compose.foundation.layout import Column\n" + + "Column(content=_acc_make(${pass % DISTINCT_CAPTURES + 1}))" + } + report("capturing lambda over $DISTINCT_CAPTURES distinct values", measured) + + assertEquals( + DISTINCT_CAPTURES, measured.wrappers, + "expected one wrapper per distinct capture, not per pass", + ) + assertReusedTheRest(measured, crossings = PASSES) + assertEveryPassDrew(measured) + + // **The stale-frame guard, and the reason a capture is compared at all.** The three captures + // draw three different amounts of ink, and every later pass has to draw the amount its own + // capture calls for. A cache keyed on the code object alone would answer every pass with the + // first capture's wrapper and this list would be twelve copies of one number -- which is a + // wrong frame, not a leak, and no reference count anywhere would notice. + val cycle = measured.inkPerPass.take(DISTINCT_CAPTURES) + assertEquals( + DISTINCT_CAPTURES, cycle.toSet().size, + "the three captures did not draw three different things, so this cannot detect a stale one: $cycle", + ) + assertEquals( + List(PASSES) { cycle[it % DISTINCT_CAPTURES] }, + measured.inkPerPass, + "a pass drew another capture's content, so a wrapper was shared between two callables", + ) + assertReleasedExactlyOnce(measured) + } + + /** + * **The same callable in two different slots is two wrappers**, because the wrapper carries the + * slot's invocation convention: a composable `content` threads a composer and a plain `onClick` + * does not, and one object cannot be both. A cache keyed on the callable alone would hand the + * `content` wrapper to `onClick` and invoke it with two arguments it has no slots for. + */ + @Test + fun oneCallableInTwoSlotShapesIsNotSharedBetweenThem() { + Python3.exec( + """ + import sys + from androidx.compose.material3 import Text + + def _acc_both(): + Text('hi') + + _acc_base = sys.getrefcount(_acc_both) + """.trimIndent(), + ) + val measured = measure("_acc_both") { pass -> + "# pass $pass\n" + + "from androidx.compose.material3 import Button\n" + + "Button(onClick=_acc_both, content=_acc_both)" + } + report("one callable, two slot shapes", measured) + + assertEquals(2, measured.wrappers, "expected one wrapper per slot shape, held across all passes") + assertEquals(2, measured.heldRefs, "expected exactly two references held, one per slot shape") + assertReusedTheRest(measured, crossings = 2 * PASSES) + assertEveryPassDrew(measured) + assertReleasedExactlyOnce(measured) + } + + /** + * **The precondition a release point between recompositions would need**, and it holds -- which + * is not what was expected. + * + * The sweep worth wanting is: at the end of a Python pass, give back every wrapper the pass + * neither created nor reused, because the body just re-ran and re-supplied everything still in + * use. Its soundness rests on one claim -- *nothing Compose still holds goes untouched across a + * pass* -- and the obvious way for that to fail is **skipping**. Interning makes it plausible: + * reusing a wrapper hands `Column` the *same object* in its `content` slot, so + * `composer.changed(content)` answers false, and a `Column` that skips keeps its children as they + * are without re-composing them. A `content` that itself declares a `content` would then have its + * inner wrapper left untouched while Compose was still holding it, and a sweep would release + * something live. + * + * **It does not happen here.** The measured shape over [SHORT_PASSES] passes of + * `Column(content=lambda: Column(content=_acc_inner))` is *two* crossings on every pass, not two + * and then one, and the inner content is invoked once per pass throughout. Nothing is skipped, so + * every live wrapper is re-supplied every pass and the sweep's precondition is satisfied for this + * shape. + * + * ### What this does and does not license + * + * It licenses the sweep for the shapes measured here. It does **not** prove Compose can never + * skip a subtree whose `content` came from Python -- `PythonComposition` is re-entered with a + * changed `String` on every pass, which is the strongest possible invalidation, and a host that + * recomposed it for a subtler reason might see `$composer.skipping` come out true. So this test + * exists as the **guard** rather than as the sweep: it is what would go red first if that + * changed, and it should be read before anything releases a wrapper before disposal. + * + * There is a second shape this test says nothing about: a slot Compose **retains across a + * re-supply**. `LaunchedEffect(key) { block }` keeps the block it already has when `key` is + * unchanged and drops the new one, so a pass that supplies a *different* wrapper -- a lambda + * whose capture moved -- leaves the retained one untouched and still live. Nothing in this module + * binds such a declaration today, so `RetainedSlotSweepPreconditionTest` measures it directly + * against `remember(key)` instead (the mechanism `LaunchedEffect(key)` is built on) -- and finds + * the shape unsafe for a same-pass sweep: see `PythonCallables.kt`'s "release point" section for + * the numbers and the verdict. + * + * The reason to want it at all is + * [aCapturedValueThatChangesIsADifferentCallableAndIsNotShared]: interning bounds a repeated + * callable to one wrapper and leaves a callable whose capture changes at one per distinct value. + */ + @Test + fun everyLiveWrapperIsResuppliedOnEveryPassEvenWhenNested() { + Python3.exec( + """ + _acc_inner_calls = [0] + + def _acc_inner(): + _acc_inner_calls[0] += 1 + """.trimIndent(), + ) + + CountingCallableArena.resetCounters() + val body = mutableStateOf(nested(0)) + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonComposition(body.value) + } + val servedPerPass = ArrayList(PASSES) + val innerCallsPerPass = ArrayList(PASSES) + try { + var previouslyServed = 0 + scene.render() + val arena = CountingCallableArena.latest ?: error("no arena was remembered") + servedPerPass += arena.scope.liveCount + arena.scope.reuseCount + innerCallsPerPass += pyInt("_acc_inner_calls[0]") + previouslyServed = servedPerPass[0] + for (pass in 1 until SHORT_PASSES) { + body.value = nested(pass) + Snapshot.sendApplyNotifications() + scene.render() + val served = arena.scope.liveCount + arena.scope.reuseCount + servedPerPass += served - previouslyServed + previouslyServed = served + innerCallsPerPass += pyInt("_acc_inner_calls[0]") + } + println( + "nested content: crossings per pass=$servedPerPass " + + "cumulative inner invocations=$innerCallsPerPass " + + "wrappers=${arena.scope.liveCount} reused=${arena.scope.reuseCount}", + ) + assertEquals( + List(SHORT_PASSES) { 2 }, servedPerPass, + "a pass served fewer than the two wrappers Compose is holding, so a subtree was " + + "skipped and an end-of-pass sweep would release something live", + ) + assertEquals( + 2, arena.scope.liveCount, + "expected exactly the outer and inner wrappers to be held across all passes", + ) + assertEquals( + List(SHORT_PASSES) { it + 1 }, innerCallsPerPass, + "the innermost content was not invoked exactly once per pass: $innerCallsPerPass", + ) + } finally { + scene.close() + } + assertEquals( + 2, CountingCallableArena.released, + "both wrappers, released once each -- the inner one is still Compose's at the end", + ) + } + + // ------------------------------------------------------------------ the harness + + /** + * What one composition accumulated. + * + * @param wrappers [PythonCallableScope.liveCount] read **before** disposal, which is the number + * this whole file is about. + * @param rootDelta `HandleTable.liveCount` above the baseline taken before the scene existed, + * also read before disposal. + * @param heldRefs how far the callable's refcount rose above its own baseline, or `-1` when the + * test named no callable to watch (an inline lambda has no name to take a refcount of). + * @param releasedAtDisposal what every [PythonCallableScope.close] reported, summed. + * @param rootsAfterDisposal `HandleTable.liveCount` above the same baseline, after disposal. + * @param refsAfterDisposal the watched callable's refcount above its baseline, after disposal. + */ + private class Accumulation( + val wrappers: Int, + val reuses: Int, + val rootDelta: Int, + val heldRefs: Int, + val releasedAtDisposal: Int, + val rootsAfterDisposal: Int, + val refsAfterDisposal: Int, + val inkPerPass: List, + ) + + /** + * Composes [source] once, then [PASSES] - 1 more times, and reads the counters before and after + * disposal. + * + * Recomposition is forced by giving `PythonComposition` a source string that differs by a comment, + * which is the only lever this harness has: `PythonComposition(source: String)` reads no state of + * its own, so an unchanged `String` is a skipped composable and no pass happens at all. A comment + * is used rather than different Python because the *body* has to stay the same -- the claim is + * about one callable crossing repeatedly, not about several. + * + * @param watched the name of a Python global whose refcount to follow, or `null`. + */ + private fun measure(watched: String?, source: (Int) -> String): Accumulation { + CountingCallableArena.resetCounters() + val baseRoots = HandleTable.liveCount + val baseRefs = if (watched == null) 0 else pyInt("sys.getrefcount($watched)") + + val body = mutableStateOf(source(0)) + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonComposition(body.value) + } + val wrappers: Int + val reuses: Int + val rootDelta: Int + val heldRefs: Int + val ink = ArrayList(PASSES) + try { + ink += inkOfImage(scene.render()) + for (pass in 1 until PASSES) { + body.value = source(pass) + Snapshot.sendApplyNotifications() + ink += inkOfImage(scene.render()) + } + assertEquals(1, CountingCallableArena.created, "the arena was rebuilt instead of remembered") + val arena = CountingCallableArena.latest ?: error("no arena was remembered") + wrappers = arena.scope.liveCount + reuses = arena.scope.reuseCount + rootDelta = HandleTable.liveCount - baseRoots + heldRefs = if (watched == null) -1 else pyInt("sys.getrefcount($watched)") - baseRefs + } finally { + scene.close() + } + return Accumulation( + wrappers = wrappers, + reuses = reuses, + rootDelta = rootDelta, + heldRefs = heldRefs, + inkPerPass = ink, + releasedAtDisposal = CountingCallableArena.released, + rootsAfterDisposal = HandleTable.liveCount - baseRoots, + refsAfterDisposal = if (watched == null) 0 else pyInt("sys.getrefcount($watched)") - baseRefs, + ) + } + + /** + * The other half of every case, and `agent-rules` §14's reason for existing: a scope that gave + * back more than it held is not a leak, it is a double release, and it does not fail where it + * happens. + * + * `PythonCallableScope.close` answers `0` for every call after the first, so + * `releasedAtDisposal > wrappers` can only come from something releasing twice; a *reference* + * released twice shows up instead as a refcount that ends below where it started, which is what + * the last assertion is for. + */ + private fun assertReleasedExactlyOnce(measured: Accumulation) { + assertEquals( + measured.wrappers, measured.releasedAtDisposal, + "the arena reported releasing ${measured.releasedAtDisposal} of ${measured.wrappers} wrappers", + ) + // Stated before the leak check and not after it, because the two are different failures and + // the double release is the one that does not fail where it happens: a crossing served from + // the intern table closes the reference the trampoline just took for a thunk nobody will + // use, and a second close of the *cached* wrapper's own reference would land here as a + // count below the baseline rather than above it. + assertTrue( + measured.refsAfterDisposal >= 0, + "the callable's refcount ended ${-measured.refsAfterDisposal} below its baseline, " + + "which is a reference released twice", + ) + assertEquals( + 0, measured.refsAfterDisposal, + "the callable's refcount did not come back to where it started", + ) + assertEquals( + 0, measured.rootsAfterDisposal, + "handles left rooted after the composition was disposed", + ) + } + + /** + * The counterpart to every "held one wrapper" assertion, and the one that stops the whole file + * from being satisfiable by a `content=` that silently stopped crossing: [crossings] calls were + * made, [Accumulation.wrappers] were built, and the difference was served from the scope's table. + */ + private fun assertReusedTheRest(measured: Accumulation, crossings: Int) { + assertEquals( + crossings - measured.wrappers, measured.reuses, + "expected $crossings crossings to be served by ${measured.wrappers} wrapper(s) and " + + "${crossings - measured.wrappers} reuse(s), got ${measured.reuses} reuse(s)", + ) + } + + /** Every pass drew something, which is what says the interned wrapper was still invoked. */ + private fun assertEveryPassDrew(measured: Accumulation) { + assertTrue( + measured.inkPerPass.all { it > 0 }, + "a pass drew nothing, so the reused wrapper was not invoked: ${measured.inkPerPass}", + ) + } + + private fun report(what: String, measured: Accumulation) { + println( + "recomposition accumulation [$what] over $PASSES passes: " + + "wrappers=${measured.wrappers} reused=${measured.reuses} " + + "handleRoots=+${measured.rootDelta} " + + "refcount=+${measured.heldRefs} " + + "released=${measured.releasedAtDisposal} " + + "rootsAfter=${measured.rootsAfterDisposal} refsAfter=${measured.refsAfterDisposal} " + + "ink=${measured.inkPerPass}", + ) + } + + /** A `content` that declares a `content`: two wrappers, one nested in the other. */ + private fun nested(pass: Int): String = + "# pass $pass\n" + + "from androidx.compose.foundation.layout import Column\n" + + "Column(content=lambda: Column(content=_acc_inner))" + + private fun columnCalling(name: String, pass: Int): String = + "# pass $pass\n" + + "from androidx.compose.foundation.layout import Column\n" + + "Column(content=$name)" + + /** Non-background pixels of one frame. `ComposableRenderTest` has the same three lines; the two + * are not shared because a test helper shared between test classes is a third thing that can be + * wrong. */ + private fun inkOfImage(image: Image): Int { + val bitmap = Bitmap.makeFromImage(image) + var ink = 0 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (bitmap.getColor(x, y) != 0) ink++ + } + } + return ink + } + + private fun pyInt(expression: String): Int = + Python3.import("__main__").getAttr("__dict__").let { globals -> + Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString().toInt() + } + + private companion object { + /** More than the two `ComposableRenderTest` already covers, and small enough that a failure + * prints a number a reader can hold: a `content` that accumulates shows up as 12. */ + const val PASSES = 12 + + /** How many *different* captures [aCapturedValueThatChangesIsADifferentCallableAndIsNotShared] + * cycles through. Fewer than [PASSES], so "one per distinct capture" and "one per pass" are + * different numbers and the test can tell them apart. */ + const val DISTINCT_CAPTURES = 3 + + /** Enough passes to see the second one behave differently from the first, which is all + * [aSkippedSubtreeKeepsAWrapperThePassNeverTouched] needs. */ + const val SHORT_PASSES = 4 + + /** `Py_eval_input`. */ + const val PY_EVAL_INPUT = 258 + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RetainedSlotSweepPreconditionTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RetainedSlotSweepPreconditionTest.kt new file mode 100644 index 000000000..2289e2951 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/RetainedSlotSweepPreconditionTest.kt @@ -0,0 +1,258 @@ +package fixture.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonCallables +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * **The measurement `PythonCallables.kt`'s "release point" section names as missing**: a slot Compose + * retains across a re-supply -- the shape `remember(key) { value }` and `LaunchedEffect(key) { block }` + * both have -- fed a callable whose capture changes every pass, so a *different* wrapper crosses on + * every pass while the retaining slot goes on handing out the first one. + * + * ### Why this drives `pythonx.runtime.newFunction` directly instead of through a walked composable + * + * No walked declaration in this fixture's `androidx.compose.material3`/`foundation.layout` surface + * retains across a re-supply -- `Column`'s `content` is invoked with whatever was passed on *every* + * pass, which is exactly what + * `RecompositionAccumulationTest.everyLiveWrapperIsResuppliedOnEveryPassEvenWhenNested` measured, and + * that KDoc says outright: "nothing in this module binds such a declaration today." Nothing here adds + * one either -- a retaining composable reachable *from Python* would need `FragmentScanner` to thread a + * `$composer`, which is `ArtifactScanner`/`ComposableThunks`' job and is wired only for the androidx + * packages this module walks. + * + * `pythonx.runtime.newFunction` is what any walked call site's crossing calls under the hood + * (`PythonxAdapter._make_function`), and `PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)` -- already needed here for nothing + * more than its dynamic `pythonx.*` package finder -- registers `PythonCallables.Fragment` as part of + * installing, which is what makes `from pythonx.runtime import newFunction` resolve at all + * (`PythonCallables.Fragment` is `internal` to `:python-multiplatform`, so this file cannot register it + * directly the way `PythonCallablesProxyArgumentTest` does there). Calling it here, once per pass, with + * a real capture-bearing Python closure, and feeding the resulting handle into a real `remember(key)`, + * produces the same crossing a retaining walked declaration would produce without one having to exist. + */ +class RetainedSlotSweepPreconditionTest { + + @BeforeTest + fun install() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + // `PythonProxySource.install()` renders its Python source from whatever is registered in + // `UpcallTable` *at the moment it is called* -- called again here, after `PythonxAdapter + // .install()` has registered `PythonCallables.Fragment`, is what actually gives + // `pythonx.runtime` a module: called only once, before that registration (the order + // `DraggableLeakTest` uses, where it does not matter because nothing there touches + // `pythonx.runtime`), `from pythonx.runtime import newFunction` raises `ModuleNotFoundError`. + PythonProxySource.install() + RetainedHandleProbe.current = 0L + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * **The number the sweep's precondition was waiting on.** [PASSES] passes, a constant retention + * key, and a callable whose capture is the pass number -- so every pass crosses a genuinely + * different wrapper (`aCapturedValueThatChangesIsADifferentCallableAndIsNotShared`'s shape) while + * `remember("fixed")` keeps handing out pass 0's. + * + * Three things are measured, not assumed: + * + * 1. [RetainedHandleProbe.current] stays pass 0's handle for all [PASSES] passes -- confirms + * `remember(key)` really ignores the re-supply, in this exact Compose runtime, for this exact + * shape. + * 2. [PythonCallableScope.liveCount] grows by exactly one every pass and [PythonCallableScope + * .reuseCount] stays zero -- confirms pass 0's wrapper is *never touched again* after pass 0: + * not rebuilt (its capture never recurs) and not reused (its key is never asked for again). + * 3. Pass 0's Python callable's own refcount, read immediately after it crossed and again after + * every later pass has crossed and been discarded, is unchanged -- confirms the reference + * [PythonCallableScope] is holding for it is still there, not doubled and not dropped. + * + * (1) and (2) together are the sweep's failure case stated in counters: the wrapper a same-pass + * "untouched" test would flag for release ((2)) is exactly the one something still needs ((1)). + * (3) is why that release would be worse than a leak (`agent-rules` §14) rather than merely + * redundant -- the reference is real and single, not already gone. + */ + @Test + fun aRetainingSlotKeepsPassZerosWrapperWhileLaterPassesCrossAndGoUntouched() { + Python3.exec( + """ + import sys + + from python.multiplatform.ffi.pythonx.PythonCallables import newFunction as _rs_new_function + + _rs_calls = [] + _rs_bodies = [] + + def _rs_make(n): + def _cb(): + _rs_calls.append(n) + return _cb + """.trimIndent(), + ) + + val scope = PythonCallables.newScope() + val handles = ArrayList(PASSES) + val retainedAfterPass = ArrayList(PASSES) + val liveAfterPass = ArrayList(PASSES) + val reusedAfterPass = ArrayList(PASSES) + + // Every pass builds and crosses a genuinely different callable -- a distinct capture, hence + // a distinct intern key -- which is what "a pass that supplies a different wrapper" means. + // The crossing happens unconditionally, the same way a Python argument expression is always + // evaluated before the call it is an argument to, regardless of what the callee goes on to + // do with it. + fun crossOnePass(pass: Int): Long { + Python3.exec( + """ + _rs_body = _rs_make($pass) + _rs_bodies.append(_rs_body) + _rs_body = None + ${if (pass == 0) "_rs_base0 = sys.getrefcount(_rs_bodies[0])" else ""} + _rs_handle_$pass = _rs_new_function(_rs_bodies[$pass], 0, False, '', 'capture:$pass') + assert _rs_handle_$pass != 0, 'expected a real wrapper handle' + ${if (pass == 0) "_rs_held0 = sys.getrefcount(_rs_bodies[0])" else ""} + """.trimIndent(), + ) + return pyLong("_rs_handle_$pass") + } + + // Pass 0's handle has to exist *before* `ImageComposeScene` is constructed -- its content + // lambda closes over `handleState`, and construction performs the first composition using + // whatever `handleState.value` already is at that moment. Measured: getting this order wrong + // (constructing the scene with a placeholder `0L` and setting the real value afterward) is + // indistinguishable from "`remember(key)` never retains anything" -- every later pass reads + // back `0L` too, because `remember` locked onto the *placeholder* as pass 0's value and every + // later pass has the same (unchanged) key. `RecompositionAccumulationTest.measure` avoids this + // the same way, initialising `mutableStateOf(source(0))` before constructing its scene. + val scope0Handle = PythonCallables.withScope(scope) { crossOnePass(0) } + handles += scope0Handle + + // One composition, recomposed [PASSES] - 1 more times -- `remember(key)` only retains + // *across recompositions of the same composition*; a fresh `ImageComposeScene` per pass would + // have no slot table to retain anything in, and every pass would look like "first + // composition" instead. + val handleState = mutableStateOf(scope0Handle) + val scene = ImageComposeScene(width = 4, height = 4, density = Density(1f)) { + RetainInProbe(key = "fixed", handle = handleState.value) + } + + try { + PythonCallables.withScope(scope) { + scene.render() + retainedAfterPass += RetainedHandleProbe.current + liveAfterPass += scope.liveCount + reusedAfterPass += scope.reuseCount + + for (pass in 1 until PASSES) { + val handle = crossOnePass(pass) + handles += handle + + // From pass 1 on, `key` hasn't changed, so `remember` hands back what pass 0 + // stored and this pass's handle is dropped on the floor -- by Compose, not by + // anything here. + handleState.value = handle + Snapshot.sendApplyNotifications() + scene.render() + retainedAfterPass += RetainedHandleProbe.current + liveAfterPass += scope.liveCount + reusedAfterPass += scope.reuseCount + } + } + + // Both read through the same expression (`_rs_bodies[0]`, never the reassigned-every-pass + // `_rs_body` local) -- comparing refcounts of two *different* Python expressions is what + // made this measurement look like a premature release the first time it was run. + val after0 = pyInt("sys.getrefcount(_rs_bodies[0])") + val base0 = pyInt("_rs_base0") + val held0 = pyInt("_rs_held0") + + println( + "retained-slot precondition: handles=$handles retained=$retainedAfterPass " + + "live=$liveAfterPass reused=$reusedAfterPass " + + "refcount(base0=$base0 held0=$held0 after0=$after0)", + ) + + // (1) Real Compose kept returning pass 0's handle throughout. + assertEquals( + List(PASSES) { handles[0] }, retainedAfterPass, + "remember(key) returned a later pass's handle even though key never changed", + ) + // (2) Every pass nonetheless built its own wrapper -- pass 0's was touched on pass 0 and + // never again, because every capture (and so every intern key) was distinct. + assertEquals(List(PASSES) { it + 1 }, liveAfterPass, "expected one new wrapper per pass") + assertEquals(List(PASSES) { 0 }, reusedAfterPass, "expected no key to ever be reused") + // (3) Pass 0's Python reference is exactly one over baseline, both right after it crossed + // and after every later pass has crossed and discarded its own. + assertEquals(1, held0 - base0, "the crossing did not take exactly one Python reference") + assertEquals( + held0, after0, + "pass 0's reference count moved while later passes crossed and discarded their own -- " + + "either it was released early (worse than a leak) or double-counted", + ) + } finally { + scene.close() + scope.close() + } + } + + private fun pyInt(expression: String): Int = + Python3.import("__main__").getAttr("__dict__").let { globals -> + Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString().toInt() + } + + /** [pyInt], widened: a `HandleTable` root is a full 64-bit pointer-shaped value and routinely + * exceeds [Int.MAX_VALUE], unlike the small refcounts and pass indices [pyInt] reads elsewhere + * in this file. */ + private fun pyLong(expression: String): Long = + Python3.import("__main__").getAttr("__dict__").let { globals -> + Python3.eval(expression, PY_EVAL_INPUT, globals, globals).toString().toLong() + } + + private companion object { + /** Enough passes to distinguish "retained once" from "retained coincidentally": if retention + * broke down on, say, pass 2, one or two passes would not show it. */ + const val PASSES = 6 + + /** `Py_eval_input`. */ + const val PY_EVAL_INPUT = 258 + } +} + +/** What [RetainInProbe] published on its most recent composition. A plain var and not a + * `mutableStateOf` on purpose: nothing here reads it back *inside* a composition, so it needs no + * snapshot semantics, only a way for the test driver outside the composition to see what `remember` + * decided. */ +private object RetainedHandleProbe { + var current: Long = 0L +} + +/** + * The retaining slot itself: `remember(key) { handle }` is the whole of `LaunchedEffect(key) { block }`'s + * own retention mechanism (`LaunchedEffectImpl` is built inside exactly this call), applied directly to + * the handle a crossing produced instead of to a suspend block -- coroutines are not what is being + * measured here, only whether the *value* Compose is asked to remember is the one it goes on returning. + */ +@Composable +private fun RetainInProbe(key: String, handle: Long) { + RetainedHandleProbe.current = remember(key) { handle } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/SwipeableRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/SwipeableRenderTest.kt new file mode 100644 index 000000000..4272f043f --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/SwipeableRenderTest.kt @@ -0,0 +1,160 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * `Modifier.swipeable` (`androidx.compose.material.SwipeableKt.swipeable-pPrIpRY`), declined for the same + * unspellable-`T` reason as `anchoredDraggable` (`docs/archive/pythonx-adapter-design.md` §9.2) -- fixed here to + * `String` the same way `AnchoredDraggableRenderTest` fixes `anchoredDraggable`. See + * `fixture.compose.pythonSwipeableString` for the wrapper under test and, specifically, for why + * `thresholds: (T, T) -> ThresholdConfig` is not crossed to Python at all. + */ +class SwipeableRenderTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + @Test + fun aRealDragReachesThePythonCallbackThroughSwipeable() { + Python3.exec( + """ + _value_events = [] + + def _on_value_change(value): + _value_events.append(value) + """.trimIndent(), + ) + + val before = pixelsOf(BODY) + assertTrue(inkOf(before) > 0, "the Text never composed, so there was nothing to drag") + + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(BODY) + } + try { + scene.render() + scene.sendPointerEvent(PointerEventType.Move, Offset(START_X, BOX_CENTRE)) + scene.sendPointerEvent(PointerEventType.Press, Offset(START_X, BOX_CENTRE)) + var x = START_X + while (x < START_X + DRAG_DISTANCE) { + x += STEP + scene.sendPointerEvent(PointerEventType.Move, Offset(x, BOX_CENTRE)) + } + scene.sendPointerEvent(PointerEventType.Release, Offset(x, BOX_CENTRE)) + scene.render() // Wait for the snap animation to settle + } finally { + scene.close() + } + + // The drag crosses the fractional threshold and triggers confirmStateChange to the new anchor + Python3.exec( + "assert len(_value_events) > 0, 'onValueChange was never invoked'", + ) + Python3.exec( + "assert 'end' in _value_events, 'did not try to settle at end anchor: ' + repr(_value_events)", + ) + + val after = pixelsOf(BODY) + val moved = differing(before, after) + println( + "compose input: swipeable drag -> ink ${inkOf(before)} -> ${inkOf(after)} px, " + + "$moved pixels changed", + ) + assertTrue( + moved > 0, + "no pixel changed after the drag, so the component did not move", + ) + } + + @Test + fun aPressOutsideTheBoxInvokesNothing() { + Python3.exec( + """ + _value_events = [] + + def _on_value_change(value): + _value_events.append(value) + """.trimIndent(), + ) + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(BODY) + } + try { + scene.render() + val at = Offset(SCENE - 2f, SCENE - 2f) + scene.sendPointerEvent(PointerEventType.Move, at) + scene.sendPointerEvent(PointerEventType.Press, at) + scene.sendPointerEvent(PointerEventType.Move, Offset(SCENE - 20f, SCENE - 2f)) + scene.sendPointerEvent(PointerEventType.Release, Offset(SCENE - 20f, SCENE - 2f)) + scene.render() + } finally { + scene.close() + } + Python3.exec( + "assert _value_events == [], " + + "'a press outside the box invoked swipeable: ' + repr(_value_events)", + ) + } + + private fun pixelsOf(body: String): IntArray { + val scene = ImageComposeScene(width = SCENE, height = SCENE, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return IntArray(SCENE * SCENE) { bitmap.getColor(it % SCENE, it / SCENE) } + } finally { + scene.close() + } + } + + private fun inkOf(pixels: IntArray): Int = pixels.count { it != BACKGROUND } + + private fun differing(a: IntArray, b: IntArray): Int = a.indices.count { a[it] != b[it] } + + private companion object { + const val BACKGROUND = 0 + const val SCENE = 80 + + const val BOX_CENTRE = 24f + const val START_X = 4f + const val DRAG_DISTANCE = 40f + const val STEP = 8f + + val BODY = """ + from fixture.compose import emptyModifier, pythonSwipeableString + from androidx.compose.foundation.layout import size__Dp + from androidx.compose.material3 import Text + + _m = pythonSwipeableString(size__Dp(emptyModifier(), 48.0), "start", "start", 0.0, "end", 40.0, 0.5, _on_value_change) + Text(str(len(_value_events)), modifier=_m) + """.trimIndent() + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt new file mode 100644 index 000000000..2fe9a7c3c --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt @@ -0,0 +1,245 @@ +package fixture.compose + +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Image +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **`TextField(state = ...)` from Python** (issue #73): the state-based text field of Compose 1.11, the + * one pythonx-compose's `TextField` design uses so that typing stays inside Compose. + * + * Everything here is reached through the walk under Kotlin names, with no fixture function between + * Python and Compose: + * + * | Python | Kotlin | + * |---|---| + * | `TextFieldState()`, `TextFieldState('seed')` | the constructor `TextFieldState(initialText = "", initialSelection = TextRange(...))`, outside any composition | + * | `rememberTextFieldState('abc')` | the `@Composable` of that name, inside one | + * | `s.text` | the `CharSequence` property, read as a Python `str` | + * | `s.setTextAndPlaceCursorAtEnd(...)`, `s.clearText()` | the two extension functions, as methods of the state's proxy | + * | `TextField(state=s)` | material3's state overload | + * + * ### Red before the fix + * + * `from androidx.compose.foundation.text.input import TextFieldState` (or `rememberTextFieldState`) + * finds no binding: the fixture did not walk the package, and once it does, the walker dropped the + * constructor (a value-class parameter compiles to a synthetic bridge) and declined `text` + * (`kotlin.CharSequence` had no boundary type). Each test therefore fails at its first `exec` with an + * `ImportError`/`AttributeError`/`TypeError` from Python -- never at a pixel assertion, which is what + * tells that red from a rendering regression. + */ +class TextFieldStateRenderTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + Python3.exec( + """ + for _name in [n for n in globals() if n.startswith('_tfs')]: + del globals()[_name] + """.trimIndent(), + ) + UpcallTable.clear() + } + + /** + * Made outside a composition, both ways (all defaults left out, and `initialText` given), read back + * as `str`, written through the two extensions -- and drawn: the same `TextField(state=...)` body + * over the empty state and over the written one gives different pixels. + */ + @Test + fun aStateMadeOutsideACompositionIsReadAsStrWrittenByItsExtensionsAndDrawn() { + Python3.exec( + """ + from androidx.compose.foundation.text.input import TextFieldState + + _tfs_empty = TextFieldState() + assert type(_tfs_empty)._kotlin_type_name == 'androidx.compose.foundation.text.input.TextFieldState', \ + type(_tfs_empty)._kotlin_type_name + assert type(_tfs_empty.text) is str, type(_tfs_empty.text) + assert _tfs_empty.text == '', repr(_tfs_empty.text) + + _tfs_seeded = TextFieldState('seed') + assert _tfs_seeded.text == 'seed', repr(_tfs_seeded.text) + _tfs_seeded.clearText() + assert _tfs_seeded.text == '', repr(_tfs_seeded.text) + + _tfs = TextFieldState() + _tfs.setTextAndPlaceCursorAtEnd('hello from python') + assert type(_tfs.text) is str and _tfs.text == 'hello from python', repr(_tfs.text) + """.trimIndent(), + ) + + val empty = pixelsOf(textFieldOver("_tfs_empty")) + val written = pixelsOf(textFieldOver("_tfs")) + val moved = differing(empty, written) + println("compose TextFieldState: ink empty ${inkOf(empty)} -> written ${inkOf(written)} px, $moved pixels differ") + assertTrue(inkOf(empty) > 0, "TextField(state=...) never composed") + assertTrue(moved > 10, "only $moved pixels differ, so the text written into the state never reached the field") + } + + /** + * The `initialSelection` slot: `TextRange` is a value class whose constructor is `internal`, so it + * crosses as a handle made by its walked factory `TextRange(index: Int)` -- separate from the test + * above so a failure here says it is this slot. + * + * Spelled `TextRange(...)`, as in Kotlin (issue #78): the module `...text.TextRange` holds the companion's + * `Zero` and is itself callable, dispatching to the factory's overload set. It used to have to be + * spelled by its table key, `TextRange__Int`, because the bare name was the module and not callable. + */ + @Test + fun theInitialSelectionCrossesAsATextRangeHandle() { + Python3.exec( + """ + from androidx.compose.foundation.text.input import TextFieldState + from androidx.compose.ui.text import TextRange + + _tfs_selected = TextFieldState('hello', TextRange(2)) + assert _tfs_selected.text == 'hello', repr(_tfs_selected.text) + _tfs_kw = TextFieldState(initialText='kw', initialSelection=TextRange(0)) + assert _tfs_kw.text == 'kw', repr(_tfs_kw.text) + """.trimIndent(), + ) + } + + /** + * Issue #78: Kotlin has `TextRange(2)` and `TextRange.Zero`; Python has both, from the one name. + * Also the explicit table-key spelling, which must keep working, and a two-argument overload. + */ + @Test + fun textRangeIsBothAFactoryAndTheHolderOfItsCompanionConstants() { + Python3.exec( + """ + from androidx.compose.ui.text import TextRange, TextRange__Int + from androidx.compose.foundation.text.input import TextFieldState + + assert callable(TextRange), type(TextRange) + _tr_zero = TextRange.Zero + _tr_two = TextRange(2) + _tr_pair = TextRange(1, 3) + _tr_key = TextRange__Int(2) + _tr_state = TextFieldState('hello', _tr_two) + assert _tr_state.text == 'hello', repr(_tr_state.text) + _tr_zero_state = TextFieldState('hello', TextRange.Zero) + assert _tr_zero_state.text == 'hello', repr(_tr_zero_state.text) + """.trimIndent(), + ) + } + + /** + * Made inside a composition by `rememberTextFieldState`, drawn by `TextField(state=...)` in the same + * body, then written from Python **outside** the composition: the state is Compose snapshot state, + * so the next frame of the *same* scene shows the new text with no host call and no Python body + * re-run being needed. Every value the body saw is the one remembered state (each reads the written + * text). + */ + @Test + fun aRememberedStateIsDrawnAndAWriteFromPythonShowsOnTheNextFrame() { + Python3.exec("_tfs_seen = []") + val scene = ImageComposeScene(width = FIELD_WIDTH, height = FIELD_HEIGHT, density = Density(1f)) { + PythonComposition(REMEMBERED) + } + val before: IntArray + var after: IntArray + var frames = 0 + try { + before = pixelsOf(scene.render()) + Python3.exec( + """ + assert len(_tfs_seen) >= 1, _tfs_seen + assert type(_tfs_seen[0].text) is str and _tfs_seen[0].text == 'abc', repr(_tfs_seen[0].text) + _tfs_seen[0].setTextAndPlaceCursorAtEnd('a much longer line of text') + """.trimIndent(), + ) + // `TextField(state=...)` does not finish reacting to a state write inside one frame: the + // text-changed handling of BasicTextField runs on the scene's coroutine dispatcher, which is + // flushed by `render()`, so the pixels can trail the write by a frame. The test is the frame + // clock (there is none), so it advances it explicitly -- but only up to MAX_FRAMES, and it + // stops at the first frame that differs. A write that truly never reaches the draw leaves every + // one of these frames identical to `before`, and the assertion below still fails. + after = before + while (frames < MAX_FRAMES && differing(before, after) <= 10) { + Snapshot.sendApplyNotifications() + after = pixelsOf(scene.render()) + frames++ + } + Python3.exec( + """ + assert all(_s.text == 'a much longer line of text' for _s in _tfs_seen), \ + [_s.text for _s in _tfs_seen] + """.trimIndent(), + ) + } finally { + scene.close() + } + val moved = differing(before, after) + println("compose rememberTextFieldState: ink ${inkOf(before)} -> ${inkOf(after)} px, $moved pixels differ after $frames frame(s)") + assertTrue(inkOf(before) > 0, "TextField(state=rememberTextFieldState(...)) never composed") + assertTrue(moved > 10, "only $moved pixels differ, so the write from Python never reached the next frame") + } + + private fun textFieldOver(stateName: String): String = """ + from androidx.compose.material3 import TextField + TextField(state=$stateName) + """.trimIndent() + + private fun pixelsOf(body: String): IntArray { + val scene = ImageComposeScene(width = FIELD_WIDTH, height = FIELD_HEIGHT, density = Density(1f)) { + PythonComposition(body) + } + try { + return pixelsOf(scene.render()) + } finally { + scene.close() + } + } + + private fun pixelsOf(image: Image): IntArray { + val bitmap = Bitmap.makeFromImage(image) + return IntArray(bitmap.width * bitmap.height) { bitmap.getColor(it % bitmap.width, it / bitmap.width) } + } + + private fun inkOf(pixels: IntArray): Int = pixels.count { it != 0 } + + private fun differing(a: IntArray, b: IntArray): Int { + assertEquals(a.size, b.size) + return a.indices.count { a[it] != b[it] } + } + + private companion object { + /** Wide enough for material3's `TextField` and a line of text at density 1. */ + const val FIELD_WIDTH = 280 + const val FIELD_HEIGHT = 64 + + /** Upper bound on frames rendered after the write in the remembered-state test. */ + const val MAX_FRAMES = 4 + + val REMEMBERED = """ + from androidx.compose.foundation.text.input import rememberTextFieldState + from androidx.compose.material3 import TextField + _tfs_state = rememberTextFieldState('abc') + _tfs_seen.append(_tfs_state) + TextField(state=_tfs_state) + """.trimIndent() + } +} diff --git a/ksp-fixtures/compose/src/desktopTest/resources/opaque-square.png b/ksp-fixtures/compose/src/desktopTest/resources/opaque-square.png new file mode 100644 index 000000000..c50c53f42 Binary files /dev/null and b/ksp-fixtures/compose/src/desktopTest/resources/opaque-square.png differ diff --git a/ksp-fixtures/klib-artifact/build.gradle.kts b/ksp-fixtures/klib-artifact/build.gradle.kts new file mode 100644 index 000000000..cfb76ef3a --- /dev/null +++ b/ksp-fixtures/klib-artifact/build.gradle.kts @@ -0,0 +1,91 @@ +/** + * The artefact walker's klib half, end to end: a real third-party `.klib` the build resolves, + * walked at build time by `KlibScanner`, installed into the same `UpcallTable` KSP's own fragments + * go into. + * + * `:ksp-fixtures:artifact` is `docs/design/ecosystem.md` §5b's second producer over a **jar** -- this is + * the same producer over a **klib**, ROADMAP §16e's "investigated, not implemented" half. One target + * only, and it is `androidNativeArm64` rather than `desktop`: a jar is what a JVM target resolves, a + * klib is what a Kotlin/Native target resolves, and `PythonArtifactBindingsTask + * .artifactConfiguration`/`.artifactSourceSet` are single-valued (ROADMAP §16f #1) -- one module can + * wire one walked configuration, so this fixture is the klib producer's own module rather than a + * second target bolted onto `:artifact`. + * + * `kotlinx-coroutines-core` is the klib walked, for the same reason JUnit is `:artifact`'s: a real, + * independently-versioned third-party artefact nothing here controls or cooperates with. + * + * ### The suite is linked by the build and run by hand + * + * KGP gives `androidNativeArm64` a `linkDebugTestAndroidNativeArm64` and **no run task** -- it + * registers one only where it knows how to reach a host, and an Android device is neither the build + * machine nor `simctl`. `:python-multiplatform` solved that for itself with a hand-written + * `androidNativeArm64Test` task (device selection, `adb push --sync` of a 60 MB stdlib, a + * TeamCity-to-JUnit-XML parser) that is project-local. Until that is generalised, this module's + * suite runs like this, reusing the payload that task already stages on the device: + * + * ./gradlew :python-multiplatform:androidNativeArm64Test # once: stages lib/ and the stdlib + * ./gradlew :ksp-fixtures:klib-artifact:linkDebugTestAndroidNativeArm64 + * D=/data/local/tmp/pmp-nativetest-arm64-v8a + * adb push ksp-fixtures/klib-artifact/build/bin/androidNativeArm64/debugTest/test.kexe \ + * $D/klibtest.kexe + * adb shell "chmod 755 $D/klibtest.kexe && cd $D && \ + * LD_LIBRARY_PATH=$D/lib PYTHONHOME=$D ./klibtest.kexe --ktest_logger=TEAMCITY" + * + * **A green ordinary build therefore says these tests compile and link, not that they pass.** + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) + id("io.github.thisisthepy.python.multiplatform.bindings") +} + +kotlin { + androidNativeArm64 { + // Mirrors `:ksp-fixtures:app`'s own androidNativeArm64 linker config: the test binary needs + // the same CPython symbols resolved at final link time, since `python-multiplatform`'s klib + // only declares them via cinterop and does not itself embed a static libpython. + val downloadDir = project(":python-multiplatform").layout.buildDirectory.dir("python-standalone").get().asFile + val pyVersion = project.findProperty("pythonVersion")?.toString() ?: project.rootProject.version.toString() + val libVersion = pyVersion.split('.').subList(0, 2).joinToString(".") + val targetExtractDir = "$downloadDir/extracted/$pyVersion/android-aarch64/prefix" + binaries.getTest(org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType.DEBUG).linkerOpts.addAll( + listOf("-L$targetExtractDir/lib/", "-lpython$libVersion", "-Wl,--allow-shlib-undefined"), + ) + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.pythonMultiplatform) + } + } + val androidNativeArm64Main by getting { + dependencies { + // A real, separately-published klib. Nothing about it knows it is going to be + // walked -- KlibScanner reads its ABI the same way it would read anyone else's. + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + } + } + val androidNativeArm64Test by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + } +} + +pythonBindings { + role.set("app") + processor.set(projects.pythonMultiplatformKsp) + + artifactConfiguration.set("androidNativeArm64CompileKlibraries") + artifactSourceSet.set("androidNativeArm64Main") + // Broad on purpose, unlike `:artifact`'s narrow `junit.runner`/`junit.framework`: coroutines' + // public surface is almost entirely `CoroutineScope`/`Job`/`Flow`-typed extensions, plus a couple + // of `@PublishedApi internal` declarations that are binary-visible but not source-visible outside + // the library that owns them -- none of those are bindable today, so the whole namespace yields + // **zero** declarations. `WalkedKlibArtifactTableTest` pins that as the correct number, not a gap: + // an earlier version bound the `@PublishedApi internal` one and failed to compile the first time + // its output was actually built rather than only unit-tested -- `KlibScanWorkAction`'s KDoc has + // the story. See that file's own KDoc for what the walker declines and why. + artifactIncludePackages.set(listOf("kotlinx.coroutines")) +} diff --git a/ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactPythonImportTest.kt b/ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactPythonImportTest.kt new file mode 100644 index 000000000..388e20a0f --- /dev/null +++ b/ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactPythonImportTest.kt @@ -0,0 +1,104 @@ +package fixture.artifact + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import python.native.ffi.UpcallEntry +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * The whole klib path in one interpreter, on a **Kotlin/Native** target: a `.klib` the build + * resolved -> `KlibScanner` -> an (empty) `ArtifactTable` -> `UpcallTable` -> `PythonProxySource`. + * + * `:ksp-fixtures:artifact`'s `WalkedArtifactPythonImportTest` made a *positive* version of this claim + * for a **jar** on **desktop**: a bound declaration reaches Python and is callable. This class makes + * the negative half instead -- see `WalkedKlibArtifactTableTest`'s KDoc for why `kotlinx-coroutines + * -core`'s klib half of the shared namespace is correctly *empty*, not merely small. Both matter: a + * walker that silently produced no bindings would look identical to one that correctly declined + * everything it saw, and only running the interpreter and asking Python whether the name exists tells + * the two apart from a bound declaration's failure mode (an `AttributeError`/`ModuleNotFoundError` + * either way looks the same from the Kotlin side, since nothing there ever calls into the klib). + * + * The bootstrap is [UpcallEntry.publish] -- five real `PyMethodDef`s installed into `__main__`, which + * is what `python-multiplatform`'s own `nativeTest` uses and the only route a consumer module has on + * this target (no Panama, no `ctypes.CFUNCTYPE`; see `WalkedArtifactPythonImportTest`'s KDoc for what + * desktop uses instead). + * + * ### Running it + * + * There is no Gradle task; see `WalkedKlibArtifactTableTest`'s KDoc for why, and this module's + * `build.gradle.kts` for the `adb` command that runs the linked binary on a device. + */ +class WalkedKlibArtifactPythonImportTest { + + @BeforeTest + fun installTheWalkersTableIntoAnInterpreter() { + Python3.initialize(silent = true) + UpcallTable.clear() + // Both producers, one table -- KSP's fragment for this module's own source and the walker's + // (empty) contribution for the klib. `ArtifactTable.fragments` is empty, which is exactly + // this class's point: installing it must be a no-op, not an error. + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + // The five names `PythonProxySource`'s guard requires. On desktop these are `ctypes` + // callbacks over Panama stubs; on Kotlin/Native they are ordinary `PyMethodDef`s, because + // Python and Kotlin are the same binary here and there is no boundary to bridge. + val mainGlobals = Python3.import("__main__").getAttr("__dict__") + check(UpcallEntry.publish(mainGlobals.pointer)) { "the upcall bootstrap could not be published" } + PythonProxySource.install() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * KSP's half of the shared namespace still reaches Python, exactly as `WalkedArtifactPythonImportTest` + * proves for the JVM producer's own declarations. + * + * `fixture.artifact` is this module's Kotlin package, reached through KSP's `FunctionTable`. + * `OwnDeclarations.kt`'s KDoc records why this file exists at all: an aggregator module with no + * source declarations of its own would emit an empty `FunctionTable` too, and this is what proves + * the emptiness this class's other test pins is specific to the klib walker, not a bootstrap + * failure that would have hidden both producers equally. + */ + @Test + fun kspsHalfOfTheSharedNamespaceIsImportableFromPython() { + Python3.exec( + """ + from fixture.artifact import whichSideAmIFrom + + assert whichSideAmIFrom() == 'ksp' + """.trimIndent(), + ) + } + + /** + * The klib walker's decline is real all the way through the interpreter, not only a Kotlin-side + * fact this suite takes on faith. + * + * `checkIndexOverflow` is `@PublishedApi internal` (`WalkedKlibArtifactTableTest`'s KDoc), so + * `ArtifactTable.fragments` never creates a `kotlinx` module at all -- there is no partial + * registration to find a stale attribute on, only the ordinary Python import machinery failing to + * locate a package that was never created. `assertFailsWith`-style would only prove *something* + * raised; this reads the exception's own type from inside Python so a *different* failure (an + * `AttributeError` from a half-built module, say) would not pass for the right reason. + */ + @Test + fun theDeclinedKlibDeclarationNeverReachesPython() { + Python3.exec( + """ + try: + from kotlinx.coroutines.flow.internal import checkIndexOverflow + except ModuleNotFoundError as e: + pass + else: + raise AssertionError('kotlinx.coroutines.flow.internal imported; the decline did not reach Python') + """.trimIndent(), + ) + } +} diff --git a/ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactTableTest.kt b/ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactTableTest.kt new file mode 100644 index 000000000..77c635a40 --- /dev/null +++ b/ksp-fixtures/klib-artifact/src/androidNativeArm64Test/kotlin/fixture/artifact/WalkedKlibArtifactTableTest.kt @@ -0,0 +1,96 @@ +package fixture.artifact + +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The klib walker end to end, on the one Kotlin/Native target this fixture builds for: a `.klib` + * the build resolved -> `KlibScanner` -> a generated (empty) `ArtifactTable`, compiled and linked + * against the real klib. + * + * ### Why `ArtifactTable.fragments` is empty, and that is what this suite pins + * + * `kotlinx-coroutines-core`'s public surface is almost entirely extension functions on + * `CoroutineScope`/`Job`/`Flow`, none of which are bindable boundary types today, plus a small number + * of `@PublishedApi internal` declarations (`kotlinx.coroutines.flow.internal.checkIndexOverflow` + * among them) that `KlibScanner` **used to** bind before this suite ever ran on a device: it is + * binary-visible so an inline call site inside `kotlinx-coroutines-core` itself can resolve it, but + * not source-visible from a generated fragment in a *different* module, and + * `:ksp-fixtures:klib-artifact:compileKotlinAndroidNativeArm64` failing with "it is internal in file" + * -- the first time this walker's generated Kotlin was ever actually compiled rather than only + * unit-tested -- is what caught it. `KlibScanWorkAction`'s KDoc has the full story; `KlibScannerTest + * .aPublishedApiInternalDeclarationIsDeclinedWithItsOwnReason` pins the fix on the JVM side, and this + * class pins that the fix reaches all the way through a real Kotlin/Native compile, link and run. + * + * `WalkedKlibArtifactPythonImportTest`, beside this file, carries the same path *through Python* -- + * both classes exist to demonstrate that an *empty* table is still wired correctly end to end, not + * merely absent. + * + * ### This suite has no Gradle task that runs it + * + * `androidNativeArm64` gets `linkDebugTestAndroidNativeArm64` from KGP and nothing that *executes* + * the binary -- KGP registers a run task only where it knows how to reach a host (the build machine, + * or `simctl`), and an Android device is neither. `:python-multiplatform` solves that for itself with + * a hand-written `androidNativeArm64Test` task (`adb push` + `PYTHONHOME` + a TeamCity-to-JUnit-XML + * parser, ~200 lines of its own `build.gradle.kts`) and that machinery is project-local. So this + * fixture's suite is **compiled and linked by the ordinary build, and run by pushing `test.kexe` to a + * device by hand** -- see this module's `build.gradle.kts` for the exact command. Until that is + * wired, a green ordinary build says these tests *compile*, not that they *pass*. + */ +class WalkedKlibArtifactTableTest { + + @BeforeTest + fun startFromAnEmptyTable() { + UpcallTable.clear() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** + * The fixed text: everything `KlibScanner` bound out of the whole `kotlinx-coroutines-core` + * klib under the `kotlinx.coroutines` namespace -- nothing, correctly. This class's own KDoc has + * the reason; `KlibScannerTest.aRealThirdPartyKlibIsScannedWithoutCrashingAndDeclinesEveryDeclaration` + * pins the same fact walking the klib directly rather than through the build. If the two ever + * disagree, the wiring between `KlibScanner` and this generated table is what broke. + * + * A generated fragment file is only ever written when a walked artefact contributes at least one + * entry (`PythonArtifactBindingsTask.generate`'s `if (entries.isEmpty()) return@forEach`), so an + * empty klib means `ArtifactTable.fragments` itself has zero elements -- not one fragment with an + * empty `entries()`. + */ + @Test + fun theWalkerCorrectlyDeclinesEveryDeclarationInTheKlib() { + assertEquals(emptyList(), ArtifactTable.fragments) + ArtifactTable.registerInto() + assertEquals(emptyList(), UpcallTable.entries()) + } + + /** + * `registerInto` on an empty `ArtifactTable` must not fail, and must not resolve a name that was + * never bound -- the two failure modes a table with zero fragments could otherwise hide. + */ + @Test + fun registeringAnEmptyTableIsHarmlessAndResolvesNothing() { + ArtifactTable.registerInto() + val entry = UpcallTable.resolve("kotlinx.coroutines.flow.internal.checkIndexOverflow") + assertTrue(!entry.isValid, "checkIndexOverflow must not resolve: it is @PublishedApi internal and must stay declined") + } + + /** `registerInto` adds; a second call must not double the table -- the same guard + * `WalkedArtifactTableTest.registeringTwiceIsANoOp` pins for the JVM producer. */ + @Test + fun registeringTwiceIsANoOp() { + ArtifactTable.registerInto() + val once = UpcallTable.callableCount + ArtifactTable.registerInto() + assertEquals(once, UpcallTable.callableCount) + } +} diff --git a/ksp-fixtures/klib-artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt b/ksp-fixtures/klib-artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt new file mode 100644 index 000000000..363edd21e --- /dev/null +++ b/ksp-fixtures/klib-artifact/src/commonMain/kotlin/fixture/artifact/OwnDeclarations.kt @@ -0,0 +1,18 @@ +package fixture.artifact + +/** + * One declaration of this module's own, so the end-to-end test can show both producers answering in + * the same Python interpreter: this one reaches Python through KSP's `FunctionTable`, and + * `kotlinx.coroutines.flow.internal.checkIndexOverflow` reaches it through the walker's + * `ArtifactTable`. + * + * **Kept, not deleted with the rest of the copied-from-`:artifact` fixture.** Everything else this + * module inherited from `:ksp-fixtures:artifact` was a *jar*-path duplicate and went, but this file + * is not about jars: `pythonBindings.role.set("app")` makes this module the aggregator, and an + * aggregator with no source declarations of its own emits an **empty** `FunctionTable`. That + * compiles and passes, and it quietly stops the fixture from demonstrating the one property + * `docs/design/ecosystem.md` §5b actually rests on -- that KSP's producer and the artefact walker's land in + * one `UpcallTable` under one Python namespace. `WalkedKlibArtifactPythonImportTest + * .kspAndTheKlibWalkerShareOnePythonNamespace` is that demonstration, and it needs this to exist. + */ +fun whichSideAmIFrom(): String = "ksp" diff --git a/ksp-fixtures/library/build.gradle.kts b/ksp-fixtures/library/build.gradle.kts new file mode 100644 index 000000000..4b52c23bd --- /dev/null +++ b/ksp-fixtures/library/build.gradle.kts @@ -0,0 +1,42 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + // One line in place of the KSP plugin, two per-target `add("ksp", ...)` lines and a + // `ksp { arg(...) }` block. The role defaults to `library`; the module name is derived from + // the project's group and path. + id("io.github.thisisthepy.python.multiplatform.bindings") +} + +kotlin { + jvm("desktop") { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + // The Native target that actually verifies `.klib` fragment discovery + // (docs/design/upcall.md §2.9's open question). Compile-only here: androidNativeArm64 + // targets a device/emulator this workspace does not run tests against. + androidNativeArm64() + // `:ksp-fixtures:app` grew a second Native leaf so that it would have an intermediate + // `androidNativeMain` -- ROADMAP §13's `iosMain` shape, without Xcode. A consumer's targets + // have to be a subset of its dependency's, so this one follows or the app module fails + // dependency resolution ("Unresolved platforms: [androidNativeX64]") before compiling. + androidNativeX64() + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.pythonMultiplatform) + } + } + } +} + +pythonBindings { + // In-repo consumer: the processor is a project here, not a published artifact. Outside this + // repo the default coordinates apply and this line is not needed. + processor.set(projects.pythonMultiplatformKsp) +} diff --git a/ksp-fixtures/library/src/commonMain/kotlin/fixture/library/Declarations.kt b/ksp-fixtures/library/src/commonMain/kotlin/fixture/library/Declarations.kt new file mode 100644 index 000000000..75a27a9c1 --- /dev/null +++ b/ksp-fixtures/library/src/commonMain/kotlin/fixture/library/Declarations.kt @@ -0,0 +1,181 @@ +package fixture.library + +import python.multiplatform.reflection.PythonInternal + +/** + * The declaration kinds ROADMAP §7 recorded as not exposed -- companion members, interfaces, + * enums, annotation classes -- plus the shapes that made the *generated* file fail to compile + * before the generator learned about them (generics, abstract classes). + * + * Everything here exists to be asserted against in + * `ksp-fixtures/app/src/desktopTest/.../GeneratedDeclarationKindsTest.kt`, against the table KSP + * actually generated rather than a hand-written fragment. + */ + +// ------------------------------------------------------------------ top level, beyond functions + +val libraryVersion: String = "1.0" + +var mutableCounter: Long = 0 + +/** A parameter whose type has type arguments. `args[0] as kotlin.collections.List` is not valid + * Kotlin, so this compiles only if the generator renders the arguments too. */ +fun listSize(items: List): Long = items.size.toLong() + +/** Not exposable: `args[0] as T` has no type to name in the generated fragment. */ +fun identity(value: T): T = value + +// ------------------------------------------------------------------------------ companion, object + +class WithCompanion(val id: Long) { + companion object { + const val TAG: String = "with-companion" + + var created: Long = 0 + + fun create(id: Long): WithCompanion { + created += 1 + return WithCompanion(id) + } + } +} + +/** A Kotlin singleton: one instance, so every member is reached without a receiver. */ +object Registry { + val label: String = "registry" + + var size: Long = 0 + + fun ping(): String = "pong" +} + +// ---------------------------------------------------------------------------------- interfaces + +interface Greeter { + val salutation: String + + fun greet(name: String): String = "$salutation, $name!" + + companion object { + fun polite(): Greeter = PoliteGreeter("Good day") + } +} + +/** Implements [Greeter] without redeclaring `greet`: the only way Python can call it on this + * instance is through the interface's own entry. */ +class PoliteGreeter(override val salutation: String) : Greeter + +// --------------------------------------------------------------------------------------- enums + +enum class Color { + RED, + GREEN, + BLUE, +} + +enum class Level(val weight: Long) { + LOW(1), + HIGH(10), + ; + + fun describe(): String = "$name/$weight" +} + +@PythonInternal +enum class HiddenEnum { + A, +} + +// ------------------------------------------------------------- annotation classes: not exposed + +annotation class Marker(val value: String) + +// ------------------------------------------------------- nesting, abstraction, generic classes + +class Outer { + enum class State { + ON, + OFF, + } + + object Nested { + fun hello(): String = "nested" + } +} + +abstract class AbstractBase(val tag: String) { + fun describe(): String = "base:$tag" +} + +class ConcreteChild : AbstractBase("child") + +/** Not exposable: neither `as Box` nor `as Box<*>` gives the generated code a receiver its + * members type-check against. */ +class Box(val value: T) + +/** Observation fixture for what KSP reports for a `data class`'s compiler-generated members. */ +data class Point(val x: Long, val y: Long) + +// -------------------------------------------------------- a `var` whose setter is not public API + +/** + * ROADMAP §13's second defect: `FragmentScanner` decided whether to emit a setter entry from + * `property.isMutable` alone, and `isMutable` is `true` for all four properties below. Only + * [openSet] has a setter the generated fragment may use. + * + * The three restricted ones fail differently, which is why one of them would not have been + * enough: + * + * - `private set` -- the generated assignment does not compile at all + * ("Cannot assign to 'privateSet': the setter is private in ..."). + * - `protected set` -- the same, from outside the class. + * - `internal set` -- **compiles**, because the fragment is generated into this module's own + * compilation and `internal` is enforced per Kotlin module. It is still wrong: what the table + * describes is the public API, and the same shape in a module that only *consumed* this one + * would not compile. Fixing only the loud failure would have left this one in the table. + * + * `open` rather than `class` so that `protected` has a meaning here. + */ +open class RestrictedSetters { + var privateSet: Long = 1 + private set + + var protectedSet: Long = 2 + protected set + + var internalSet: Long = 3 + internal set + + /** The control: an ordinary `var`, whose setter entry must survive. */ + var openSet: Long = 4 + + /** Lets a test move the read-only values without there being a setter entry. */ + fun bumpAll() { + privateSet += 1 + protectedSet += 1 + internalSet += 1 + } +} + +/** [RestrictedSetters] for the `STATIC_SETTER` path: a top-level `var` goes through + * `FragmentScanner.staticPropertyEntries`, which carried the same `isMutable`-only branch. */ +var topLevelPrivateSet: Long = 7 + private set + +/** The control for [topLevelPrivateSet]. */ +var topLevelOpenSet: Long = 8 + +/** Moves [topLevelPrivateSet] without exposing a setter. */ +fun bumpTopLevelPrivateSet() { + topLevelPrivateSet += 1 +} + +/** The `object` form of the same shape; `Registry.size` above is the mutable control. */ +object RestrictedRegistry { + var counted: Long = 0 + internal set + + fun count() { + counted += 1 + } +} diff --git a/ksp-fixtures/library/src/commonMain/kotlin/fixture/library/LibFunctions.kt b/ksp-fixtures/library/src/commonMain/kotlin/fixture/library/LibFunctions.kt new file mode 100644 index 000000000..803240116 --- /dev/null +++ b/ksp-fixtures/library/src/commonMain/kotlin/fixture/library/LibFunctions.kt @@ -0,0 +1,61 @@ +package fixture.library + +import python.multiplatform.ffi.PyObject +import python.multiplatform.reflection.PythonInternal + +fun add(a: Long, b: Long): Long = a + b + +fun greet(name: String): String = "Hello, $name!" + +fun scale(value: Int, by: Int): Int = value * by + +fun noArgs() { + // exercises the Unit-return path +} + +@PythonInternal +fun hiddenFromPython(): String = "must not appear in the generated table" + +class Counter(var count: Long = 0) { + fun increment(by: Long): Long { + count += by + return count + } + + fun label(prefix: String): String = "$prefix$count" + + /** A method returning a Kotlin class (#94): the result must be a `Counter` proxy. */ + fun twin(): Counter = Counter(count) + + @PythonInternal + fun hiddenMethod(): Long = -1L +} + +/** A top-level function returning a Kotlin class (#94): the result must be a `Counter` proxy. */ +fun makeCounter(start: Long): Counter = Counter(start) + +/** Holds Python references directly, the way a real exposed class would -- exercises the + * generator's `tp_traverse` field-detection path (docs/design/object-lifetime.md). */ +class RefHolder(var primary: PyObject?, var secondary: PyObject?) + +/** + * A `PyObject`-typed parameter reached from a proxy that renders on the *plain* owner: [Counter] + * has no `PyObject` field, so `hasTraverse` is false for it and its proxy is a `_PmObject`. That is + * the shape `99acd830` filed -- `_pm_unwrap` turns any `_PmObject` into its raw handle, and this + * cast is what fails when it does. + */ +fun echoObject(value: PyObject?): PyObject? = value + +/** + * A parameter declared as a Kotlin class, beside [RefHolder.primary]'s parameter declared as a + * `PyObject`. Both cross as `TypeTag.OBJECT` and the two need opposite unwrapping decisions in the + * generated proxy, which is what `ProxyObjectArgumentTest` pins: a proxy passed here must still be + * unwrapped to its handle and resolved back to the Kotlin object, or this cast fails. + */ +fun describeHolder(holder: RefHolder): String = + "primary=${holder.primary != null},secondary=${holder.secondary != null}" + +@PythonInternal +class HiddenClass { + fun shouldNeverBeReachable(): Int = 0 +} diff --git a/ksp-fixtures/library/src/commonMain/kotlin/fixture/library/Suspending.kt b/ksp-fixtures/library/src/commonMain/kotlin/fixture/library/Suspending.kt new file mode 100644 index 000000000..d07cb8b33 --- /dev/null +++ b/ksp-fixtures/library/src/commonMain/kotlin/fixture/library/Suspending.kt @@ -0,0 +1,170 @@ +package fixture.library + +import kotlin.jvm.JvmInline + +/** + * Every shape a `suspend` function can take in a scanned module, so that what the generator does + * with it is a *measured* fact rather than a reading of [python.multiplatform.ksp.BindingPolicy]. + * + * `docs/design/upcall.md` explains why none of these can be in the table as they stand: a + * CPython callback slot is a C frame that must hand back a `PyObject *` before it returns, and a + * suspension has nothing to hand back. The fixtures exist so that the day an async convention + * lands, the change in behaviour shows up as these tests failing rather than as silence. + * + * Each suspending declaration is paired with a non-suspending control in the same scope, so a + * test asserting the suspending one is absent cannot pass by the whole scope having been dropped. + */ + +// ------------------------------------------------------------------------------------ top level + +/** Nothing about the body suspends; the modifier alone is the subject. */ +suspend fun suspendingTopLevel(x: Long): Long = x * 2 + +/** The control for [suspendingTopLevel], in the same file and package. */ +fun blockingTopLevel(x: Long): Long = x * 2 + +// -------------------------------------------------------------------------------------- members + +class SuspendingService(val id: Long) { + + suspend fun fetch(key: String): String = "async:$key" + + /** The control: the class must stay exposed and constructible with a suspending member. */ + fun fetchBlocking(key: String): String = "sync:$key" + + /** + * A `val` whose *type* is suspending, as opposed to a suspending accessor -- the member-scope + * sibling of top-level [suspendingHandler]. `BindingPolicy.isSuspending` only ever sees this + * as a `GETTER`-shaped declaration with no `suspend` modifier of its own, so the property-type + * check ([python.multiplatform.ksp.BindingPolicy.hasExposableTypes]) is what has to catch it. + */ + val handler: suspend (Long) -> Long = { it + 1 } + + /** The member-function-parameter sibling of top-level [runsHandler]. */ + fun runsHandlerMember(handler: suspend (Long) -> Long): String = + "member:" + handler.toString().substringBefore('@') + + /** The member-function-return sibling of top-level [makeHandler]. */ + fun makesHandlerMember(): suspend (Long) -> Long = { it + 1 } + + companion object { + suspend fun create(id: Long): SuspendingService = SuspendingService(id) + + /** The control for [create]. */ + fun createBlocking(id: Long): SuspendingService = SuspendingService(id) + + /** The companion-scope sibling of top-level [suspendingHandler]: folded onto the owner's + * static surface by [python.multiplatform.ksp.FragmentScanner.companionEntries], a + * separate code path from the member-property one [handler] exercises. */ + val companionHandler: suspend (Long) -> Long = { it + 1 } + } +} + +interface SuspendingSource { + suspend fun load(): String + + /** The control: an interface with a suspending member is still exposed for the rest. */ + fun describe(): String = "source" +} + +object SuspendingRegistry { + suspend fun ping(): String = "async-pong" + + /** The control. */ + fun pingBlocking(): String = "pong" +} + +// ------------------------------------------------------- suspending *types*, not suspending funs +// +// None of the declarations below carry `Modifier.SUSPEND` -- `BindingPolicy.isSuspending` reads +// that modifier on a *declaration*, and a `suspend` function *type* used as a property, parameter +// or return type carries no such modifier. What makes each one unexposable is +// `KSType.isSuspendFunctionType` on the *type*, which is why the check lives beside +// `hasRenderableSignature` in `BindingPolicy` as its own predicate +// (`BindingPolicy.hasExposableTypes`) rather than folded into the declaration-shape checks. +// +// `docs/design/upcall.md` §5.3 measured the base case: the generated cast +// (`args[0] as kotlin.coroutines.SuspendFunction1<...>`) compiles and the runtime checkcast +// passes, because that classifier is real to the Kotlin compiler even though nothing in this +// compilation ever declares it in source -- there is no `invoke` entry for Python to call through, +// so what would cross is a handle Python can hold and hand back and nothing else. Everything from +// [handlerList] onward is a variation on where that same unusable classifier can hide: a generic +// type argument, a nullable wrapper, and a `typealias`. + +/** + * A top-level property whose type is a suspending function type. + * + * This is a different question from a `suspend fun`: [python.multiplatform.ksp.BindingPolicy] + * reads the *modifier* on a declaration, and there is no modifier here -- the suspension is in + * the type. Whether the generated cast for it compiles is the thing being pinned. + */ +val suspendingHandler: suspend (Long) -> Long = { it + 1 } + +/** The same question on the parameter side. */ +fun runsHandler(handler: suspend (Long) -> Long): String = handler.toString().substringBefore('@') + +/** And on the return side. */ +fun makeHandler(): suspend (Long) -> Long = suspendingHandler + +/** + * A `List` whose element type is a suspending function type. + * + * The outer type ([kotlin.collections.List]) is an ordinary, renderable classifier -- the boundary + * already has no collection marshalling for it (a separate, older limitation), so on its own a + * `List` property would just be an inert `OBJECT` handle, the same as any other unmarshalled + * class. What makes this fixture about *this* task rather than that one is the element type: the + * generated cast for a *parameter* of this shape ([runsHandlerList]) nests the unusable classifier + * one level down (`kotlin.collections.List>`), so + * `isExposableType` has to recurse into type arguments rather than only look at the outermost one. + */ +val handlerList: List Unit> = emptyList() + +/** The parameter-position sibling of [handlerList], where the nested cast is actually written. */ +fun runsHandlerList(handlers: List Unit>): Int = handlers.size + +/** A nullable suspending function type: [isExposableType] must not be fooled by the `?`. */ +val nullableHandler: (suspend (Long) -> Long)? = null + +/** + * `typealias` for a suspending function type. + * + * Measured, not assumed to behave like [suspendingHandler]: `KSType.isSuspendFunctionType` + * answers `false` for the alias-typed `KSType` itself (`KSType.declaration` is the `KSTypeAlias`, + * and the check does not look through it on its own). Without expanding one level of alias in + * `isExposableType`, [runsAliasedHandler] would generate a cast to `fixture.library.LongHandler` + * -- a real, source-declared classifier this time, since the `typealias` itself is a declaration + * in this compilation -- and pass every check while remaining exactly as uncallable underneath. + */ +typealias LongHandler = suspend (Long) -> Long + +/** The property-position sibling of [runsAliasedHandler]. */ +val aliasedHandler: LongHandler = { it + 1 } + +/** The parameter-position case a `typealias` has to be expanded to catch. */ +fun runsAliasedHandler(handler: LongHandler): String = handler.toString().substringBefore('@') + +/** + * A `value class` whose single property is a suspending function type. + * + * The class itself is not the problem -- [ValueHandler] is an ordinary, constructible, + * source-declared classifier, and [runsValueHandler] casting a parameter to it is exactly as fine + * as casting to any other exposed class. What is unexposable is *inside* it: the primary + * constructor's parameter type and the property's own type are both `suspend (Long) -> Long`, so + * [ValueHandler.][ValueHandler] and [ValueHandler.f] must each be individually excluded + * while the class itself stays exposed with whatever surface remains (here, nothing -- both of + * its two members are this shape, so the generated `ReflectedClass` for it carries an empty + * `memberNames`). This is also the fixture that exercises the constructor code path specifically: + * `FragmentScanner`'s primary-constructor selection does not route through + * `BindingPolicy.isExposedMemberFunction`/`isExposedTopLevelFunction` the way every other function + * entry does, so it needed its own, separate wiring to the same [isExposableType] check. + */ +@JvmInline +value class ValueHandler(val f: suspend (Long) -> Long) + +/** The control: an *ordinary* (non-suspending) function type stays exposed as an `OBJECT` + * handle exactly as it did before this task -- only `suspend` ones are unrenderable. */ +val plainHandler: (Long) -> Long = { it + 1 } + +/** The control for [ValueHandler]: passing an already-exposed class as a parameter is unaffected + * by anything unexposable living inside that class. */ +fun runsValueHandler(v: ValueHandler): String = v.toString() diff --git a/python-checksums.properties b/python-checksums.properties new file mode 100644 index 000000000..c7b46d0cf --- /dev/null +++ b/python-checksums.properties @@ -0,0 +1,23 @@ +# Python Multiplatform Checksums +android-aarch64-3.14.7=6d50cc3aa66e414a439594089bcdfb5f1264358155c70c1f00471c24cfb477fb +android-aarch64-3.15.0rc1=7d6fb28a69368841656f919a08141b0e3720837ac67987388037ee9d2a5c5492 +android-x86_64-3.14.7=2c16ce2359565cd8c24f86cfb75630768ba6607e732946b294b969797f583b60 +android-x86_64-3.15.0rc1=f048f5c60b538efc64e4c26399a0a21202ce430949d5b2c10369d624548a5dc3 +ios-3.14-b11=b591f3301bd22a4f423c49c746cac9e55558b909fd14d6eb8327ccc62234ab7b +ios-3.15.0rc1-pythonorg=178bf7bef9cd0f18b27cacb98b14332a5ba44c9427543ffb71f8809bc1f11c3c +linux-x86_64-3.14.7-20260807=a2478d654ed51d443bae21ec20ad927f116b4f5aae4094ab74918a6aa38f0575 +linux-x86_64-3.14.7-20260807-freethreaded=13f80b7fc242814c0be4e2a4d8283f39c7e1c0f3f0d8f6d6a15cedb5b62eb6d3 +linux-x86_64-3.15.0rc1-20260807=aeb0d2cfe5e7028170039a95b96d74aa6830efe2fc49808122aa8f835d6e9f31 +linux-x86_64-3.15.0rc1-20260807-freethreaded=c04c940dbf8eca39c66c73d8c51330341a5a7f9b3b13f689ec14521c6357a44d +macos-aarch64-3.14.7-20260807=532645be202d3df8510ab318ca5faed92060a694c8327144e14fa81f31af0f6d +macos-aarch64-3.14.7-20260807-freethreaded=618fe8d34b0704ce4a8aa2cfa6fa96063d6aa48970a131f19e1591d9785d1111 +macos-aarch64-3.15.0rc1-20260807=bfd0fa8be7d7dfb5c75f6848757feb4bb9e4274da634e97e77aa00c8e521a6a0 +macos-aarch64-3.15.0rc1-20260807-freethreaded=7dc7e3758b6a77358b24d795e0a7eee3b5f3d9bc5fa0e3e3c65db52898f07815 +macos-x86_64-3.14.7-20260807=9964e2d618ebea03be8ea3e65ab0ecc0f2b030ce203345b8f92654641fd4de66 +macos-x86_64-3.14.7-20260807-freethreaded=746207f0d2644c5c98619f9d0ed37824d9871ea8d27640edc779a46d85038b3b +macos-x86_64-3.15.0rc1-20260807=f36c0921d03468747ed74c434e4b04749a35593718cd641d0f08fa01e9c7885e +macos-x86_64-3.15.0rc1-20260807-freethreaded=69beed4659c78b3683f1ea56c12956c7234f16e0417edf24f7f69c5a9f7b09ec +windows-x86_64-3.14.7-20260807=840e368318d1630bd8efbd3f7de8fab32cb4bbd735baef733f9034f2676c6a92 +windows-x86_64-3.14.7-20260807-freethreaded=c8323293c9e76e8b244a0e533894512f067dac2a6aa2dfcb3494cbe74b3af58e +windows-x86_64-3.15.0rc1-20260807=bc0d0f3c7b88873688f30bc78da8387a6653a5493424951d3a84c658c973198e +windows-x86_64-3.15.0rc1-20260807-freethreaded=e5998e014f16074ac9f6d61eb2a31ff8ac8bc331b61da2c5acf7d15abf4a2650 diff --git a/python-multiplatform-compose/build.gradle.kts b/python-multiplatform-compose/build.gradle.kts new file mode 100644 index 000000000..bee7b9502 --- /dev/null +++ b/python-multiplatform-compose/build.gradle.kts @@ -0,0 +1,52 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/** + * The Compose host for python-multiplatform: the `@Composable` entry point that draws a Python-declared + * root (`PythonContent`) and the composer/callable plumbing it shares with every Python call into a + * composable. + * + * A separate module so that `:python-multiplatform` itself never depends on Compose (same naming as + * `-ksp` and `-gradle-plugin`). JVM targets first -- desktop and Android share the code, which is all + * in `commonMain`; iOS and wasm come later. + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.jetpack.compose) + alias(libs.plugins.compose.compiler) +} + +kotlin { + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + jvm("desktop") { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + sourceSets { + commonMain.dependencies { + api(projects.pythonMultiplatform) + api(compose.runtime) + } + } +} + +android { + namespace = "python.multiplatform.compose" + compileSdk = libs.versions.android.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonCallableArena.kt b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonCallableArena.kt new file mode 100644 index 000000000..63b545ba3 --- /dev/null +++ b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonCallableArena.kt @@ -0,0 +1,51 @@ +package python.multiplatform.compose + +import androidx.compose.runtime.RememberObserver +import python.multiplatform.ffi.pythonx.PythonCallableScope +import python.multiplatform.ffi.pythonx.PythonCallables + +/** + * Who holds the Python callables a composition passes into Kotlin (`content=lambda: ...`), and the one + * hook that says when to let go of them. + * + * A Python callable given to a composable has to outlive the call that passed it -- Compose stores it + * in the slot table and calls it on later recompositions -- so something must hold a Python + * reference for it. The only thing whose lifetime *is* the composition's is a remembered value, and + * [RememberObserver.onForgotten] is the only hook that reports Compose dropping it. + * + * | candidate holder | what it gets wrong | + * |---|---| + * | the composition **pass** | ends when the pass returns, and Compose calls a stored `content` on every later recomposition | + * | the `PyObject`'s own cleaner | fires whenever the collector reaches the Kotlin wrapper, which is not a time and is not every platform | + * | `HandleTable` alone | a strong root nothing gives back | + * | **this** | `onRemembered` ... `onForgotten` is exactly the interval in which Compose may call the content | + * + * Use it as `remember { PythonCallableArena() }` and run the Python pass inside + * `PythonCallables.withScope(arena.scope) { ... }`. + * + * `onAbandoned` closes the scope too: it is the case where the composition that created this was + * discarded before it was applied, so `onForgotten` will never come. [PythonCallableScope.close] is + * idempotent and answers how many callables it actually released (`0` on every later call), and + * [released] accumulates that -- so a total higher than what crossed can only be a double release. + * + * Open so that a test harness can count the lifecycle; subclasses must call `super`. + */ +open class PythonCallableArena : RememberObserver { + + /** The scope Python callables crossing during this arena's passes are registered in. */ + val scope: PythonCallableScope = PythonCallables.newScope() + + /** What [PythonCallableScope.close] reported, summed over every close. */ + var released: Int = 0 + private set + + override fun onRemembered() {} + + override fun onForgotten() { + released += scope.close() + } + + override fun onAbandoned() { + released += scope.close() + } +} diff --git a/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonComposer.kt b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonComposer.kt new file mode 100644 index 000000000..b77243e49 --- /dev/null +++ b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonComposer.kt @@ -0,0 +1,40 @@ +package python.multiplatform.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.currentComposer +import python.multiplatform.ffi.Python3 +import python.multiplatform.reflection.HandleTable + +/** + * Runs [block] -- Python code that may call bound `@Composable`s -- with this composition's composer + * made available to the binding layer. + * + * `$composer` is not a value but a *position*: `currentComposer` resolves to the composer parameter of + * the enclosing composable, so the only way to obtain one is to be inside a composition. Everything + * else about calling a composable from Python (which arguments were written, the `$default` mask, + * `$changed`) is arithmetic the binding layer does in Python (`python_multiplatform.binding`'s + * `_bind_composable`), which is why this is the one thing Kotlin has to supply. + * + * The composer crosses as an ordinary `TypeTag.OBJECT` handle, pushed onto the binding layer's + * composer stack for exactly the duration of [block] and released after it, so its lifetime is this + * call's: `push_composer` retains nothing. + * + * [block] is not `@Composable`: it composes through Python, not through Kotlin call sites. + */ +@Composable +fun withPythonComposer(block: () -> T): T { + val reference = HandleTable.register(currentComposer) + try { + Python3.exec("import $BINDING_MODULE as _pm_binding\n_pm_binding.push_composer(${reference.raw})") + try { + return block() + } finally { + Python3.exec("import $BINDING_MODULE as _pm_binding\n_pm_binding.pop_composer()") + } + } finally { + HandleTable.release(reference) + } +} + +/** The binder's own binding layer (`KotlinSurface`'s `BINDING_MODULE`). Not a library name. */ +private const val BINDING_MODULE = "python_multiplatform.binding" diff --git a/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt new file mode 100644 index 000000000..8de24e3e6 --- /dev/null +++ b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt @@ -0,0 +1,149 @@ +package python.multiplatform.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.RememberObserver +import androidx.compose.runtime.State +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonCallables +import python.multiplatform.reflection.HandleTable +import python.native.ffi.PyLong_AsLongLong + +/** + * Composes the Python application root found at [root], and recomposes whenever Python replaces it. + * + * [root] is one of: + * + * - **a Compose [State] that Python holds** -- the Python proxy of a Kotlin `State` / `MutableState` + * (anything carrying the binder's `_pm_handle` for one). Its `value` is the root: a Python callable + * taking no arguments, or `None`/`null` for "nothing yet". The value is read *inside* the + * composition, so Compose subscribes to it, and Python replacing the root is an ordinary state write + * (`state.value = Root`): the next frame composes the new root and nothing on the Kotlin side is + * called to make that happen. + * - **a Python callable** -- a root that never changes. + * + * The root is called with this composition's composer available to the binding layer + * ([withPythonComposer]), so it may call any bound `@Composable`. Python callables it passes into + * composables (`content=lambda: ...`) are held for as long as *that root* is composed + * ([PythonCallableArena]): replacing the root, or disposing the composition, gives them back. + * Replacing the root also discards the old root's composition state (its `remember`ed values), as + * calling a different composable would -- a redeclared root is a new function. + * + * The binder names no library here: what the root state is called, and which package declares it, + * is the host's and that package's business. + * + * [root] is the caller's: it is neither consumed nor closed. + */ +@Composable +fun PythonContent(root: PyObject) { + val source = remember(root) { RootSource.of(root) } + val current = source.current() + if (current != null) { + key(current) { + PythonRoot(current) + } + } +} + +/** + * [PythonContent] for a root state that lives at `module.attribute` -- `PythonContent("main", "app")` + * for a Python `main.app`. + * + * The attribute is resolved **once**, when this enters the composition (and again only if [module] + * or [attribute] change). Rebinding the module attribute afterwards is not observed; replacing the + * root is a write into the state the attribute names, which is. + */ +@Composable +fun PythonContent(module: String, attribute: String) { + val resolved = remember(module, attribute) { ResolvedAttribute(module, attribute) } + PythonContent(resolved.value) +} + +/** + * One root, composed. Its own composable so that the root's composition -- and the arena holding the + * Python callables it passed -- is keyed by the root and leaves when the root is replaced. + * + * A Python read of a Compose state during the call is recorded against this function's recompose + * scope like any other read, so a root that reads `state.value` is called again when that changes. + */ +@Composable +private fun PythonRoot(root: PyObject) { + val arena = remember { PythonCallableArena() } + // The composer call outermost: Compose forbids a try/finally around a composable invocation, + // and `withScope`'s is one once inlined. + withPythonComposer { + PythonCallables.withScope(arena.scope) { + root().close() + } + } +} + +/** Where the current root comes from. */ +private sealed class RootSource { + + /** The root as of this read. Called during composition, which is what subscribes to a [State]. */ + abstract fun current(): PyObject? + + class Fixed(private val root: PyObject) : RootSource() { + override fun current(): PyObject = root + } + + class FromState(private val state: State<*>) : RootSource() { + override fun current(): PyObject? = when (val value = state.value) { + null -> null + is PyObject -> value + else -> throw IllegalStateException( + "the Python root state holds a ${value::class.simpleName}, not a Python callable", + ) + } + } + + companion object { + fun of(root: PyObject): RootSource { + val handle = root.getAttrOrNull(HANDLE_ATTRIBUTE) + if (handle != null) { + val raw = try { + Python3.withPython { PyLong_AsLongLong(handle.pointer) } + } finally { + handle.close() + } + return when (val kotlin = HandleTable.resolveRaw(raw)) { + is State<*> -> FromState(kotlin) + null -> throw IllegalArgumentException("the Python root refers to a released Kotlin object") + else -> throw IllegalArgumentException( + "the Python root is a ${kotlin::class.simpleName}; expected a Compose State or a Python callable", + ) + } + } + require(root.isCallable) { "the Python root is neither a Compose State nor a Python callable" } + return Fixed(root) + } + + /** The attribute the binder's proxy of a Kotlin object carries its handle under. */ + private const val HANDLE_ATTRIBUTE = "_pm_handle" + } +} + +/** `module.attribute`, held for as long as the composition remembers it. */ +private class ResolvedAttribute(module: String, attribute: String) : RememberObserver { + + val value: PyObject = Python3.import(module).let { imported -> + try { + imported.getAttr(attribute) + } finally { + imported.close() + } + } + + override fun onRemembered() {} + + override fun onForgotten() { + value.close() + } + + override fun onAbandoned() { + value.close() + } +} diff --git a/python-multiplatform-gradle-plugin/build.gradle.kts b/python-multiplatform-gradle-plugin/build.gradle.kts new file mode 100644 index 000000000..4f767181e --- /dev/null +++ b/python-multiplatform-gradle-plugin/build.gradle.kts @@ -0,0 +1,372 @@ +// Imported rather than written as `java.util.Properties`: inside a Kotlin DSL build script, `java` +// resolves to the `JavaPluginExtension` accessor, not to the package root. +import java.util.Properties + +plugins { + `kotlin-dsl` + `maven-publish` +} + +group = "io.github.thisisthepy" +version = "3.13.0" // kept in step with the root build's version, which the processor is published under + +/** + * `KlibScanner`'s reader, named once because three places have to agree on it: this module's own + * compile classpath, its test runtime, and the coordinate the plugin hands a *consumer* to resolve + * at klib-walking time (`DEFAULT_KLIB_READER_COORDINATES`, generated below). + * + * It is deliberately **not** an `implementation` dependency -- see `verifyPluginRuntimeClasspath` + * at the bottom of this file for the consumer build failure that made it one thing this plugin may + * never publish. + */ +val klibReaderCoordinates = "org.jetbrains.kotlin:kotlin-compiler-embeddable:2.0.20" + +/** + * The rest of what `KlibScanWorkAction`'s isolated worker classloader needs, beside the reader and + * this plugin's own jar. + * + * `KlibScanner` calls `ArtifactScanner.disambiguateOverloads`, so loading it loads `ArtifactScanner` + * too, and that object's own classpath is ASM plus `kotlin-metadata-jvm`. They are ordinary + * `implementation` dependencies as well (below) because the *jar* walker runs in the task's own + * classloader; this list is the second, disjoint place they are needed, and naming them here is what + * lets the worker classpath be built from an explicit list of coordinates rather than from whatever + * happens to be on the plugin classloader -- which, in an external consumer, is the whole Kotlin + * Gradle Plugin. See `KlibScanWorkAction.pluginClasspath`'s KDoc for the measurement. + */ +val asmCoordinates = listOf("org.ow2.asm:asm:9.7.1", "org.ow2.asm:asm-tree:9.7.1") +val kotlinMetadataCoordinates = "org.jetbrains.kotlin:kotlin-metadata-jvm:2.0.20" +val klibWorkerCoordinates = listOf(klibReaderCoordinates, kotlinMetadataCoordinates) + asmCoordinates + +dependencies { + // The plugin applies KSP itself, so the KSP Gradle plugin has to be on its runtime classpath + // -- but deliberately *not* on its compile classpath. + // + // `kotlin-dsl` compiles against Gradle's embedded Kotlin (2.0.20 on Gradle 8.11.1; it was 1.9 + // on the 8.9 this build used before ROADMAP §13), and KSP 2.3.11 is compiled with Kotlin 2.3: + // "Class 'com.google.devtools.ksp.gradle.KspExtension' was compiled with an incompatible + // version of Kotlin ... the compiler version 1.9.0 can read versions up to 2.0.0". The + // Gradle bump narrowed that gap but did not close it -- 2.0.20 reads up to 2.1 -- so + // `runtimeOnly` still applies. It keeps the id resolvable (plugin ids are resolved from a + // classpath resource, not from compiled types) while the two `ksp { arg(...) }` calls go + // through `PythonBindingsPlugin.setKspArg`. + runtimeOnly("com.google.devtools.ksp:symbol-processing-gradle-plugin:${libs.versions.ksp.get()}") + + // The artefact walker (`docs/design/ecosystem.md` §5b's second producer) reads compiled class files + // out of the jars the build resolves. ASM is a *plugin* dependency and deliberately not a + // library one: nothing at runtime reads bytecode, and the walk happens once, at build time. + // + // Gradle bundles ASM, but under `org.gradle.internal.impldep.org.objectweb.asm` -- a relocated + // internal package with no compatibility promise -- so this is a real coordinate rather than a + // reach into Gradle's own copy. + asmCoordinates.forEach { implementation(it) } + + // `KlibScanner`'s reader: `org.jetbrains.kotlin.library.abi.LibraryAbiReader`, which decodes a + // `.klib`'s ABI protobuf into Kotlin-typed declarations. A klib is what a Kotlin/Native target + // resolves where a JVM target resolves a jar, so this is to `KlibScanner` exactly what ASM plus + // `kotlin-metadata-jvm` are to `ArtifactScanner` -- and a *plugin* dependency for the same + // reason both of those are: the walk happens once, at build time, and nothing this plugin emits + // into a consumer's build re-reads a klib at run time. + // + // **It is 55.6 MiB (58,251,465 bytes), by far the largest thing on this classpath, so where that + // lands was measured rather than assumed:** + // - not in the library. `python-multiplatform` does not depend on this plugin; + // `:python-multiplatform:dependencies` reports `kotlin-compiler-embeddable:2.0.20` in no + // configuration at all, `desktopRuntimeClasspath` included. + // - not on a consumer's *application* classpath. `:ksp-fixtures:app:dependencies` reports zero + // occurrences of `2.0.20`. (It does report `kotlin-compiler-embeddable:2.4.0` and + // `2.4.20-Beta2` under `kotlinCompilerPluginClasspath*` and the commonizer's configurations + // -- KGP's own, present before this dependency existed.) + // - it *was* on a consumer's **buildscript** classpath, and that is exactly what broke them: + // `implementation` becomes `runtime` scope in this plugin's published POM (checked in the + // generated `pom-default.xml`), so applying the plugin put a second copy of + // `org.jetbrains.kotlin.build.report.metrics.*` on the plugin classloader beside the Kotlin + // Gradle Plugin's own, and KGP could no longer decorate `KotlinCompile` -- `./gradlew tasks` + // failed outright in all three external consumer projects. `compileOnly` is what keeps it out + // of the POM; `verifyPluginRuntimeClasspath` (bottom of this file) is what keeps it out. + // + // Gradle 8.11.1 ships a byte-identical copy in its own `lib/` (same SHA-256), but plugin + // classloaders see a filtered view of Gradle's runtime -- `org.gradle.*` API packages, not + // `org.jetbrains.kotlin.library.abi` -- so this is a real coordinate rather than a reach into + // Gradle's own copy, for the same reason the ASM lines above are. + // + // ### Does not survive a consumer's classloader hierarchy called directly -- fixed with isolation + // + // Called from `PythonArtifactBindingsTask`'s/`PythonStubsTask`'s own classloader, + // `KlibScanner.scanKlib` fails at execution with `NoSuchMethodError: + // KotlinLibraryImplKt.createKotlinLibrary$default(...)`. Not a version conflict Gradle could + // resolve -- `buildEnvironment` reports this coordinate at 2.0.20 on that project's plugin + // classpath, correctly. It is **class mixing across classloader scopes**, measured by printing + // each class's `CodeSource` from inside the task: + // + // org.jetbrains.kotlin.library.abi.LibraryAbiReader <- 2.0.20, this jar + // org.jetbrains.kotlin.library.ToolingSingleFileKlibResolveStrategy <- 2.0.20, this jar + // org.jetbrains.kotlin.library.impl.KotlinLibraryImplKt <- kotlin-util-klib 2.4.20-Beta2 + // org.jetbrains.kotlin.konan.file.ZipFileSystemAccessor <- kotlin-util-io 2.4.20-Beta2 + // + // The last two come from the **root project's** buildscript scope, which the Kotlin Gradle Plugin + // populates and which is a *parent* of every subproject's plugin classloader. Parent-first + // delegation hands out `kotlin-util-klib`'s half of `org.jetbrains.kotlin.library.*` at KGP's + // version and this jar's half at 2.0.20, and the halves do not fit. Any consumer that declares + // KGP in its root `plugins {}` block reproduces it. + // + // Aligning the version does not fix it -- tried both ways: + // - `2.4.20-Beta2` alone does not compile: Gradle's embedded Kotlin is 2.0.20 and that jar's + // modules carry metadata 2.2.0-2.4.0 ("expected version is 2.0.0"). + // - with `-Xskip-metadata-version-check` it compiles and gets further, then fails with + // `NoClassDefFoundError: org/jetbrains/kotlin/protobuf/Internal$EnumLite` -- the parent's + // `kotlin-util-klib` needs the protobuf runtime shaded *inside this jar*, in the child scope + // it cannot see. The mixing is symmetric; neither version wins. + // + // **Fixed with real isolation**: `KlibScanWorkAction` + // (`python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact + // /KlibScanWorkAction.kt`) runs every `KlibScanner` call behind a `WorkerExecutor` + // `classLoaderIsolation` whose explicit classpath is this plugin's own classloader's `.urLs` -- + // the child scope only, none of the root buildscript scope above it. An isolated worker + // classloader's parent is Gradle's own minimal worker infrastructure, not that root scope, so + // `org.jetbrains.kotlin.library.*` has exactly one source inside the worker. Confirmed against a + // real consumer build, not only this plugin's own unit tests: + // `:ksp-fixtures:klib-artifact:generatePythonArtifactBindings` and `:generatePythonStubs` both + // pass and emit the walker's real output (`kotlinx.coroutines.flow.internal.checkIndexOverflow`). + // + // ### Compile-only here, resolved by the consumer at klib-walking time + // + // `KlibScanner` is the only file in this plugin that names a type from this jar, and it only ever + // runs inside `KlibScanWorkAction`'s isolated worker -- which builds its classloader from an + // *explicit* classpath, not from this one. So the reader has to be **compilable** here and + // **present in the worker**, and those are two different classpaths. Nothing needs it on the + // plugin's own runtime classpath in between, and the block above is the price of putting it there. + // + // The worker's copy arrives instead through a resolvable configuration the *consumer's* project + // owns: `PythonBindingsPlugin` creates a detached configuration for `DEFAULT_KLIB_READER_COORDINATES` + // and hands it to `PythonArtifactBindingsTask`/`PythonStubsTask` as `klibReaderClasspath`, which + // `scanKlibIsolated` appends to the worker classpath. It is resolved lazily -- a build that walks + // only jars never downloads it -- and pinned, so a consumer's own Kotlin version cannot move it. + // + // Pinned to 2.0.20 for the reason the `kotlin-metadata-jvm` comment below spells out, and that + // pin is also the version floor `KlibScanner`'s KDoc measures its extension-receiver decline + // against. + compileOnly(klibReaderCoordinates) + // `KlibScannerTest` calls `KlibScanner` in-process, in the test JVM, where there is no worker to + // hand a classpath to. The test runtime is not published and not a consumer classpath, so this is + // the one place the reader may still be an ordinary dependency. + testImplementation(klibReaderCoordinates) + + // Reads `@Metadata`'s `d1`/`d2` payload -- the facts ASM's view of a class file cannot reach: + // a multi-file facade's part classes, an extension function's receiver, a value class's + // underlying type and accessor, `suspend`. See `ArtifactScanner`'s KDoc. Also a *plugin* + // dependency, for the same reason ASM is: the walk happens once, at build time, and nothing + // this plugin emits into a consumer's own build re-reads bytecode at runtime. + // + // Pinned to "2.0.20" rather than `libs.versions.kotlin` (2.4.20-Beta2): this module's own + // `compileKotlin` runs on Gradle's *embedded* Kotlin compiler (2.0.20 on Gradle 8.11.1, see the + // `kotlin-dsl` comment above), which cannot read a dependency whose own metadata a newer + // compiler wrote -- "the actual metadata version is 2.4.0, but the compiler version 2.0.0 can + // read versions up to 2.1.0". The library's own read/write logic tolerates a wide range of + // *target* metadata versions regardless of which release of the library does the reading, so an + // older `kotlin-metadata-jvm` reading androidx's or kotlin-stdlib's newer-than-2.0.20 metadata is + // not the same constraint -- see `ArtifactScannerTest`'s real-jar cases, which this version reads + // correctly. + implementation(kotlinMetadataCoordinates) + + testImplementation(kotlin("test")) +} + +/** + * A real third-party jar for `ArtifactScannerTest` to walk, kept off the test *classpath* on + * purpose. + * + * `tasks.test` runs on the JUnit Platform and `kotlin-test` picks its framework from what it finds + * on the classpath; putting JUnit 4 there as well is exactly the kind of skew that makes the whole + * suite report zero tests without failing. A separate configuration gives the test a file path and + * nothing else -- which is all a walker needs, and is also how a consumer's build will hand it + * artefacts. + */ +val walkerFixtureJar: Configuration by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + walkerFixtureJar(libs.junit) { isTransitive = false } +} + +/** + * A real third-party klib for [KlibScannerTest], the klib counterpart of [walkerFixtureJar]. + * + * `kotlinx-coroutines-core-androidnativearm64` is Kotlin/Native's per-target leaf module -- an + * ordinary Maven artifact (a `.klib` file at a GAV coordinate, no Kotlin Gradle Plugin variant + * resolution involved) despite the platform in its name, so a plain resolvable configuration reaches + * it the same way [walkerFixtureJar] reaches a jar. `isTransitive = false` for the same reason too: + * the test wants one file, not `atomicfu` and `kotlin-stdlib` pulled in behind it. + */ +val walkerFixtureKlib: Configuration by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + walkerFixtureKlib("org.jetbrains.kotlinx:kotlinx-coroutines-core-androidnativearm64:1.8.1") { isTransitive = false } +} + +gradlePlugin { + plugins { + create("pythonMultiplatformBindings") { + id = "io.github.thisisthepy.python.multiplatform.bindings" + implementationClass = "python.multiplatform.gradle.PythonBindingsPlugin" + displayName = "Python Multiplatform bindings" + description = "Wires the python-multiplatform KSP processor into every Kotlin target of a module." + } + } +} + +/** + * The default processor coordinates have to name this build's own group and version, and a + * hand-copied string would drift the first time either changes. Generating a one-line source file + * keeps it derived. + */ +val generateCoordinates = tasks.register("generateCoordinates") { + val outputDir = layout.buildDirectory.dir("generated/coordinates") + // Plain `group`/`version` here resolve against the task itself (`Task.getGroup()` is a task + // category label, always null on this task; `Task` has no `version` property so that one + // falls through to the project by luck) -- not against the project, even though this lambda + // reads as if it were project-scoped. Qualifying both is what makes the generated coordinates + // name this build's actual group instead of the literal string "null". + val coordinates = "${project.group}:python-multiplatform-ksp:${project.version}" + + // The CPython distribution the *library* was built against, read from the root build's own + // `gradle.properties` rather than restated here. `StagePythonHomeTask` stages this exact + // upstream build for a consumer, and `desktopJar` carries the `libpython` taken from it, so a + // second copy of these values that drifted would pair a stdlib with an interpreter from a + // different build. The fallbacks match `python-multiplatform/build.gradle.kts`'s own, for the + // case where this build is checked out without the root's properties beside it. + val rootProperties = Properties().apply { + val file = rootDir.resolve("../gradle.properties") + if (file.isFile) file.inputStream().use { load(it) } + } + val pythonVersionValue = rootProperties.getProperty("pythonVersion") ?: "3.14.7" + val pbsReleaseValue = rootProperties.getProperty("pythonBuildStandaloneRelease") ?: "20260807" + val freeThreadedValue = rootProperties.getProperty("pythonFreeThreaded")?.toBoolean() ?: false + + // `python-multiplatform` itself is versioned "$pythonVersion-alpha01" -- see that build's own + // `val libraryVersion` -- which is *not* this plugin build's `project.version` (kept in step by + // hand, see the top of this file). The wasm-runtime zip is published under the library's + // version, alongside the library artifact it has to pair with, so the coordinate has to be + // built the same way rather than reusing this plugin's own version. + val libraryVersionValue = "$pythonVersionValue-alpha01" + val wasmRuntimeCoordinatesValue = "${project.group}:python-multiplatform-wasm-runtime:$libraryVersionValue" + + val klibWorkerValue = klibWorkerCoordinates + + inputs.property("coordinates", coordinates) + inputs.property("klibWorkerCoordinates", klibWorkerValue) + inputs.property("pythonVersion", pythonVersionValue) + inputs.property("pbsRelease", pbsReleaseValue) + inputs.property("pythonFreeThreaded", freeThreadedValue) + inputs.property("wasmRuntimeCoordinates", wasmRuntimeCoordinatesValue) + outputs.dir(outputDir) + doLast { + val file = outputDir.get().file("python/multiplatform/gradle/ProcessorCoordinates.kt").asFile + file.parentFile.mkdirs() + file.writeText( + """ + // GENERATED by the plugin build. Do not edit. + package python.multiplatform.gradle + + internal const val DEFAULT_PROCESSOR_COORDINATES: String = "$coordinates" + + /** The CPython release the `libpython` bundled in `desktopJar` was taken from. */ + internal const val DEFAULT_PYTHON_VERSION: String = "$pythonVersionValue" + + /** The python-build-standalone release tag that published it. */ + internal const val DEFAULT_PBS_RELEASE: String = "$pbsReleaseValue" + + internal const val DEFAULT_PYTHON_FREE_THREADED: Boolean = $freeThreadedValue + + /** + * `python-multiplatform`'s `wasmRuntime` publication -- see ROADMAP §10. Versioned + * with the library, not with this plugin, because that is the artifact it has to + * agree with about which CPython build is inside. + */ + internal const val DEFAULT_WASM_RUNTIME_COORDINATES: String = "$wasmRuntimeCoordinatesValue" + + /** + * Everything `KlibScanWorkAction`'s isolated worker classloader needs beside this + * plugin's own jar: the klib reader (`kotlin-compiler-embeddable`) and the classpath + * `ArtifactScanner` brings with it. + * + * Resolved by the *consumer's* project rather than shipped with the plugin -- see this + * build's `compileOnly` comment for why the reader may not be a dependency of the + * plugin at all, and `verifyPluginRuntimeClasspath` for what breaks when it is. + * + * Generated rather than restated so that the versions the worker resolves are the ones + * `KlibScanner` was compiled against; a drift between those two is the + * `NoSuchMethodError` class of failure this whole arrangement exists to avoid. + */ + internal val DEFAULT_KLIB_WORKER_COORDINATES: List = listOf( + ${klibWorkerValue.joinToString(",\n ") { "\"$it\"" }}, + ) + """.trimIndent() + "\n", + ) + } +} + +sourceSets.main { + kotlin.srcDir(generateCoordinates) +} + +/** + * Fails if anything a consumer would put on their **buildscript** classpath by applying this plugin + * carries the Kotlin compiler with it. + * + * This is not a style rule. `implementation("...kotlin-compiler-embeddable")` becomes `runtime` scope + * in the published POM, so applying the plugin id puts that jar on the plugin classloader *beside the + * Kotlin Gradle Plugin's own copy of the same classes*, and KGP then cannot even create its tasks: + * + * Could not create task of type 'KotlinCompile'. + * > Could not generate a decorated class for type KotlinCompile. + * > class org.jetbrains.kotlin.build.report.metrics.BuildTimeMetric has interface + * org.jetbrains.kotlin.build.report.metrics.BuildPerformanceMetric as super class + * + * Reproduced in every one of the three external consumer projects; a 100% hard failure, not the + * "unpredictable and inconsistent behavior" Gradle's own warning about the same jar suggests. The + * reader now reaches `KlibScanner` through [klibReaderClasspath] instead -- resolved by the consumer + * only when a klib is actually walked, and handed to the worker classloader that already isolates it. + * + * `runtimeClasspath` rather than the generated POM: it is the same set the POM's `runtime` scope is + * derived from, plus the transitives, so it also catches the jar arriving behind something else. + */ +val forbiddenRuntimeArtifacts = listOf("kotlin-compiler-embeddable", "kotlin-compiler") + +val verifyPluginRuntimeClasspath = tasks.register("verifyPluginRuntimeClasspath") { + group = "verification" + description = "Fails if the Kotlin compiler leaks onto a consumer's plugin classpath." + val resolved = configurations.named("runtimeClasspath") + .flatMap { provider { it.incoming.artifacts.resolvedArtifacts } } + .flatMap { it } + val forbidden = forbiddenRuntimeArtifacts + doLast { + val offenders = resolved.get() + .map { it.id.componentIdentifier.displayName } + .filter { name -> forbidden.any { name.contains(":$it:") } } + .sorted() + if (offenders.isNotEmpty()) { + error( + "python-multiplatform-gradle-plugin: ${offenders.size} artifact(s) would land on a " + + "consumer's buildscript classpath and break the Kotlin Gradle Plugin's own task " + + "creation: ${offenders.joinToString()}. See verifyPluginRuntimeClasspath's kdoc.", + ) + } + } +} + +tasks.named("check") { dependsOn(verifyPluginRuntimeClasspath) } + +tasks.test { + useJUnitPlatform() + inputs.files(walkerFixtureJar).withPropertyName("walkerFixtureJar").withPathSensitivity(PathSensitivity.NAME_ONLY) + // Resolved here rather than inside the test so the test never has to know a repository, a + // coordinate or a cache layout -- it reads one system property holding one path. + systemProperty("python.multiplatform.walkerFixtureJar", walkerFixtureJar.singleFile.absolutePath) + inputs.files(walkerFixtureKlib).withPropertyName("walkerFixtureKlib").withPathSensitivity(PathSensitivity.NAME_ONLY) + systemProperty("python.multiplatform.walkerFixtureKlib", walkerFixtureKlib.singleFile.absolutePath) +} diff --git a/python-multiplatform-gradle-plugin/settings.gradle.kts b/python-multiplatform-gradle-plugin/settings.gradle.kts new file mode 100644 index 000000000..b6ffa1260 --- /dev/null +++ b/python-multiplatform-gradle-plugin/settings.gradle.kts @@ -0,0 +1,16 @@ +rootProject.name = "python-multiplatform-gradle-plugin" + +dependencyResolutionManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + // One source of truth for the KSP version: this plugin applies the KSP plugin and + // pulls in the processor, and a version skew between the two is exactly the class of + // wiring bug the plugin exists to remove. + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/AcquiredHeaderVersion.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/AcquiredHeaderVersion.kt new file mode 100644 index 000000000..a95951aa5 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/AcquiredHeaderVersion.kt @@ -0,0 +1,32 @@ +package python.multiplatform.gradle + +import java.io.File + +/** + * Reads `PY_VERSION` out of the acquired CPython headers (`patchlevel.h`) and compares it with the + * pinned `pythonVersion` (issue #47). The archive name says what a download was meant to be; the + * header says what it is. + */ +object AcquiredHeaderVersion { + private val pyVersion = Regex("""^\s*#\s*define\s+PY_VERSION\s+"([^"]+)"""", RegexOption.MULTILINE) + + fun parse(patchlevelText: String): String = + requireNotNull(pyVersion.find(patchlevelText)) { "no PY_VERSION in patchlevel.h" }.groupValues[1] + + fun patchlevelFile(extractedRoot: File, target: String, pythonVersion: String): File = + extractedRoot.resolve(CPythonIncludeLayout.relativePath(target, CPythonFlavour.GIL, pythonVersion)).resolve("patchlevel.h") + + /** target -> description, for every GIL target whose headers exist and report a different version. */ + fun mismatches(extractedRoot: File, pythonVersion: String): Map = + CPythonIncludeLayout.allTargets.mapNotNull { t -> + val f = patchlevelFile(extractedRoot, t, pythonVersion) + if (!f.isFile) return@mapNotNull null + val actual = parse(f.readText()) + if (actual == pythonVersion) null else t to "headers say PY_VERSION $actual, expected $pythonVersion ($f)" + }.toMap() + + fun requireAll(extractedRoot: File, pythonVersion: String) { + val bad = mismatches(extractedRoot, pythonVersion) + check(bad.isEmpty()) { "Acquired CPython does not match pythonVersion=$pythonVersion:\n" + bad.entries.joinToString("\n") { " ${it.key}: ${it.value}" } } + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/CPythonIncludeDirectories.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/CPythonIncludeDirectories.kt new file mode 100644 index 000000000..009e09163 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/CPythonIncludeDirectories.kt @@ -0,0 +1,167 @@ +package python.multiplatform.gradle + +import org.gradle.api.GradleException +import org.gradle.api.file.Directory +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider + +/** Which CPython build a header directory belongs to. The two are different trees with different `Py_GIL_DISABLED` layouts. */ +enum class CPythonFlavour { GIL, FREE_THREADED } + +/** + * Where, inside the build-time acquisition tree (`python-multiplatform/build/python-standalone/ + * extracted//`), the headers of each target's CPython live. Pure functions, so the + * layout is pinned by tests without downloading anything. + * + * The value is the directory that **directly contains `Python.h`** -- the one a compiler wants as + * `-I`. That is one level below the `include/` directory on every target except Windows + * (`include/python3.14/`, `include/python3.14t/` for free-threaded), which is why the issue's + * table (`.../include`) and this differ by that last segment. + */ +object CPythonIncludeLayout { + val desktopTargets = listOf("macos-aarch64", "macos-x86_64", "linux-x86_64", "windows-x86_64") + val androidTargets = listOf("android-aarch64", "android-x86_64") + + /** XCFramework slice directory names; the simulator slice is a fat arm64+x86_64 one. */ + val iosTargets = listOf("ios-arm64", "ios-arm64_x86_64-simulator") + + val allTargets: List = desktopTargets + androidTargets + iosTargets + + fun supports(target: String, flavour: CPythonFlavour): Boolean = + target in allTargets && (flavour == CPythonFlavour.GIL || target in desktopTargets) + + /** The extraction task that produces [target] -- the name `python-multiplatform/build.gradle.kts` registers. */ + fun downloadTaskName(target: String): String = when (target) { + in iosTargets -> "downloadPython_ios" + in desktopTargets, in androidTargets -> "downloadPython_${target.replace("-", "_")}" + else -> throw IllegalArgumentException("unknown CPython target '$target'") + } + + /** Path of the include directory relative to the version-keyed extraction root. */ + fun relativePath(target: String, flavour: CPythonFlavour, pythonVersion: String): String { + require(supports(target, flavour)) { "no $flavour CPython for target '$target'" } + val parts = pythonVersion.split('.') + require(parts.size >= 2) { "python version '$pythonVersion' has no major.minor" } + val tag = "${parts[0]}.${parts[1]}" + if (flavour == CPythonFlavour.FREE_THREADED) "t" else "" + return when (target) { + "windows-x86_64" -> + (if (flavour == CPythonFlavour.FREE_THREADED) "windows-x86_64-freethreaded" else "windows-x86_64") + + "/python/include" + in desktopTargets -> { + val dir = if (flavour == CPythonFlavour.FREE_THREADED) "$target-freethreaded" else target + "$dir/python/include/python$tag" + } + in androidTargets -> "$target/prefix/include/python$tag" + else -> "ios/Python.xcframework/$target/include/python$tag" + } + } + + // ---- link libraries (issue #56) ---- + + /** Android links `libpython3.x.so`, Windows links the import library; macOS/Linux/iOS need none. */ + fun isLinkRequired(target: String): Boolean = target in androidTargets || target == "windows-x86_64" + + /** Free-threaded link libraries exist on Windows only (`python314t.lib`); Android has no free-threaded build. */ + fun supportsLinkLibrary(target: String, flavour: CPythonFlavour): Boolean = + isLinkRequired(target) && (flavour == CPythonFlavour.GIL || target == "windows-x86_64") + + /** Directory holding the link library, relative to the version-keyed extraction root. */ + fun linkLibraryDirRelativePath(target: String, flavour: CPythonFlavour): String { + require(supportsLinkLibrary(target, flavour)) { "no $flavour link library for target '$target'" } + return when (target) { + "windows-x86_64" -> + (if (flavour == CPythonFlavour.FREE_THREADED) "windows-x86_64-freethreaded" else "windows-x86_64") + + "/python/libs" + else -> "$target/prefix/lib" + } + } + + /** File name of the link library, e.g. `libpython3.14.so` or `python314.lib` (`python314t.lib` free-threaded). */ + fun linkLibraryFileName(target: String, flavour: CPythonFlavour, pythonVersion: String): String { + require(supportsLinkLibrary(target, flavour)) { "no $flavour link library for target '$target'" } + val parts = pythonVersion.split('.') + require(parts.size >= 2) { "python version '$pythonVersion' has no major.minor" } + return if (target == "windows-x86_64") { + "python${parts[0]}${parts[1]}" + (if (flavour == CPythonFlavour.FREE_THREADED) "t" else "") + ".lib" + } else { + "libpython${parts[0]}.${parts[1]}.so" + } + } +} + +/** + * The public output: one `Provider` per (target, flavour), each carrying the extraction + * task as its build dependency, so anything that wires it as an input (`-I`, an `@InputDirectory`) + * triggers the download and unpack. + * + * Only the flavour the build was configured for (`-PpythonFreeThreaded`) has an extraction task; + * asking for the other one fails with a message naming the property, rather than returning a path + * nothing will ever create. + * + * @param extractedRoot the version-keyed extraction directory + * @param extractionTask the task that fills [extractedRoot] for a target+flavour, or null when this build does not extract it + */ +class CPythonIncludeDirectories( + private val extractedRoot: Directory, + private val pythonVersion: String, + private val extractionTask: (target: String, flavour: CPythonFlavour) -> TaskProvider<*>?, +) { + val targets: List get() = CPythonIncludeLayout.allTargets + + /** Whether [includeDir] would succeed for this pair in this build. */ + fun isAvailable(target: String, flavour: CPythonFlavour = CPythonFlavour.GIL): Boolean = + CPythonIncludeLayout.supports(target, flavour) && extractionTask(target, flavour) != null + + /** The directory holding `Python.h` for [target]; resolving it makes Gradle run the extraction task first. */ + fun includeDir(target: String, flavour: CPythonFlavour = CPythonFlavour.GIL): Provider { + if (!CPythonIncludeLayout.supports(target, flavour)) { + throw GradleException( + "No $flavour CPython include directory for '$target'. Targets: ${CPythonIncludeLayout.allTargets}; " + + "free-threaded exists for desktop targets only.", + ) + } + val task = extractionTask(target, flavour) ?: throw GradleException( + "The $flavour CPython for '$target' is not extracted by this build. " + + "Only the flavour selected with -PpythonFreeThreaded is downloaded.", + ) + val relative = CPythonIncludeLayout.relativePath(target, flavour, pythonVersion) + // `TaskProvider.map` keeps the task as the producer, so the dependency travels with the value. + return task.map { extractedRoot.dir(relative) } + } + + /** Whether linking against CPython is needed at all for [target] (false on macOS, Linux and iOS). */ + fun isLinkRequired(target: String): Boolean = CPythonIncludeLayout.isLinkRequired(target) + + /** Whether [libraryDir]/[libraryFile] would succeed for this pair in this build. */ + fun isLinkAvailable(target: String, flavour: CPythonFlavour = CPythonFlavour.GIL): Boolean = + CPythonIncludeLayout.supportsLinkLibrary(target, flavour) && extractionTask(target, flavour) != null + + private fun linkTask(target: String, flavour: CPythonFlavour): TaskProvider<*> { + if (!CPythonIncludeLayout.supportsLinkLibrary(target, flavour)) { + throw GradleException( + "No $flavour CPython link library for '$target'. Only android-* and windows-x86_64 link against " + + "CPython; macOS, Linux and iOS need none (isLinkRequired=false); free-threaded exists on Windows only.", + ) + } + return extractionTask(target, flavour) ?: throw GradleException( + "The $flavour CPython for '$target' is not extracted by this build. " + + "Only the flavour selected with -PpythonFreeThreaded is downloaded.", + ) + } + + /** The directory holding the link library (the `-L` directory); resolving it runs the extraction task first. */ + fun libraryDir(target: String, flavour: CPythonFlavour = CPythonFlavour.GIL): Provider { + val task = linkTask(target, flavour) + val relative = CPythonIncludeLayout.linkLibraryDirRelativePath(target, flavour) + return task.map { extractedRoot.dir(relative) } + } + + /** The link library file itself (`libpython3.14.so` / `python314.lib`); carries the extraction task. */ + fun libraryFile(target: String, flavour: CPythonFlavour = CPythonFlavour.GIL): Provider { + val task = linkTask(target, flavour) + val relative = CPythonIncludeLayout.linkLibraryDirRelativePath(target, flavour) + "/" + + CPythonIncludeLayout.linkLibraryFileName(target, flavour, pythonVersion) + return task.map { extractedRoot.file(relative) } + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/EmbeddedPythonVersion.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/EmbeddedPythonVersion.kt new file mode 100644 index 000000000..2b4a714fe --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/EmbeddedPythonVersion.kt @@ -0,0 +1,59 @@ +package python.multiplatform.gradle + +import org.gradle.api.Project + +/** + * Which CPython a published `python-multiplatform` build embeds (issue #61). Published three ways so + * a consumer never has to guess: + * + * - as the project extension `pythonMultiplatform` (composite builds), + * - as the Gradle attributes [ATTRIBUTE_VERSION] / [ATTRIBUTE_FREE_THREADED] on every consumable + * `*Elements` configuration (published Gradle module metadata), + * - as the classpath resource [RESOURCE_PATH] (read from the jar/AAR, no Gradle needed). + */ +class EmbeddedPythonVersion(val pythonVersion: String, val freeThreaded: Boolean) { + + /** `"3.14"` for `"3.14.7"`. */ + val majorMinor: String + get() { + val parts = pythonVersion.split('.') + require(parts.size >= 2) { "python version '$pythonVersion' has no major.minor" } + return "${parts[0]}.${parts[1]}" + } + + fun renderProperties(): String = "pythonVersion=$pythonVersion\nfreeThreaded=$freeThreaded\n" + + /** Adds the two attributes to every consumable `*Elements` configuration, including ones created later. */ + fun publishAttributes(project: Project) { + project.configurations + .matching { it.isCanBeConsumed && it.name.endsWith("Elements") } + .configureEach { + attributes.attribute(org.gradle.api.attributes.Attribute.of(ATTRIBUTE_VERSION, String::class.java), pythonVersion) + attributes.attribute( + org.gradle.api.attributes.Attribute.of(ATTRIBUTE_FREE_THREADED, String::class.java), + freeThreaded.toString(), + ) + } + } + + fun register(project: Project) { + project.extensions.add(EXTENSION_NAME, this) + publishAttributes(project) + } + + companion object { + const val EXTENSION_NAME = "pythonMultiplatform" + const val ATTRIBUTE_VERSION = "org.thisisthepy.python.version" + const val ATTRIBUTE_FREE_THREADED = "org.thisisthepy.python.free-threaded" + const val RESOURCE_PATH = "META-INF/python-multiplatform/python.properties" + + fun parse(text: String): EmbeddedPythonVersion { + val map = text.lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") && '=' in it } + .associate { it.substringBefore('=').trim() to it.substringAfter('=').trim() } + val version = requireNotNull(map["pythonVersion"]) { "python.properties has no pythonVersion" } + return EmbeddedPythonVersion(version, map["freeThreaded"]?.toBoolean() ?: false) + } + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/GenerateWasmProxyExportsTask.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/GenerateWasmProxyExportsTask.kt new file mode 100644 index 000000000..30dbcbb03 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/GenerateWasmProxyExportsTask.kt @@ -0,0 +1,79 @@ +package python.multiplatform.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction + +/** + * Generates the three `@WasmExport` trampoline functions that CPython's type-slot machinery + * calls back through into Kotlin. + * + * ### Why this task exists + * + * `@WasmExport` is honoured only in the compilation that produces the `.wasm` binary. A library + * klib linked in does not propagate its `@WasmExport` declarations 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` (see `ProxyTypeExportNames`'s KDoc). + * + * So the three trampoline functions must be declared in every executable (or test-binary) + * compilation that embeds this library. Previously they lived as hand-written boilerplate in + * `wasmJsTest/kotlin/.../ProxyTypeExports.kt`; this task generates the equivalent file so that + * no embedding module needs to copy three lines and understand why they exist. + * + * ### What it generates + * + * ```kotlin + * // GENERATED by python-multiplatform-gradle-plugin. Do not edit. + * @file:OptIn(kotlin.wasm.ExperimentalWasmInterop::class) + * package + * + * @kotlin.wasm.WasmExport("pmp_tp_traverse") + * fun pmpTpTraverse(selfPtr: Int, visitPtr: Int, argPtr: Int): Int = + * python.multiplatform.ffi.ProxyType.traverse(selfPtr, visitPtr, argPtr) + * + * @kotlin.wasm.WasmExport("pmp_tp_clear") + * fun pmpTpClear(selfPtr: Int): Int = python.multiplatform.ffi.ProxyType.clear(selfPtr) + * + * @kotlin.wasm.WasmExport("pmp_tp_dealloc") + * fun pmpTpDealloc(selfPtr: Int) = python.multiplatform.ffi.ProxyType.dealloc(selfPtr) + * ``` + * + * ### Mirroring the install-seam pattern + * + * `python-multiplatform-ksp`'s `InstallSeam.kt` generates `installGeneratedUpcallTable`'s + * `actual` into each leaf (executable) compilation -- the only place that can name `FunctionTable`. + * This task follows exactly the same pattern: it generates into the executable's source set, the + * one place `@WasmExport` is honoured. The package name defaults to one that keeps the file out + * of the user's own namespace. + */ +abstract class GenerateWasmProxyExportsTask : DefaultTask() { + + /** + * The Kotlin package the generated file is placed in. + * + * Defaults to `python.multiplatform.generated.wasm` -- the same `generated` prefix the KSP + * processor uses for its own output, so the file is easy to identify as machine-produced. + * A consumer may override it, but has no reason to: the three functions have no API surface. + */ + @get:Input + abstract val packageName: Property + + /** Where the generated `.kt` file is written. Add this to the target source set's `kotlin.srcDir`. */ + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val pkg = packageName.get() + val dir = outputDirectory.get().asFile + // Package hierarchy as a directory path + val pkgDir = pkg.split('.').fold(dir) { acc, segment -> acc.resolve(segment) } + pkgDir.mkdirs() + val outFile = pkgDir.resolve("$WASM_PROXY_EXPORTS_FILE_NAME.kt") + outFile.writeText(renderWasmProxyExportsSource(pkg)) + logger.info("Generated $outFile") + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosPythonHome.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosPythonHome.kt new file mode 100644 index 000000000..dce8d74a4 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/IosPythonHome.kt @@ -0,0 +1,250 @@ +package python.multiplatform.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * What an iOS app bundle carries of CPython's standard library, and where it comes from -- SPEC L-11, + * issue #59. + * + * `Python.xcframework` ships the interpreter and headers and nothing else (`iosMain/README.md`); the + * standard library sits *beside* the framework slices in the same archive: + * + * ``` + * Python.xcframework/ + * lib/python3.14/ pure-Python stdlib, shared by every slice + * ios-arm64/lib-arm64/python3.14/ lib-dynload/ (the .so files), _sysconfigdata (device) + * ios-arm64_x86_64-simulator/lib-arm64/... the same for the arm64 simulator + * ios-arm64_x86_64-simulator/lib-x86_64/... ...and the x86_64 simulator + * build/iOS-dylib-Info-template.plist Info.plist for an extension-module framework + * build/utils.sh upstream's own Xcode helper (install_python) + * ``` + * + * `build/utils.sh`'s `install_stdlib` merges the shared tree with one `lib-$ARCHS` tree into the app; + * this does the same merge at Gradle time, into a directory per slice, so the Xcode phase + * (`tools/xcode/install-python.sh`) copies a finished prefix and needs no path into the archive. + * Pure functions are separated out so the layout is pinned by tests without downloading anything. + */ +object IosPythonHomeLayout { + + /** + * The directory inside the app bundle the prefix is copied to, and that `IosPythonHome` in the + * library's `iosMain` looks for under `NSBundle.mainBundle.resourcePath`. The same name desktop + * packages under ([PACKAGED_HOME_DIRECTORY]); deliberately *not* upstream's `python/`, because + * `python/` is the consumer's payload root (`PythonPayload.PAYLOAD_ROOT`, SPEC L-6) and the + * payload phase `rsync --delete`s it. + */ + const val BUNDLE_DIRECTORY: String = PACKAGED_HOME_DIRECTORY + + /** The Info.plist template for an extension-module framework, relative to the xcframework. */ + const val DYLIB_INFO_TEMPLATE: String = "build/iOS-dylib-Info-template.plist" + + /** + * Top-level stdlib packages an iOS app never ships. + * + * Measured on 3.14 (`Python.xcframework/lib/python3.14`, 51 MB, 2,407 files): `test` alone is + * 36 MB and 1,641 files -- CPython's own regression suite. `tkinter`, `idlelib` and `turtledemo` + * need `_tkinter`, which no iOS slice builds (no `lib-dynload/_tkinter*`). `ensurepip` installs + * pip, which cannot run inside an app sandbox. Everything else is kept, as on desktop (L-9). + */ + val EXCLUDED_TOP_LEVEL_PACKAGES: Set = setOf("test", "idlelib", "tkinter", "turtledemo", "ensurepip") + + /** One xcframework slice + architecture: what Xcode builds for in one Run Script invocation. */ + data class Slice( + /** Xcode's platform name without the dash: `iphoneos`, `iphonesimulator`. */ + val sdk: String, + /** One `ARCHS` value: `arm64`, `x86_64`. */ + val arch: String, + /** The xcframework slice directory holding `lib-/`. */ + val xcframeworkSlice: String, + ) { + /** `iphonesimulator-arm64`: the staging directory name. */ + val name: String get() = "$sdk-$arch" + + /** `stageIosPythonHome_iphonesimulator_arm64`: the library build's task for this slice. */ + val stageTaskName: String get() = "stageIosPythonHome_" + name.replace('-', '_') + } + + /** + * Every slice this library stages. `iosArm64` is a device; `iosSimulatorArm64` and `iosX64` + * both live in the fat simulator slice, with separate `lib-` trees -- which is why `iosX64` + * needs its own entry rather than the hard-coded `lib-arm64` ROADMAP §13.4 records for the test + * task. + */ + val slices: List = listOf( + Slice("iphoneos", "arm64", "ios-arm64"), + Slice("iphonesimulator", "arm64", "ios-arm64_x86_64-simulator"), + Slice("iphonesimulator", "x86_64", "ios-arm64_x86_64-simulator"), + ) + + /** + * The slice an Xcode build phase is building for, from the two settings Xcode exports to it. + * + * Exactly one architecture: `ARCHS="arm64 x86_64"` (a universal simulator build, which Xcode + * does for Release unless `ONLY_ACTIVE_ARCH=YES`) would need two `lib-dynload` trees in one + * prefix, and CPython looks in one. Upstream's `install_stdlib` (`lib-$ARCHS`) has the same + * limit, silently; this says so. + */ + fun sliceFor(effectivePlatformName: String?, archs: String?): Slice { + val platform = effectivePlatformName?.trim()?.removePrefix("-") + require(!platform.isNullOrEmpty()) { + "EFFECTIVE_PLATFORM_NAME is not set. This task picks the iOS slice from the settings Xcode " + + "exports to a Run Script phase; run it from one (tools/xcode/install-python.sh does)." + } + val archList = archs?.trim()?.split(Regex("\\s+"))?.filter { it.isNotEmpty() }.orEmpty() + require(archList.isNotEmpty()) { "ARCHS is not set; run this from an Xcode Run Script phase." } + require(archList.size == 1) { + "ARCHS='${archs!!.trim()}' names ${archList.size} architectures, and one app prefix can hold " + + "one lib-dynload. Build one architecture at a time (ONLY_ACTIVE_ARCH=YES, or ARCHS=arm64)." + } + val arch = archList.single() + return slices.firstOrNull { it.sdk == platform && it.arch == arch } + ?: throw IllegalArgumentException( + "no CPython iOS slice for platform '$platform' and architecture '$arch'; this library " + + "stages ${slices.joinToString { it.name }}." + ) + } + + /** `3.14` from `3.14.7`. */ + fun stdlibTag(pythonVersion: String): String { + val parts = pythonVersion.split('.') + require(parts.size >= 2) { "python version '$pythonVersion' has no major.minor" } + return "${parts[0]}.${parts[1]}" + } + + /** + * The trees merged into `lib/python/` for [slice], relative to the xcframework, shared tree + * first: the slice tree only adds files (`lib-dynload/`, `_sysconfigdata__ios_*`), but if it ever + * overlapped, the slice-specific file is the one that must win, as in `install_stdlib`. + */ + fun sourceDirectories(slice: Slice, pythonVersion: String): List { + val tag = stdlibTag(pythonVersion) + return listOf("lib/python$tag", "${slice.xcframeworkSlice}/lib-${slice.arch}/python$tag") + } + + /** + * Whether a file or directory at [stdlibRelativePath] -- relative to `lib/python/`, `/` + * separated -- goes into the app. + * + * Not copied: any `__pycache__` (bytecode from whatever ran against the extracted tree, so the + * app would depend on the build machine's history), the [EXCLUDED_TOP_LEVEL_PACKAGES], and any + * `libpython*.dylib` (upstream excludes it too: the interpreter arrives as `Python.framework`, + * and a loose dylib in an app bundle is rejected by App Store validation). + */ + fun isStdlibPathStaged(stdlibRelativePath: String): Boolean { + val segments = stdlibRelativePath.trim('/').split('/').filter { it.isNotEmpty() } + if (segments.isEmpty()) return true + if (segments.first() in EXCLUDED_TOP_LEVEL_PACKAGES) return false + if (segments.any { it == "__pycache__" }) return false + val name = segments.last() + if (name.startsWith("libpython") && name.endsWith(".dylib")) return false + return true + } + + /** + * Every file the prefix for [slice] contains, as destination path (relative to the prefix) to + * source file. A later source overrides an earlier one at the same destination + * ([sourceDirectories]' order). Fails when a source tree is missing, rather than staging half a + * prefix that would abort `Py_Initialize()` on the device. + */ + fun plan(xcframework: File, slice: Slice, pythonVersion: String): Map { + val tag = stdlibTag(pythonVersion) + val result = sortedMapOf() + for (relative in sourceDirectories(slice, pythonVersion)) { + val root = File(xcframework, relative) + if (!root.isDirectory) { + throw GradleException( + "$root does not exist, so the ${slice.name} prefix would have no " + + (if (relative.startsWith("lib/")) "standard library" else "lib-dynload") + + ". The Python.xcframework layout may have changed; see IosPythonHomeLayout's KDoc." + ) + } + root.walkTopDown() + .onEnter { dir -> dir == root || isStdlibPathStaged(dir.relativeTo(root).invariantSeparatorsPath) } + .filter { it.isFile } + .forEach { file -> + val inStdlib = file.relativeTo(root).invariantSeparatorsPath + if (isStdlibPathStaged(inStdlib)) result["lib/python$tag/$inStdlib"] = file + } + } + return result + } +} + +/** + * Stages one slice's prefix for an iOS app bundle (SPEC L-11): `/home/lib/python/` + * as [IosPythonHomeLayout.plan] lists it, plus `/dylib-Info-template.plist` *beside* + * the prefix -- it is an input to the Xcode phase, not part of what the app carries. + * + * A plain copy rather than a `Sync` so that what [IosPythonHomeLayout.plan] returns -- which the + * tests pin -- is exactly what is copied, with no second notion of "excluded" in a copy spec. + */ +abstract class StageIosPythonHomeTask : DefaultTask() { + + /** `.../extracted//ios/Python.xcframework`. */ + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val xcframework: DirectoryProperty + + /** One of [IosPythonHomeLayout.slices]' names, e.g. `iphonesimulator-arm64`. */ + @get:Input + abstract val sliceName: Property + + @get:Input + abstract val pythonVersion: Property + + @get:OutputDirectory + abstract val destinationDir: DirectoryProperty + + @TaskAction + fun stage() { + val slice = IosPythonHomeLayout.slices.firstOrNull { it.name == sliceName.get() } + ?: throw GradleException("unknown iOS slice '${sliceName.get()}'") + val framework = xcframework.get().asFile + val version = pythonVersion.get() + val plan = IosPythonHomeLayout.plan(framework, slice, version) + + val root = destinationDir.get().asFile + val home = File(root, PREFIX_DIRECTORY) + // Whole directory replaced: a file dropped from the plan (an exclusion added, an upstream + // module removed) must not survive from the previous run into the next app. + home.deleteRecursively() + for ((relative, source) in plan) { + val target = File(home, relative) + target.parentFile.mkdirs() + Files.copy(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES) + } + + val template = File(framework, IosPythonHomeLayout.DYLIB_INFO_TEMPLATE) + if (!template.isFile) { + throw GradleException("$template is missing; the Xcode phase needs it to wrap extension modules as frameworks.") + } + Files.copy(template.toPath(), File(root, TEMPLATE_NAME).toPath(), StandardCopyOption.REPLACE_EXISTING) + + // The marker PythonHomeCheck probes on the device, checked here where it is cheap to fail. + val marker = File(home, "lib/python${IosPythonHomeLayout.stdlibTag(version)}/os.py") + if (!marker.isFile) { + throw GradleException("staged ${slice.name} into $home but $marker is missing; the app would abort at Py_Initialize().") + } + logger.info("Staged {} files of CPython {} for {} into {}", plan.size, version, slice.name, home) + } + + companion object { + /** The prefix inside [destinationDir]; what the Xcode phase copies into the app. */ + const val PREFIX_DIRECTORY: String = "home" + + /** The framework Info.plist template inside [destinationDir]. */ + const val TEMPLATE_NAME: String = "dylib-Info-template.plist" + } +} 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 new file mode 100644 index 000000000..01aa4a30c --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonBindingsPlugin.kt @@ -0,0 +1,788 @@ +package python.multiplatform.gradle + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import python.multiplatform.gradle.artifact.PythonArtifactBindingsTask +import python.multiplatform.gradle.stubs.PythonStubsTask + +/** + * KSP option names. Copied rather than imported: these are the processor's + * `python.multiplatform.ksp.Constants`, and this plugin ships as a separate build so that a + * consumer resolves it from the plugin portal without also resolving the processor's compile + * classpath. Changing one without the other shows up in `ksp-fixtures` as an unwired module. + */ +private const val OPTION_ROLE = "python.multiplatform.role" +private const val OPTION_MODULE_NAME = "python.multiplatform.moduleName" +private const val OPTION_EXCLUDE_PACKAGES = "python.multiplatform.excludePackages" + +private const val ROLE_LIBRARY = "library" +private const val ROLE_APP = "app" + +/** Created by toolchain's plugin (toolchain#23); referenced by name only. */ +private const val TYPEDPYTHON_STUBS_CONFIGURATION = "typedpythonStubs" + +/** + * Which KSP configurations the processor belongs on. + * + * KSP creates one configuration per *compilation*, plus several of its own internal ones. The + * processor belongs on each target's main compilation and nowhere else: + * + * - a `...Test` compilation would scan the test sources and emit a *second* `Fragment_` + * under the same name, in the same package, in the same module; + * - `kspCommonMainMetadata` would do the same for `commonMain`, which every platform target + * already compiles; + * - `kspPluginClasspath*` and `...ProcessorClasspath` are KSP's own plumbing; + * - the bare `ksp` configuration is the single-platform one. Adding to it from a multiplatform + * project fails outright -- "The 'ksp' configuration is deprecated in Kotlin Multiplatform + * projects. Please use target-specific configurations like 'kspJvm' instead" -- so + * [isMultiplatform] decides whether it counts. + * + * **A test compilation is not always a name that ends in `Test`.** On a module carrying an Android + * plugin, KSP names its configurations after AGP's source sets, and AGP puts the build type last: + * + * main kspAndroid kspAndroidDebug kspAndroidRelease + * unit test kspAndroidTest kspAndroidTestDebug kspAndroidTestRelease + * instrumented kspAndroidAndroidTest kspAndroidAndroidTestDebug kspAndroidAndroidTestRelease + * testFixtures kspAndroidTestFixtures kspAndroidTestFixturesDebug kspAndroidTestFixturesRelease + * + * so `endsWith("Test")` caught only the variant-less three of those nine. The processor landed on + * `kspAndroidTestDebug`, scanned the *test* sources, and emitted a second `Fragment_` -- + * and a second `FunctionTable` -- into the test compilation, where they shadowed the real ones + * from `main`, because a compilation's own generated sources win over its classpath. The symptom + * is a table holding the test classes and nothing the module actually exposes. + * `ksp-fixtures/android` is what surfaced this; no module without an Android plugin can. + * + * `Test` is therefore matched as a camel-case *word* rather than as a suffix: it must start a word + * and end one. A target or product flavour genuinely named `testing` is not a test compilation and + * must keep the processor. + * + * Matching on names rather than walking the Kotlin extension's targets keeps this plugin free of + * a Kotlin Gradle plugin dependency, and so free of its version. The observed name set is pinned + * in `WiringTest`. + */ +private val TEST_WORD = Regex("(?:^|[a-z0-9])Test(?:[A-Z]|$)") + +internal fun isBindingKspConfiguration(name: String, isMultiplatform: Boolean): Boolean { + if (!name.startsWith("ksp")) return false + if (name == "ksp") return !isMultiplatform + if (name == "kspCommonMainMetadata") return false + if (name.startsWith("kspPluginClasspath")) return false + if (name.endsWith("ProcessorClasspath")) return false + if (TEST_WORD.containsMatchIn(name)) return false + return true +} + +/** + * The module name the generated fragment object is named after. + * + * Every fragment in every artifact lands in one package (`docs/design/upcall.md` §2.1), so + * the name has to be unique across artifacts, not just within a build -- two independent + * libraries both called `:core` would otherwise emit the same object into the same package. The + * Maven group is what already carries that uniqueness, so it goes in front. + */ +internal fun deriveModuleName(group: String, path: String, name: String): String { + val projectPart = path.removePrefix(":").ifBlank { name } + val raw = if (group.isBlank()) projectPart else "$group.$projectPart" + return raw.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("") +} + +/** + * `app` for the module that produces the final binary, `library` for everything else. + * + * Only one module in a build may aggregate: the aggregator emits `FunctionTable` into a fixed + * package, so two of them collide. Gradle has no general notion of "the module that ships", and + * a Kotlin Multiplatform module that builds a framework or a native executable carries no plugin + * that distinguishes it -- hence the explicit `role` override on the extension, which is what + * `ksp-fixtures/app` uses. + */ +internal fun inferRole(hasApplicationPlugin: Boolean, hasAndroidApplicationPlugin: Boolean): String = + if (hasApplicationPlugin || hasAndroidApplicationPlugin) ROLE_APP else ROLE_LIBRARY + +/** Everything a consumer may want to override; every one of them has a working default. */ +interface PythonBindingsExtension { + /** `"library"` or `"app"`. Defaults to [inferRole]. */ + val role: Property + + /** Defaults to [deriveModuleName] over the project's group and path. */ + val moduleName: Property + + /** Package prefixes to keep out of the table, on top of the library's own. */ + val excludePackages: ListProperty + + /** + * The processor artifact. Defaults to this plugin's own group/version coordinates; an + * in-repo consumer sets it to the project instead: + * `pythonBindings { processor.set(projects.pythonMultiplatformKsp) }`. + */ + val processor: Property + + /** + * 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 + * 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 + * false is for the case where a build must not reach the network at all: the first build on a + * machine downloads ~25 MB from python-build-standalone. Every later build, and every other + * project on the same machine, reuses it. + */ + val stagePythonHome: Property + + /** + * Whether a packaged desktop application carries the staged CPython prefix -- SPEC L-9, issue + * #60. Defaults to true. + * + * When on, every Compose Desktop `prepare*AppResources` task (what `createDistributable` and + * the `package*` tasks take their resources from) depends on `stagePythonHome` and copies the + * prefix's stdlib and shared library into `python-multiplatform-home/`; the library finds it + * there at run time through Compose's `compose.application.resources.dir`. Independent of + * [stagePythonHome]: an app must ship the prefix this library was built against even on a + * machine whose developer runs with their own `PYTHONHOME`. Turn it off for an app that + * provides a prefix some other way (and names it with `-Dpython.multiplatform.home=...`). + */ + val packagePythonHome: Property + + /** CPython version to stage. Defaults to the one this library was built against. */ + val pythonVersion: Property + + /** python-build-standalone release tag to stage from. Defaults to the one this library used. */ + val pythonBuildStandaloneRelease: Property + + /** Whether to stage a free-threaded build. Defaults to what this library was built against. */ + val pythonFreeThreaded: Property + + /** + * Whether to unpack `python-multiplatform`'s published wasm browser runtime for any `wasmJs*` + * task this consumer's build declares. Defaults to true, and is a no-op unless a `wasmJs()` + * target actually exists -- see [StageWasmBrowserRuntimeTask]'s kdoc for why registering it is + * always safe. See ROADMAP §10. + */ + val stageWasmBrowserRuntime: Property + + /** Maven coordinates of the runtime zip. Defaults to the one this library published. */ + val wasmRuntimeCoordinates: Property + + /** + * Package or class names to bind out of the artefacts this build **resolves**, rather than out + * of the source it compiles -- `docs/design/ecosystem.md` §5b's second producer. + * + * KSP only ever sees declarations being compiled, so a third-party binary can never carry a + * fragment. Setting this (together with [artifactConfiguration] and [artifactSourceSet]) + * registers `generatePythonArtifactBindings`, which walks the resolved jars with ASM and emits + * `python.multiplatform.generated.artifacts.ArtifactTable` beside KSP's `FunctionTable`. + * + * Each name is matched as a *namespace*: `junit.runner` covers `junit.runner.Version`, and + * `junit.run` covers nothing. Empty (the default) leaves the walker unregistered entirely -- + * binding a whole compile classpath by accident is not a useful default. + */ + val artifactIncludePackages: ListProperty + + /** + * The resolvable configuration to walk, e.g. `desktopCompileClasspath`. + * + * Named explicitly rather than derived, because this plugin carries no Kotlin Gradle Plugin + * types (see [TEST_WORD]) and so cannot ask a target what its compile classpath is called. One + * configuration and one source set is also the honest scope of what has been verified: see + * `ksp-fixtures/artifact`, and ROADMAP §16 for the generalisation. + */ + val artifactConfiguration: Property + + /** The Kotlin source set the generated fragments are compiled into, e.g. `desktopMain`. */ + val artifactSourceSet: Property + + /** + * More than one target's worth of the same thing: resolvable configuration name -> Kotlin + * source set name, e.g. `"desktopCompileClasspath" to "desktopMain"`. + * + * [artifactConfiguration]/[artifactSourceSet] name exactly one pair and stay supported -- a + * build that sets them keeps its task named `generatePythonArtifactBindings`, unsuffixed, so + * nothing that already invokes that task by name breaks. Every *additional* pair gets a task + * named after its source set, because two tasks cannot share a name and the source set is what + * distinguishes their output directories already. + * + * Still named by hand rather than derived, for the reason [artifactConfiguration] gives: this + * plugin carries no Kotlin Gradle Plugin types, so it cannot ask a target what its compile + * classpath is called. What changes here is only that the answer no longer has to be singular. + * The naming convention a caller can rely on is `CompileClasspath` -> `Main` + * for a plain Kotlin target; Android's variant-aware configurations do not follow it, which is + * exactly why this stays a map the caller writes rather than something derived. + */ + val artifactTargets: MapProperty + + /** + * Whether to emit `.pyi` stubs for the walked artefacts -- `docs/design/pyi-generation-design.md`. + * + * Defaults to true wherever the walker itself is registered, and is a no-op everywhere else: + * nothing is enumerable to stub until there is something to stub *from*. The output is + * `build/generated/pythonStubs//` and **nothing compiles it** -- see + * [python.multiplatform.gradle.stubs.PythonStubsTask] for why it is not written into the source + * tree the way PyREPL's generator was, and for what registering it with an interpreter still + * needs per environment. + */ + val generateStubs: Property +} + +/** + * ROADMAP §7's last open wiring item: what used to be `add("kspDesktop", ...)`, + * `add("kspAndroidNativeArm64", ...)` and two `ksp { arg(...) }` lines per module, once per + * target, is now one plugin. + * + * plugins { + * id("io.github.thisisthepy.python.multiplatform.bindings") + * } + * + * A module that produces the final binary adds `pythonBindings { role.set("app") }` unless it + * applies `application` or `com.android.application`, which are inferred. + * + * The consumer's own `org.jetbrains.kotlin.multiplatform` (or `.jvm`/`.android`) plugin version + * has to satisfy two independent floors, verified against a real external consumer outside this + * repository (ROADMAP §15f): + * + * - **API-compatible with the pinned KSP Gradle plugin** (`libs.versions.ksp` in this build -- + * currently 2.3.11). Too old a Kotlin Gradle Plugin and `kspKotlin` fails at + * configuration time with a raw `NoSuchMethodError`-shaped message + * (`KotlinJvmCompilerOptions.getJvmDefault()` on Kotlin 2.1.0) -- KSP's plugin calls an API the + * older Kotlin Gradle Plugin does not have. + * - **New enough to read the published library's metadata version.** `python-multiplatform` is + * built with this repo's own Kotlin version (currently 2.4.20-Beta2, `libs.versions.kotlin`). + * A consumer's Kotlin *compiler* one or more feature releases behind that fails + * `compileKotlin` with "was compiled with an incompatible version of Kotlin ... can + * read versions up to X.Y" once it reaches classes from the dependency -- this happens even + * when the KSP-vs-KGP floor above is already satisfied (observed with Kotlin 2.2.20). + * + * The version this repo itself uses is the only combination exercised; there is no published + * compatibility matrix beyond it. + */ +class PythonBindingsPlugin : Plugin { + + override fun apply(project: Project) { + val extension = project.extensions.create("pythonBindings", PythonBindingsExtension::class.java) + + project.pluginManager.apply("com.google.devtools.ksp") + + // `matching` is live, so targets declared after this plugin is applied -- which is the + // normal case, since `kotlin { ... }` comes after `plugins { ... }` -- are still covered. + // The multiplatform check is inside the predicate, not hoisted: `matching` evaluates it + // when a configuration is realised, so this holds even if the Kotlin plugin is applied + // after this one. + project.configurations.matching { + isBindingKspConfiguration(it.name, project.plugins.hasPlugin("org.jetbrains.kotlin.multiplatform")) + }.configureEach { + dependencies.addLater( + project.provider { + project.dependencies.create(extension.processor.getOrElse(DEFAULT_PROCESSOR_COORDINATES)) + }, + ) + } + + configurePythonHomeStaging(project, extension) + configureWasmBrowserRuntimeStaging(project, extension) + + project.afterEvaluate { + setKspArg( + OPTION_ROLE, + extension.role.getOrElse( + inferRole( + hasApplicationPlugin = plugins.hasPlugin("application"), + hasAndroidApplicationPlugin = plugins.hasPlugin("com.android.application"), + ), + ), + ) + setKspArg( + OPTION_MODULE_NAME, + extension.moduleName.getOrElse(deriveModuleName(group.toString(), path, name)), + ) + val excluded = extension.excludePackages.getOrElse(emptyList()) + if (excluded.isNotEmpty()) setKspArg(OPTION_EXCLUDE_PACKAGES, excluded.joinToString(",")) + + // After evaluation, because it names a configuration and a Kotlin source set that the + // consumer's own `kotlin { ... }` block creates -- both of which are declared after + // `plugins { ... }` and so do not exist while this plugin is being applied. + configureArtifactBindings(this, extension) + configureWasmProxyExports(this) + } + } + + /** + * Registers the artefact walker -- `docs/design/ecosystem.md` §5b's second producer -- when a consumer + * has asked for one. + * + * A no-op unless all three of [PythonBindingsExtension.artifactIncludePackages], + * [PythonBindingsExtension.artifactConfiguration] and [PythonBindingsExtension.artifactSourceSet] + * are set, so a build that only wants KSP pays nothing: no task, no configuration resolution and + * no generated source directory. + */ + internal fun configureArtifactBindings(project: Project, extension: PythonBindingsExtension) { + val includes = extension.artifactIncludePackages.getOrElse(emptyList()) + if (includes.isEmpty()) return + + val singleConfig = extension.artifactConfiguration.orNull + val singleSourceSet = extension.artifactSourceSet.orNull + val targets = extension.artifactTargets.getOrElse(emptyMap()).toMutableMap() + if (singleConfig != null && singleSourceSet != null) { + targets[singleConfig] = singleSourceSet + } + + if (targets.isEmpty()) { + error( + "pythonBindings.artifactIncludePackages is set but no targets are configured: " + + "set artifactConfiguration/artifactSourceSet or artifactTargets." + ) + } + + val klibReader = klibReaderClasspath(project) + + for ((configurationName, sourceSetName) in targets) { + val configuration = project.configurations.findByName(configurationName) + ?: error("no configuration named '$configurationName' in ${project.path}") + + // `incoming.artifacts` rather than the configuration's own file collection: it is the only + // route that carries each file's *coordinates* alongside it, and a fragment has to be named + // after the artefact rather than after whatever the cache called the file. + val resolved = configuration.incoming.artifacts + + val taskSuffix = sourceSetName.replaceFirstChar { it.uppercaseChar() } + val isLegacyName = targets.size == 1 && configurationName == singleConfig + + val bindingsTaskName = if (isLegacyName) "generatePythonArtifactBindings" else "generatePythonArtifactBindings$taskSuffix" + + val task = project.tasks.register( + bindingsTaskName, + PythonArtifactBindingsTask::class.java, + ) { + group = "python" + description = "Walks resolved artefacts and emits Python bindings for their declarations." + artifacts.from(resolved.artifactFiles) + coordinatesByFileName.set( + resolved.resolvedArtifacts.map { set -> + set.associate { it.file.name to it.id.componentIdentifier.displayName } + }, + ) + includePrefixes.set(includes) + this.klibReaderClasspath.from(klibReader) + outputDirectory.set( + project.layout.buildDirectory.dir("generated/pythonArtifactBindings/$sourceSetName"), + ) + thunkDirectory.set( + project.layout.buildDirectory.dir("generated/pythonComposableThunks/$sourceSetName"), + ) + } + + addKotlinSourceDirectory(project, sourceSetName, task) + addThunkClasspath(project, sourceSetName, task) + configureStubGeneration(project, extension, resolved, includes, sourceSetName, klibReader, isLegacyName, taskSuffix) + } + } + + /** + * Everything `KlibScanWorkAction`'s isolated worker classloader needs beside this plugin's own + * jar, resolved **in the consumer's project** rather than taken from this plugin's classpath. + * + * ### Why it cannot be a plugin dependency + * + * `kotlin-compiler-embeddable` on this plugin's runtime classpath becomes `runtime` scope in its + * published POM, which puts it on the plugin classloader of every build that applies the plugin id + * -- beside the Kotlin Gradle Plugin's own copy of the same classes. KGP then fails to create its + * own tasks: + * + * Could not create task of type 'KotlinCompile'. + * > Could not generate a decorated class for type KotlinCompile. + * > class org.jetbrains.kotlin.build.report.metrics.BuildTimeMetric has interface + * org.jetbrains.kotlin.build.report.metrics.BuildPerformanceMetric as super class + * + * `./gradlew tasks` failed on that in all three external consumer projects, whether or not they + * asked for any artefact walking at all. Gradle warns about this jar being on a build classpath + * ("unpredictable and inconsistent behavior"); in this combination it is not unpredictable, it is a + * hard failure every time. The worker classloader isolation `KlibScanWorkAction` already has does + * not help -- that isolates *execution*, and the breakage is the jar merely being *present*. + * + * ### Why the whole worker classpath, not only the reader + * + * The worker classloader cannot borrow anything from the plugin's own: in an external consumer, + * every plugin in the same `plugins { }` block shares one classloader, so this plugin's `.urLs` + * out there include the Kotlin Gradle Plugin's `kotlin-util-klib` -- the exact jar the isolation + * exists to escape. `KlibScanWorkAction.pluginCodeSource`'s KDoc has the printed list. So the + * worker's classpath is this configuration plus this plugin's own jar, and nothing else, which is + * why `kotlin-metadata-jvm` and ASM are named here too even though the plugin already depends on + * them for the *jar* walker. + * + * ### Why detached, and why it costs a consumer nothing + * + * `detachedConfiguration` keeps it out of the consumer's own configuration container, so no + * `configurations.all { }` rule, dependency substitution or platform constraint of theirs can move + * the versions away from the ones `KlibScanner` was compiled against -- the same reason + * `configureWasmBrowserRuntimeStaging` uses one. It is only ever resolved from + * `PythonArtifactBindingsTask`/`PythonStubsTask`'s task action, and only in the klib branch: a + * consumer walking jars only never downloads it, and a consumer who registers no walker never + * creates the tasks at all. + * + * `Usage=java-runtime` because a bare detached configuration carries no attributes, and these + * modules publish Gradle module metadata with both an api and a runtime variant to choose between. + */ + private fun klibReaderClasspath(project: Project) = + project.configurations.detachedConfiguration( + *DEFAULT_KLIB_WORKER_COORDINATES.map { project.dependencies.create(it) }.toTypedArray(), + ).apply { + attributes.attribute( + org.gradle.api.attributes.Usage.USAGE_ATTRIBUTE, + project.objects.named(org.gradle.api.attributes.Usage::class.java, org.gradle.api.attributes.Usage.JAVA_RUNTIME), + ) + } + + /** + * Puts the generated composable thunks on the compilation's classpath. + * + * ### Why not a dependency + * + * The obvious spelling -- `dependencies { desktopCompileOnly(files(thunkDir)) }` -- is a + * **circular task dependency**, and not a subtle one: the walker's own input is + * `desktopCompileClasspath` (whatever [PythonBindingsExtension.artifactConfiguration] names), + * both `compileOnly` and `implementation` feed that configuration, so the task would be waiting + * for its own output. A compile task's `libraries` is the one place a classpath entry can be + * added *past* the configuration the walker reads. + * + * The runtime half has no such problem -- a runtime classpath is not a compile classpath -- so it + * goes on `RuntimeOnly` the ordinary way, and on the test one beside it, since a `Test` + * task resolves a classpath of its own. + * + * ### Reflection, for the same reason [addKotlinSourceDirectory] uses it + * + * This plugin never names a Kotlin Gradle Plugin type at compile time. `getLibraries()` on a + * Kotlin JVM compile task returns Gradle's own [org.gradle.api.file.ConfigurableFileCollection], + * so only the lookup is reflective and nothing about the value is. + * + * A source set called `Main` compiles in `compileKotlin`, which is KGP's own + * convention for a target's main compilation. A name this cannot map is not an error: a build + * that binds no composable emits no thunk and never reads this classpath entry. + */ + private fun addThunkClasspath( + project: Project, + sourceSetName: String, + task: org.gradle.api.tasks.TaskProvider, + ) { + val thunks = project.files(task.map { it.thunkDirectory }) + val targetName = sourceSetName.removeSuffix("Main") + val compileTaskName = "compileKotlin" + targetName.replaceFirstChar { it.uppercaseChar() } + project.tasks.matching { it.name == compileTaskName }.configureEach { + val libraries = javaClass.methods + .firstOrNull { it.name == "getLibraries" && it.parameterCount == 0 } + ?.also { it.isAccessible = true } + ?.invoke(this) as? org.gradle.api.file.ConfigurableFileCollection + ?: return@configureEach + libraries.from(thunks) + } + listOf("${targetName}RuntimeOnly", "${targetName}TestRuntimeOnly").forEach { name -> + if (project.configurations.findByName(name) != null) project.dependencies.add(name, thunks) + } + } + + /** + * Registers `generatePythonStubs` -- `docs/design/pyi-generation-design.md`'s generator. + * + * Hooked onto `prepareKotlinIdeaImport` the way PyREPL hooked its own generator (§1.4 keeps that + * row): it is the one step that makes the stubs exist before the IDE indexes, and it costs one + * line. `tasks.matching` rather than `getByName`, because the task exists only when the Kotlin + * Gradle Plugin creates it -- a plain `./gradlew build` has no IDE import step. + * + * Nothing else depends on this task. **Nothing compiles a `.pyi`** (§6.2), so unlike the binding + * fragments there is no `srcDir(task)` that would pull it into a build; a consumer runs it, or + * their IDE import does. + */ + private fun configureStubGeneration( + project: Project, + extension: PythonBindingsExtension, + resolved: org.gradle.api.artifacts.ArtifactCollection, + includes: List, + sourceSetName: String, + klibReader: org.gradle.api.artifacts.Configuration, + isLegacyName: Boolean, + taskSuffix: String, + ) { + if (!extension.generateStubs.getOrElse(true)) return + val stubTaskName = if (isLegacyName) "generatePythonStubs" else "generatePythonStubs$taskSuffix" + val task = project.tasks.register(stubTaskName, PythonStubsTask::class.java) { + group = "python" + description = "Emits .pyi stubs for the declarations the artefact walker bound." + artifacts.from(resolved.artifactFiles) + includePrefixes.set(includes) + klibReaderClasspath.from(klibReader) + outputDirectory.set(project.layout.buildDirectory.dir("generated/pythonStubs/$sourceSetName")) + } + project.tasks.matching { it.name == "prepareKotlinIdeaImport" }.configureEach { dependsOn(task) } + feedTypedPythonStubs(project, task) + } + + /** + * Adds [task]'s output to toolchain's `typedpythonStubs` configuration, by name only -- there is + * no compile-time dependency on toolchain. `matching` + `configureEach` covers a configuration + * that already exists and one created later, so plugin application order does not matter; if it + * never appears, nothing happens. The provider is `flatMap`ped from the task, so resolving the + * configuration runs the task. + */ + private fun feedTypedPythonStubs(project: Project, task: org.gradle.api.tasks.TaskProvider) { + project.configurations.matching { it.name == TYPEDPYTHON_STUBS_CONFIGURATION }.configureEach { + project.dependencies.add(name, project.files(task.flatMap { it.outputDirectory })) + } + } + + /** + * `kotlin { sourceSets.getByName(name).kotlin.srcDir(task) }` without a compile-time reference + * to any Kotlin Gradle Plugin type -- the same constraint, and the same reflective answer, as + * [setKspArg]. + * + * Only the last hop needs reflection to *read*: `KotlinSourceSet.getKotlin()` returns Gradle's + * own [org.gradle.api.file.SourceDirectorySet], and `sourceSets` is a Gradle + * [org.gradle.api.NamedDomainObjectContainer], so both ends of the chain are types this plugin + * can name. Passing the task provider rather than a directory is what makes the compilation -- + * and KSP's own scan of the same source set -- depend on the walker having run. + */ + internal fun addKotlinSourceDirectory( + project: Project, + sourceSetName: String, + task: org.gradle.api.tasks.TaskProvider<*>, + ) { + val kotlin = project.extensions.findByName("kotlin") + ?: error("the kotlin extension is missing: pythonBindings.artifactIncludePackages needs a Kotlin project") + val sourceSets = kotlin.javaClass.methods + .firstOrNull { it.name == "getSourceSets" && it.parameterCount == 0 } + ?.also { it.isAccessible = true } + ?.invoke(kotlin) as? org.gradle.api.NamedDomainObjectContainer<*> + ?: error("the kotlin extension has no sourceSets container; the Kotlin Gradle Plugin API changed") + val sourceSet = sourceSets.findByName(sourceSetName) + ?: error( + "no Kotlin source set named '$sourceSetName' in ${project.path}; " + + "pythonBindings.artifactSourceSet must name one that exists", + ) + val directories = sourceSet.javaClass.methods + .firstOrNull { it.name == "getKotlin" && it.parameterCount == 0 } + ?.also { it.isAccessible = true } + ?.invoke(sourceSet) as? org.gradle.api.file.SourceDirectorySet + ?: error("Kotlin source set '$sourceSetName' has no kotlin SourceDirectorySet; the API changed") + directories.srcDir(task) + } + + /** + * Registers `stagePythonHome`, hands its result to every task that launches a JVM, and puts it + * into a packaged desktop application. + * + * 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 + * only way CPython's `getenv(3)` and [python.multiplatform.env.PythonHomeCheck]'s + * `System.getenv` can be guaranteed to read the same thing. + * + * The `environment(...)` wiring is skipped when `PYTHONHOME` is already set, so a consumer with + * their own prefix keeps it. The task itself is registered regardless -- registration is lazy, + * and it runs only when something depends on it -- because packaging (SPEC L-9) must ship the + * prefix this library was built against whatever the developer's own shell points at. Nothing + * at all happens on a host that is not a platform this library ships for; that warns rather + * than fails, because a project may well be building only its Android or iOS targets there. + */ + internal fun configurePythonHomeStaging(project: Project, extension: PythonBindingsExtension) { + val enabled = extension.stagePythonHome.getOrElse(true) + + val platform = hostDesktopPlatform( + System.getProperty("os.name").orEmpty(), + System.getProperty("os.arch").orEmpty(), + ) + if (platform == null) { + project.logger.info( + "python-multiplatform: not staging a CPython prefix -- no python-build-standalone " + + "distribution for ${System.getProperty("os.name")}/${System.getProperty("os.arch")}.", + ) + return + } + + val version = extension.pythonVersion.getOrElse(DEFAULT_PYTHON_VERSION) + val release = extension.pythonBuildStandaloneRelease.getOrElse(DEFAULT_PBS_RELEASE) + val freeThreaded = extension.pythonFreeThreaded.getOrElse(DEFAULT_PYTHON_FREE_THREADED) + + // Shared across every project on the machine: the prefix is 24 MB of files that are + // identical for every consumer of a given release, and a per-project copy would pay for it + // once per checkout. + val cacheRoot = java.io.File(project.gradle.gradleUserHomeDir, PythonHomeStaging.CACHE_DIRECTORY) + val flavour = if (freeThreaded) "-freethreaded" else "" + val destination = java.io.File(cacheRoot, "$version+$release$flavour/$platform") + val prefix = java.io.File(destination, "python") + + val stage = project.tasks.register("stagePythonHome", StagePythonHomeTask::class.java) { + group = "python" + description = "Downloads and unpacks a CPython prefix for PYTHONHOME to point at." + pythonVersion.set(version) + pbsRelease.set(release) + this.platform.set(platform) + this.freeThreaded.set(freeThreaded) + destinationDir.set(destination) + downloadDir.set(java.io.File(cacheRoot, "archives")) + } + + // `providers.environmentVariable`, not `System.getenv`: the latter reads the *daemon's* + // environment, and a daemon is reused across invocations that do not share one. A user + // running `PYTHONHOME=/their/prefix ./gradlew run` against a daemon that was started + // without it would otherwise be told, silently, that they had set nothing -- and have + // their prefix replaced by a staged one on the very task they were configuring. + if (shouldStagePythonHome(project.providers.environmentVariable("PYTHONHOME").orNull, enabled)) { + val home = prefix.absolutePath + project.tasks.withType(org.gradle.api.tasks.JavaExec::class.java).configureEach { + dependsOn(stage) + environment("PYTHONHOME", home) + } + project.tasks.withType(org.gradle.api.tasks.testing.Test::class.java).configureEach { + dependsOn(stage) + environment("PYTHONHOME", home) + } + } + + configurePackagedPythonHome( + project = project, + extension = extension, + stage = stage, + includes = packagedPrefixIncludes(version, freeThreaded, windows = platform.startsWith("windows")), + ) + } + + /** + * SPEC L-9: copies the staged prefix's stdlib and shared library into every Compose Desktop + * `prepare*AppResources` task under [PACKAGED_HOME_DIRECTORY], so that `createDistributable` + * puts it at `$APPDIR/resources/python-multiplatform-home` -- where the library looks, through + * the `compose.application.resources.dir` property Compose's launcher always sets. + * + * Into Compose's own resources task rather than through `nativeDistributions.appResourcesRootDir`: + * that is a single directory property the consumer may already be using for their own files, + * and setting it here would replace theirs. Adding a `from` to the task that assembles it + * composes with whatever the consumer configured. One copy, too: the prefix goes straight from + * the Gradle-user-home cache into Compose's staging directory, and `Sync` is incremental. + * + * [PythonBindingsExtension.packagePythonHome] is read when the task is *executed* and its + * dependencies computed, not now -- this runs while the plugin is being applied, before the + * consumer's `pythonBindings { }` block has. + */ + private fun configurePackagedPythonHome( + project: Project, + extension: PythonBindingsExtension, + stage: org.gradle.api.tasks.TaskProvider, + includes: List, + ) { + val packaging = project.provider { extension.packagePythonHome.getOrElse(true) } + val stagedPrefix = stage.flatMap { it.destinationDir.dir("python") } + project.tasks.withType(org.gradle.api.tasks.Sync::class.java) + .matching { isComposeAppResourcesTask(it.name) } + .configureEach { + dependsOn(java.util.concurrent.Callable { if (packaging.get()) listOf(stage) else emptyList() }) + from(java.util.concurrent.Callable { if (packaging.get()) stagedPrefix else emptyList() }) { + include(includes) + exclude(PACKAGED_PREFIX_EXCLUDES) + into(PACKAGED_HOME_DIRECTORY) + } + doLast { + requireNoEmptyLibraries(destinationDir.resolve(PACKAGED_HOME_DIRECTORY)) + } + } + } + + /** + * Registers unpacking of the wasm browser runtime, and wires it into whichever `wasmJs*` tasks + * this consumer's build happens to declare -- ROADMAP §10. + * + * `stage` is always registered; [StageWasmBrowserRuntimeTask]'s detached configuration is not + * resolved until the task executes, and nothing makes it execute unless a `wasmJsProcessResources` + * or `wasmJs*Webpack` task exists to depend on it. So a consumer with no `wasmJs()` target pays + * nothing -- no network access, no `mavenLocal`/repository lookup -- for this being registered. + * + * `tasks.matching` is a live view: it fires for tasks declared *after* this runs too, which is + * the normal case since `kotlin { wasmJs() }` is declared after `plugins { ... }`. Matched by + * name rather than by Kotlin Gradle Plugin task type for the same reason `TEST_WORD` is -- this + * plugin's `kotlin-dsl` classpath does not carry the Kotlin Multiplatform Gradle plugin, so it + * has no compile-time reference to `KotlinWebpack` or `ProcessResources`'s Kotlin/JS subclass to + * match against. `org.gradle.language.jvm.tasks.ProcessResources` is Gradle's own class, not the + * Kotlin Gradle Plugin's, so casting `wasmJsProcessResources` to it does not add that dependency. + */ + private fun configureWasmBrowserRuntimeStaging(project: Project, extension: PythonBindingsExtension) { + if (!extension.stageWasmBrowserRuntime.getOrElse(true)) return + + val coordinates = extension.wasmRuntimeCoordinates.getOrElse(DEFAULT_WASM_RUNTIME_COORDINATES) + val configuration = project.configurations.detachedConfiguration( + project.dependencies.create(coordinates), + ).apply { isTransitive = false } + + val destination = project.layout.buildDirectory.dir("wasm-browser-runtime") + val stage = project.tasks.register("stageWasmBrowserRuntime", StageWasmBrowserRuntimeTask::class.java) { + group = "python" + description = "Unpacks python-multiplatform's published CPython wasm browser runtime." + runtimeArtifact.from(configuration) + destinationDir.set(destination) + } + + // The *main* resources task, not `wasmJsTestProcessResources` -- test runs go through the + // node runner, which this plugin does not wire (ROADMAP §10 scopes this to browser bundles). + project.tasks.matching { it.name == "wasmJsProcessResources" }.configureEach { + dependsOn(stage) + (this as org.gradle.language.jvm.tasks.ProcessResources).from(destination) + } + + // Every webpack task reads the compile-sync output, so the patch has to land between that + // sync and webpack's own run -- `doFirst` on the webpack task is the only point that is + // after one and before the other, exactly as `:sample`'s own copy of this does. + // + // The generated module name is `"${rootProject.name}-${project.name}"` only for a + // *subproject* -- `:sample` is one, which is why its own copy of this patch could use that + // formula unconditionally. A single-module build (`consumer-plugin-android`, ROADMAP §10's + // verification) has the `wasmJs()` target on the *root* project, and there Kotlin does not + // double the name: the generated files are `consumer-plugin-android.*`, not + // `consumer-plugin-android-consumer-plugin-android.*`. Checked against the actual compile + // -sync output rather than assumed -- the first version of this patch named a directory + // that never existed and failed with "No *.import-object.mjs in ...". + val modulePrefix = if (project == project.rootProject) { + project.name + } else { + "${project.rootProject.name}-${project.path.removePrefix(":").replace(":", "-")}" + } + project.tasks.matching { it.name.startsWith("wasmJs") && it.name.endsWith("Webpack") }.configureEach { + dependsOn(stage) + val syncedDir = project.rootProject.layout.buildDirectory.dir("wasm/packages/$modulePrefix/kotlin") + val log = project.logger + doFirst { + patchWasmOutputForCPython(syncedDir.get().asFile, modulePrefix, log) + } + } + } + + /** + * `ksp { arg(key, value) }` without a compile-time reference to `KspExtension` -- see the + * `runtimeOnly` comment in this build's `build.gradle.kts` for why there cannot be one. + * A missing method here means KSP changed its extension API, which is worth a loud failure at + * configuration time rather than a silently unconfigured processor. + */ + private fun Project.setKspArg(key: String, value: String) { + val ksp = extensions.findByName("ksp") + ?: error("the KSP extension is missing: com.google.devtools.ksp was not applied") + val arg = ksp.javaClass.methods.firstOrNull { method -> + method.name == "arg" && + method.parameterCount == 2 && + method.parameterTypes.all { it == String::class.java } + } ?: error("KspExtension has no arg(String, String); python-multiplatform-gradle-plugin needs updating") + arg.invoke(ksp, key, value) + } + + internal fun configureWasmProxyExports(project: Project) { + val kotlin = project.extensions.findByName("kotlin") ?: return + val sourceSets = kotlin.javaClass.methods + .firstOrNull { it.name == "getSourceSets" && it.parameterCount == 0 } + ?.also { it.isAccessible = true } + ?.invoke(kotlin) as? org.gradle.api.NamedDomainObjectContainer<*> + ?: return + if (sourceSets.findByName("wasmJsMain") == null) return + + val task = project.tasks.register("generateWasmProxyExports", GenerateWasmProxyExportsTask::class.java) { + group = "python" + description = "Generates the @WasmExport trampoline functions for CPython proxy type slots." + packageName.set("python.multiplatform.generated.wasm") + outputDirectory.set(project.layout.buildDirectory.dir("generated/wasmProxyExports/main")) + } + addKotlinSourceDirectory(project, "wasmJsMain", task) + } +} 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 new file mode 100644 index 000000000..ce08d41c5 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/PythonHomeStaging.kt @@ -0,0 +1,548 @@ +package python.multiplatform.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ArchiveOperations +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.net.URI +import java.security.MessageDigest +import javax.inject.Inject + +/** + * 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 + * `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. + * + * Every path that *produces* such a prefix (`downloadAllPythonBuilds` and its per-platform + * `downloadPython_*` tasks) lives in `python-multiplatform/build.gradle.kts` and is reachable only + * by building that repository from source. So a consumer who followed README to the letter had a + * working interpreter binary, a working object model, working upcalls, and no way to obtain the + * ~24 MB of Python source files the first `import` needs. + * + * ### Why this is a build-time task rather than a runtime helper + * + * Android solved the same problem at runtime (`PythonBootstrap`, §15f) because there was no + * choice: the stdlib has to travel *inside* the APK, so something has to unpack it out of + * `assets/` on the device. Neither half of that reasoning transfers to desktop, and one of them + * inverts. + * + * - **The bytes do not have to ship in the artifact.** A desktop `PYTHONHOME` may name any path on + * the machine, so the prefix can be fetched once per machine and shared by every project on it, + * 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 + * 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 + * 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 + * 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`. + * + * ### A packaged application (SPEC L-9) + * + * Both points above are about the developer's machine. A packaged app has no Gradle to set its + * environment, so the same staged prefix is also copied into Compose Desktop's application + * resources ([packagedPrefixIncludes], `PythonBindingsPlugin.configurePackagedPythonHome`) and the + * library hands it to CPython in-process with `Py_SetPythonHome` -- which is not an environment + * variable, so the objection above does not apply to it. Only the host's platform is copied, into + * the *application*, never into the library artifact. See `docs/platforms/desktop-packaged-app.md`. + * + * ### What it deliberately does not do + * + * It does not validate the prefix beyond [stdlibMarkerRelativePath], and that marker is the file + * `PythonHomeCheck.diagnose` itself looks for. A second, independent notion of "usable prefix" is + * a second thing that can drift from the layout `Py_Initialize()` wants; this produces the layout, + * that verifies it, on the value this sets. + */ +internal object PythonHomeStaging { + + /** Where every project on this machine shares one staged prefix, under the Gradle user home. */ + internal const val CACHE_DIRECTORY = "python-multiplatform/python-home" + + /** Written after the last extracted byte; see [stagingStamp]. */ + internal const val STAMP_NAME = ".python-multiplatform-prefix" +} + +/** + * This library's name for the host, or null when the host is not one it ships for. + * + * The four returned names are not free-form: `manager.platformDirectory()` builds the same strings + * to locate `lib//libpython*` inside `desktopJar`, and `desktopTargets` in + * `python-multiplatform/build.gradle.kts` keys the download tasks by them. + * + * `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 + * not verified on such a host, see this file's own note in ROADMAP §15g. + */ +internal fun hostDesktopPlatform(osName: String, osArch: String): String? { + val arch = when (osArch.lowercase()) { + "aarch64", "arm64" -> "aarch64" + "x86_64", "amd64" -> "x86_64" + else -> return null + } + val name = osName.lowercase() + val os = when { + name.startsWith("mac") || name.startsWith("darwin") -> "macos" + name.startsWith("windows") -> "windows" + name.startsWith("linux") -> "linux" + else -> return null + } + // Upstream publishes no aarch64 Windows `install_only_stripped` build, and this library has never + // shipped a `libpython` for one either. + if (os == "windows" && arch == "aarch64") return null + return "$os-$arch" +} + +/** python-build-standalone's target triple for one of [hostDesktopPlatform]'s names. */ +internal fun pbsTripleFor(platform: String): String = when (platform) { + "macos-aarch64" -> "aarch64-apple-darwin" + "macos-x86_64" -> "x86_64-apple-darwin" + "linux-x86_64" -> "x86_64-unknown-linux-gnu" + "linux-aarch64" -> "aarch64-unknown-linux-gnu" + "windows-x86_64" -> "x86_64-pc-windows-msvc" + else -> throw IllegalArgumentException("no python-build-standalone triple for platform '$platform'") +} + +/** + * The release asset holding a CPython prefix for [platform]. + * + * Byte-for-byte the name `downloadPython_*` builds in `python-multiplatform/build.gradle.kts`. The + * two must agree: a consumer that stages a *different* upstream build than the `libpython` in + * `desktopJar` was taken from can pair a stdlib and an interpreter that disagree about ABI while + * both calling themselves the same version. + */ +internal fun pbsAssetName( + pythonVersion: String, + pbsRelease: String, + platform: String, + freeThreaded: Boolean, +): String { + val flavour = if (freeThreaded) "freethreaded-install_only_stripped" else "install_only_stripped" + return "cpython-$pythonVersion+$pbsRelease-${pbsTripleFor(platform)}-$flavour.tar.gz" +} + +internal fun pbsAssetUrl(pbsRelease: String, assetName: String): String = + "https://github.com/astral-sh/python-build-standalone/releases/download/$pbsRelease/$assetName" + +/** + * The SHA-256 upstream records for [assetName], or null when the release does not list it. + * + * Matched on the whole filename rather than with `endsWith`, which the root build uses and which + * is wrong in principle: `...-install_only_stripped.tar.gz` is a suffix of + * `...-freethreaded-install_only_stripped.tar.gz`, so asking for the default asset could return the + * free-threaded hash and fail verification on a file that is perfectly intact. + */ +internal fun sha256Of(sha256sums: String, assetName: String): String? = + sha256sums.lineSequence() + .mapNotNull { line -> + val trimmed = line.trim() + if (trimmed.isEmpty()) return@mapNotNull null + // Upstream's format is " ". + val separator = trimmed.indexOf(' ') + if (separator <= 0) return@mapNotNull null + val name = trimmed.substring(separator).trim().removePrefix("*") + if (name == assetName) trimmed.substring(0, separator) else null + } + .firstOrNull() + +/** + * 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 + * `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. + */ +internal fun stdlibMarkerRelativePath(pythonVersion: String, freeThreaded: Boolean, windows: Boolean): String { + if (windows) return "Lib/os.py" + val parts = pythonVersion.split('.') + require(parts.size >= 2) { "python version '$pythonVersion' has no major.minor" } + val tag = "${parts[0]}.${parts[1]}" + if (freeThreaded) "t" else "" + return "lib/python$tag/os.py" +} + +/** + * 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 + * 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 + * CPython version under a new date tag, and both feed the same cache directory. + */ +internal fun stagingStamp( + pythonVersion: String, + pbsRelease: String, + platform: String, + freeThreaded: Boolean, +): String = "python-multiplatform $pythonVersion+$pbsRelease $platform " + + (if (freeThreaded) "freethreaded" else "default") + + // Bumped when the extraction itself changes. "links-materialised" (issue #74): Gradle's + // `tarTree` turned the archive's `libpython3.14.so -> libpython3.14.so.1.0` into a 0-byte file, + // so every prefix staged before this is wrong and must be extracted again. + " links-materialised" + +/** + * Extracts a python-build-standalone `.tar.gz` into [root], turning every symbolic and hard link + * into a copy of its in-archive target (issue #74). + * + * Gradle's `tarTree` extracts a symlink as an empty regular file, so `lib/libpython3.14.so` -- a + * link to `libpython3.14.so.1.0` -- came out 0 bytes and `System.load` failed with "file too + * short". A copy rather than a link also survives being copied into a jar or an app image, which + * flatten links. Dependency-free: `GZIPInputStream` plus a minimal ustar/GNU/pax header reader. + * A link whose target is not inside the archive, or that escapes [root], is skipped. + */ +internal fun extractTarGzMaterialisingLinks(archive: File, root: File) { + val rootPath = root.canonicalFile.toPath() + fun inside(rel: String): java.nio.file.Path? { + val p = rootPath.resolve(rel).normalize() + return if (p.startsWith(rootPath) && p != rootPath) p else null + } + class Link(val path: java.nio.file.Path, val target: String, val hard: Boolean) + val links = mutableListOf() + + java.util.zip.GZIPInputStream(archive.inputStream().buffered(1 shl 16)).use { input -> + val header = ByteArray(512) + var longName: String? = null + var longLink: String? = null + var paxPath: String? = null + var paxLink: String? = null + + fun readFully(buf: ByteArray, len: Int): Boolean { + var n = 0 + while (n < len) { val r = input.read(buf, n, len - n); if (r < 0) return false; n += r } + return true + } + fun str(off: Int, len: Int): String { + var end = off + while (end < off + len && header[end] != 0.toByte()) end++ + return String(header, off, end - off, Charsets.UTF_8) + } + fun octal(off: Int, len: Int): Long = str(off, len).trim().ifEmpty { "0" }.toLong(8) + fun readData(size: Long): ByteArray { + val padded = (size + 511) / 512 * 512 + val buf = ByteArray(padded.toInt()) + check(readFully(buf, buf.size)) { "truncated tar entry in $archive" } + return buf.copyOf(size.toInt()) + } + + while (true) { + if (!readFully(header, 512)) break + if (header.all { it == 0.toByte() }) break + var name = str(0, 100) + val prefix = str(345, 155) + if (prefix.isNotEmpty() && str(257, 5) == "ustar") name = "$prefix/$name" + val mode = octal(100, 8) + val size = octal(124, 12) + val type = header[156].toInt().toChar() + var linkName = str(157, 100) + when (type) { + 'L' -> { longName = String(readData(size), Charsets.UTF_8).trimEnd('\u0000'); continue } + 'K' -> { longLink = String(readData(size), Charsets.UTF_8).trimEnd('\u0000'); continue } + 'x' -> { + String(readData(size), Charsets.UTF_8).lineSequence().forEach { rec -> + val kv = rec.substringAfter(' ', "") + when { + kv.startsWith("path=") -> paxPath = kv.removePrefix("path=") + kv.startsWith("linkpath=") -> paxLink = kv.removePrefix("linkpath=") + } + } + continue + } + 'g' -> { readData(size); continue } + } + (paxPath ?: longName)?.let { name = it } + (paxLink ?: longLink)?.let { linkName = it } + longName = null; longLink = null; paxPath = null; paxLink = null + + val dest = inside(name.trimEnd('/')) ?: run { + if (size > 0) readData(size) + null + } ?: continue + when (type) { + '5' -> java.nio.file.Files.createDirectories(dest) + '2' -> links += Link(dest, linkName, hard = false) + '1' -> links += Link(dest, linkName, hard = true) + '0', '\u0000', '7' -> { + java.nio.file.Files.createDirectories(dest.parent) + dest.toFile().outputStream().use { out -> + var remaining = size + val buf = ByteArray(1 shl 16) + while (remaining > 0) { + val r = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt()) + check(r >= 0) { "truncated tar entry $name in $archive" } + out.write(buf, 0, r); remaining -= r + } + } + var pad = (512 - size % 512) % 512 + while (pad > 0) { val r = input.skip(pad); if (r <= 0) { input.read(); pad-- } else pad -= r } + if (mode and 0b001_001_001L != 0L) dest.toFile().setExecutable(true, false) + } + else -> if (size > 0) readData(size) + } + } + } + + // Links last, repeated until stable, so a link to a link (or to a later entry) resolves. + var pending: List = links + while (pending.isNotEmpty()) { + val unresolved = mutableListOf() + for (link in pending) { + val source = if (link.hard) inside(link.target) + else link.path.parent.resolve(link.target).normalize().takeIf { it.startsWith(rootPath) } + val file = source?.toFile() + if (file == null || !file.exists() || (file.isFile && pending.any { it.path == source })) { + unresolved += link; continue + } + if (file.isDirectory) file.copyRecursively(link.path.toFile(), overwrite = true) + else { + java.nio.file.Files.createDirectories(link.path.parent) + file.copyTo(link.path.toFile(), overwrite = true) + link.path.toFile().setExecutable(file.canExecute(), false) + } + } + if (unresolved.size == pending.size) break // remaining targets are outside the archive + pending = unresolved + } +} + +/** + * Fails when a packaged prefix carries a zero-length shared library -- a symlink stub is never a + * library, and shipping one fails at the user's first launch with "file too short" (issue #74). + */ +internal fun requireNoEmptyLibraries(dir: File) { + if (!dir.isDirectory) return + val empty = dir.walkTopDown() + .filter { it.isFile && it.length() == 0L && it.name.startsWith("libpython") } + .map { it.relativeTo(dir).path }.toList() + if (empty.isNotEmpty()) { + throw GradleException("packaged CPython prefix contains zero-length libraries: $empty (symlink stubs?)") + } +} + +/** + * The directory a packaged desktop application carries its CPython prefix in, inside Compose + * Desktop's application resources (`$APPDIR/resources/` in a `createDistributable` image) -- + * SPEC L-9, issue #60. + * + * `PackagedPythonHome.DIRECTORY_NAME` in the library's `jvmMain` is the run-time half and must be + * the same string; the two builds share no code, so each side pins it in a test. + */ +internal const val PACKAGED_HOME_DIRECTORY = "python-multiplatform-home" + +/** + * What of a staged prefix a packaged application needs, as include patterns relative to the prefix. + * + * The standard library and the shared library `manager.loadLibPython` loads out of the same prefix. + * Measured for 3.14.7 macos-aarch64 on this machine, `__pycache__` excluded: 1,195 files and + * 39.6 MB (19.4 MB of it `libpython3.14.dylib`) out of the prefix's 68.9 MB. Left out: + * `include/` and `lib/pkgconfig` (build-time only), `bin/` (an embedding app has no `python3` + * executable to run), and Tcl/Tk (`libtcl*`, `tcl9.0/`, `tk9.0/`: `tkinter` is not something a + * Compose app reaches, and they are a further 4 MB of libraries alone). + * + * The Windows layout differs and is matched as the library's own lookups match it: `Lib/` and + * `DLLs/` (extension modules) under the prefix, and the interpreter plus the MSVC runtime as + * `*.dll` at its root, which is where `manager.libraryUnder` looks on that layout. + */ +internal fun packagedPrefixIncludes(pythonVersion: String, freeThreaded: Boolean, windows: Boolean): List { + if (windows) return listOf("Lib/**", "DLLs/**", "*.dll") + val parts = pythonVersion.split('.') + require(parts.size >= 2) { "python version '$pythonVersion' has no major.minor" } + val tag = "${parts[0]}.${parts[1]}" + if (freeThreaded) "t" else "" + return listOf("lib/python$tag/**", "lib/libpython$tag.*") +} + +/** + * Never packaged: the staged prefix is shared by every project on the machine, and CPython writes + * bytecode caches into it from every run that uses it, so its `__pycache__` directories reflect the + * developer's history rather than the build. + */ +internal val PACKAGED_PREFIX_EXCLUDES: List = listOf("**/__pycache__/**") + +/** + * Compose Desktop's `Sync` task that assembles an application's resources: `prepareAppResources` + * for the default build type, `prepareAppResources` for the others (`Release`). Matched + * by name, like the wasm wiring matches `wasmJsProcessResources`, so this plugin carries no + * compile-time dependency on the Compose Gradle plugin; `Sync` itself is Gradle's own type. + */ +internal fun isComposeAppResourcesTask(name: String): Boolean = + Regex("""prepare([A-Z]\w*)?AppResources""").matches(name) + +/** + * 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 + * 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. + */ +internal fun shouldStagePythonHome(existingPythonHome: String?, stagingEnabled: Boolean): Boolean = + stagingEnabled && existingPythonHome.isNullOrBlank() + +/** + * Downloads, verifies and extracts one python-build-standalone distribution into a shared + * per-machine cache, then stamps it. + * + * Incremental in the way that matters: a matching stamp makes the whole task body two `stat` calls + * and a short read, which is what keeps this off the critical path of every subsequent build. + */ +abstract class StagePythonHomeTask : DefaultTask() { + + @get:Input + abstract val pythonVersion: Property + + @get:Input + abstract val pbsRelease: Property + + @get:Input + abstract val platform: Property + + @get:Input + abstract val freeThreaded: Property + + /** The directory the archive is extracted into; the prefix itself is `python/` beneath it. */ + @get:OutputDirectory + abstract val destinationDir: DirectoryProperty + + /** Where downloaded archives are kept, shared across every project on this machine. */ + @get:org.gradle.api.tasks.Internal + abstract val downloadDir: DirectoryProperty + + @get:Inject + protected abstract val fs: FileSystemOperations + + @get:Inject + protected abstract val archives: ArchiveOperations + + @TaskAction + fun stage() { + val version = pythonVersion.get() + val release = pbsRelease.get() + val target = platform.get() + val ft = freeThreaded.get() + + val root = destinationDir.get().asFile + val stamp = File(root, PythonHomeStaging.STAMP_NAME) + val expected = stagingStamp(version, release, target, ft) + val prefix = File(root, "python") + val marker = File(prefix, stdlibMarkerRelativePath(version, ft, windows = target.startsWith("windows"))) + + // Both conditions, not either: the stamp proves the extraction finished, the marker proves + // the tree is the shape `PythonHomeCheck` will demand of it. A stamp alone would survive + // someone deleting the prefix out of the cache by hand. + if (stamp.isFile && stamp.readText() == expected && marker.isFile) { + didWork = false + logger.info("CPython {} prefix already staged at {}", version, prefix) + return + } + + val assetName = pbsAssetName(version, release, target, ft) + val archive = File(downloadDir.get().asFile, assetName) + download(assetName, release, archive) + + // Order matters, and it is the same order `PythonBootstrap` uses on Android: the stamp + // goes first and comes back last, so a stamp is never valid for a tree that is currently + // being replaced. + stamp.delete() + fs.delete { delete(root) } + if (!root.mkdirs() && !root.isDirectory) { + throw GradleException("could not create $root to stage the CPython prefix into") + } + + logger.lifecycle("Staging CPython $version ($target) into $prefix") + // Not `tarTree`: it extracts the archive's symlinks as empty files (issue #74). + extractTarGzMaterialisingLinks(archive, root) + + if (!marker.isFile) { + throw GradleException( + "staged $assetName into $root but ${marker.absolutePath} is missing, so PYTHONHOME " + + "would not be usable. The archive layout may have changed upstream; this task " + + "expects a single top-level 'python/' directory holding the prefix." + ) + } + stamp.writeText(expected) + } + + private fun download(assetName: String, release: String, archive: File) { + if (archive.isFile) { + // Re-verified below rather than trusted: a half-written archive from an interrupted + // build is exactly the case a plain existence check accepts. + if (verify(assetName, release, archive, throwOnMismatch = false)) return + logger.lifecycle("Cached $assetName failed checksum verification; downloading again") + archive.delete() + } + archive.parentFile.mkdirs() + val url = pbsAssetUrl(release, assetName) + logger.lifecycle("Downloading $url") + val partial = File(archive.parentFile, "${archive.name}.part") + partial.delete() + URI(url).toURL().openStream().use { input -> + partial.outputStream().use { output -> input.copyTo(output) } + } + // Renamed only once it is complete, so a build interrupted mid-download leaves a `.part` + // behind rather than a plausible-looking archive. + if (!partial.renameTo(archive)) { + throw GradleException("could not move $partial into place at $archive") + } + verify(assetName, release, archive, throwOnMismatch = true) + } + + private fun verify(assetName: String, release: String, archive: File, throwOnMismatch: Boolean): Boolean { + val sumsFile = File(archive.parentFile, "SHA256SUMS-$release") + if (!sumsFile.isFile) { + val url = "https://github.com/astral-sh/python-build-standalone/releases/download/$release/SHA256SUMS" + URI(url).toURL().openStream().use { input -> + sumsFile.outputStream().use { output -> input.copyTo(output) } + } + } + val expected = sha256Of(sumsFile.readText(), assetName) + ?: throw GradleException( + "python-build-standalone release $release does not list $assetName in its " + + "SHA256SUMS. Check that pythonVersion and pythonBuildStandaloneRelease name a " + + "combination that upstream actually published." + ) + + val digest = MessageDigest.getInstance("SHA-256") + archive.inputStream().use { stream -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = stream.read(buffer) + if (read <= 0) break + digest.update(buffer, 0, read) + } + } + val actual = digest.digest().joinToString("") { "%02x".format(it) } + if (actual == expected) return true + if (throwOnMismatch) { + archive.delete() + throw GradleException("checksum mismatch for $assetName: expected $expected, got $actual") + } + return false + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmBrowserRuntimeStaging.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmBrowserRuntimeStaging.kt new file mode 100644 index 000000000..b4d83da2f --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmBrowserRuntimeStaging.kt @@ -0,0 +1,159 @@ +package python.multiplatform.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ArchiveOperations +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.logging.Logger +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import java.io.File +import javax.inject.Inject + +/** + * Unpacking `python-multiplatform`'s published wasm browser runtime for a consumer -- ROADMAP §10. + * + * ### The gap this closes + * + * `python-multiplatform`'s own `stageWasmBrowserRuntime` assembles CPython's Emscripten build plus + * this library's `cpython.mjs` into a directory a `wasmJsMain.resources.srcDir` can point at, and + * `:sample` proved that assembling it is enough to make a *browser* bundle come up (not just the + * node test runner). But that task reads a **local directory** named by `-PwasmPythonDir`, which + * exists only inside this repository's checkout. An external consumer has no such directory and no + * way to get one -- the CPython build it names is produced by hand-patching CPython's own source + * (`Lib/sysconfig`, the generated `Makefile`) against a specific Emscripten SDK version, which is + * not something any upstream publishes yet. See `tools/wasm/build-cpython.sh`'s own header for why: PEP + * 783's `pyemscripten_2026_0` tag is real and accepted, but nothing ships a `python.wasm` whose + * `EXPORTED_RUNTIME_METHODS` include `wasmExports`/`wasmMemory` -- that addition exists only so this + * library's `@WasmImport` declarations have something to bind to, and no distributor has a reason to + * carry it. + * + * So the *runtime itself* had to become something a consumer's build can resolve, not just + * something this repository can assemble. `python-multiplatform/build.gradle.kts` zips + * `stageWasmBrowserRuntime`'s output and publishes it under its own artifact ID + * (`python-multiplatform-wasm-runtime`), at the library's own version -- so resolving the library + * and resolving its runtime never drift apart the way two independently-versioned coordinates could. + * This task is the other half: unpack that zip for whichever build actually needs it. + * + * ### Why a detached configuration rather than a task dependency + * + * A consumer who never adds a `wasmJs()` target must not need this artifact to exist in `mavenLocal` + * (or wherever it eventually publishes) at all -- most won't build a browser bundle, and this + * artifact is ~7 MB compressed. [PythonBindingsPlugin.apply] therefore always *registers* this task + * (registration is free), but the detached configuration behind [runtimeArtifact] is resolved only + * when this task actually executes, and it only executes when something depends on it -- which + * [PythonBindingsPlugin] wires up by matching `wasmJs*` task names, so it only happens for a project + * that actually has the target. + */ +abstract class StageWasmBrowserRuntimeTask : DefaultTask() { + + /** The resolved `python-multiplatform-wasm-runtime` zip; a detached configuration's files. */ + @get:Classpath + abstract val runtimeArtifact: ConfigurableFileCollection + + /** Where the zip is unpacked to -- what a consumer's `wasmJsMain.resources.srcDir` names. */ + @get:OutputDirectory + abstract val destinationDir: DirectoryProperty + + @get:Inject + protected abstract val archives: ArchiveOperations + + @get:Inject + protected abstract val fs: FileSystemOperations + + @TaskAction + fun stage() { + val zip = runtimeArtifact.singleFile + val dir = destinationDir.get().asFile + // Deleted and rewritten whole rather than merged: a stale `python.wasm` sitting next to a + // fresh `cpython.mjs` from a different library version is exactly the ABI-drift bug + // `verifyWasmAbiSignatures` exists to catch upstream, and unpacking into a mixed directory + // here would hide it instead. + fs.delete { delete(dir) } + fs.copy { + from(archives.zipTree(zip)) + into(dir) + } + logger.lifecycle("Staged CPython's wasm browser runtime (from $zip) into $dir") + } +} + +/** + * Rewrites a consumer's generated Kotlin/Wasm output so that it can reach the interpreter. + * + * Byte-identical in intent to `patchKotlinWasmOutputForCPython` in + * `python-multiplatform/build.gradle.kts`, and to `:sample`'s own copy of it -- ROADMAP §10 records + * why there were already two: a Gradle build script's functions are not visible to another + * project's build script, so every consumer had to carry this by hand. This is the third copy and + * the one meant to end the duplication -- it lives where [PythonBindingsPlugin] can reach it, so a + * consumer who applies the plugin does not write it at all. + * + * Deliberately free of any Kotlin Gradle Plugin type: it edits generated `.mjs` files with `File` and + * `Regex`, the same way the two prior copies do, so this plugin's `kotlin-dsl` classpath (Gradle's + * embedded Kotlin, not this repository's) never needs to see Kotlin/Wasm's task types. + * + * @param dir the compile-sync output webpack reads, `build/wasm/packages//kotlin`. + * @param modulePrefix the generated module basename, which is `-`. + */ +internal fun patchWasmOutputForCPython(dir: File, modulePrefix: String, logger: Logger) { + val importObject = dir.listFiles()?.firstOrNull { it.name.endsWith(".import-object.mjs") } + ?: throw GradleException("No *.import-object.mjs in $dir -- the Kotlin/Wasm output layout changed.") + val text = importObject.readText() + + // Unlike `:sample` and the library's own wasmJs test bundle -- which always call into the FFI, + // so the import is guaranteed present -- a generic external consumer may build a wasmJs target + // whose reachable code never calls `python-multiplatform` at all (observed against + // `consumer-plugin-android`'s KSP-generated `FunctionTable`, which wraps plain Kotlin functions + // with no path into `bindings.kt`). Kotlin/Wasm's dead-code elimination then removes every + // `@WasmImport` declaration along with the `./cpython.mjs` import that names them, and there is + // nothing to patch -- which is correct, not an error to fail the build over. + val ns = Regex("""import \* as (\w+) from ['"]\./cpython\.mjs['"];""").find(text)?.groupValues?.get(1) + if (ns == null) { + logger.info( + "${importObject.name} does not import ./cpython.mjs -- nothing in this module's " + + "reachable wasmJs code calls into python-multiplatform, so there is nothing to patch." + ) + return + } + val placeholder = Regex("""memory:\s*new WebAssembly\.Memory\(\{[^}]*}\)""") + if (placeholder.containsMatchIn(text)) { + importObject.writeText(placeholder.replace(text, "memory: $ns.wasmMemory")) + logger.lifecycle("Pointed ${importObject.name}'s intrinsics.memory at Emscripten's wasmMemory") + } else if (!text.contains("memory: $ns.wasmMemory")) { + throw GradleException( + "${importObject.name} has no `intrinsics.memory` placeholder to replace and is not " + + "already patched. See docs/platforms/wasm-design.md's integration step." + ) + } + + val entry = dir.listFiles() + ?.firstOrNull { + it.name.endsWith(".mjs") && !it.name.contains("import-object") && + !it.name.contains("js-builtins") && it.name.startsWith(modulePrefix) + } + ?: throw GradleException("No Kotlin/Wasm entry module in $dir -- the output layout changed.") + val handoff = "pmpSetKotlinExports" + val entryText = entry.readText() + if (!entryText.contains(handoff)) { + if (!entryText.contains("const exports = wasmInstance.exports")) { + throw GradleException( + "${entry.name} has no `const exports = wasmInstance.exports` to hand to cpython.mjs." + ) + } + // Before `exports._start()`, not appended after it -- `_start()` is Kotlin `main()` in an + // executable bundle, and appending ran the handoff one line too late. See ROADMAP §10. + val startCall = "exports._start();" + val note = "// Added by python-multiplatform-gradle-plugin: upcall registration needs the\n" + + "// raw wasm exports, and this is the only scope that has them. See ROADMAP §7/§10.\n" + val body = if (entryText.contains(startCall)) { + entryText.replace(startCall, "$handoff(exports);\n\n$startCall") + } else { + entryText.trimEnd() + "\n\n$handoff(exports);\n" + } + entry.writeText(note + "import { $handoff } from './cpython.mjs';\n\n" + body) + logger.lifecycle("Handed ${entry.name}'s wasm exports to cpython.mjs for upcall registration") + } +} 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 new file mode 100644 index 000000000..b64e9f0a9 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/WasmProxyExportsRendering.kt @@ -0,0 +1,116 @@ +package python.multiplatform.gradle + +/** + * Export names matching `python.multiplatform.ffi.ProxyTypeExportNames` in the library. + * + * Duplicated rather than imported: the Gradle plugin has no runtime dependency on + * `python-multiplatform`, so it cannot reach the library's constants at build time. + * These are ABI -- CPython's slot mechanism depends on them -- and they will not change + * without a coordinated update to both the library and this plugin. + */ +internal const val WASM_EXPORT_TRAVERSE = "pmp_tp_traverse" +internal const val WASM_EXPORT_CLEAR = "pmp_tp_clear" +internal const val WASM_EXPORT_DEALLOC = "pmp_tp_dealloc" + +/** Fully-qualified name of the library singleton that holds the slot implementations. */ +private const val PROXY_TYPE = "python.multiplatform.ffi.ProxyType" + +/** + * The generated file name (no `.kt` extension; KSP/Gradle conventions omit it). + * + * Placed beside the other generated wasm files; the name makes its origin unambiguous. + */ +internal const val WASM_PROXY_EXPORTS_FILE_NAME = "WasmProxyTypeExports" + +/** + * Renders the three `@WasmExport` trampoline functions an executable wasm module must declare + * so that CPython's type machinery can call back into Kotlin. + * + * ### 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 + * 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. + * + * ### What each trampoline does + * + * Each body is a one-line delegation to [ProxyType]'s corresponding method. All logic, including + * everything that could ever need changing, stays in the library. The trampolines are glue only. + * + * ### Arities are load-bearing + * + * `call_indirect` is statically typed and does not coerce. A trampoline whose arity does not + * 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) -> () + * + * [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. + */ +internal data class WasmExportSlot( + val exportName: String, + val kotlinName: String, + val parameters: String, + val returnType: String, + val delegateCall: String, + val multiline: Boolean = false +) + +internal val DEFAULT_PROXY_TYPE_SLOTS = listOf( + WasmExportSlot( + exportName = WASM_EXPORT_TRAVERSE, + kotlinName = "pmpTpTraverse", + parameters = "selfPtr: Int, visitPtr: Int, argPtr: Int", + returnType = "Int", + delegateCall = "$PROXY_TYPE.traverse(selfPtr, visitPtr, argPtr)", + multiline = true + ), + WasmExportSlot( + exportName = WASM_EXPORT_CLEAR, + kotlinName = "pmpTpClear", + parameters = "selfPtr: Int", + returnType = "Int", + delegateCall = "$PROXY_TYPE.clear(selfPtr)", + multiline = false + ), + WasmExportSlot( + exportName = WASM_EXPORT_DEALLOC, + kotlinName = "pmpTpDealloc", + parameters = "selfPtr: Int", + returnType = "Unit", + delegateCall = "$PROXY_TYPE.dealloc(selfPtr)", + multiline = false + ) +) + +internal fun renderWasmProxyExportsSource( + packageName: String, + slots: List = DEFAULT_PROXY_TYPE_SLOTS +): String = buildString { + appendLine("// GENERATED by python-multiplatform-gradle-plugin. Do not edit.") + appendLine("// See python.multiplatform.ffi.ProxyTypeExportNames for the contract this implements.") + appendLine("@file:OptIn(kotlin.wasm.ExperimentalWasmInterop::class)") + appendLine() + appendLine("package $packageName") + + for (slot in slots) { + appendLine() + appendLine("@kotlin.wasm.WasmExport(\"${slot.exportName}\")") + val decl = if (slot.returnType == "Unit") { + "fun ${slot.kotlinName}(${slot.parameters})" + } else { + "fun ${slot.kotlinName}(${slot.parameters}): ${slot.returnType}" + } + if (slot.multiline) { + appendLine("$decl =") + appendLine(" ${slot.delegateCall}") + } else { + appendLine("$decl = ${slot.delegateCall}") + } + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactRendering.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactRendering.kt new file mode 100644 index 000000000..1e09d9c05 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactRendering.kt @@ -0,0 +1,326 @@ +package python.multiplatform.gradle.artifact + +import java.io.Serializable + +/** The package walked artefacts' fragments land in. Deliberately **not** + * `python.multiplatform.generated.fragments`, which `PackageScanFragmentDiscovery` sweeps into + * KSP's `FunctionTable` -- see [renderArtifactTableSource]. It is still under + * `python.multiplatform.generated`, which `python-multiplatform-ksp`'s + * `ALWAYS_EXCLUDED_PACKAGE_PREFIXES` excludes by prefix, so the processor never rescans this + * generated source as if a consumer had written it. */ +internal const val ARTIFACTS_PACKAGE = "python.multiplatform.generated.artifacts" + +private const val FRAGMENT_INTERFACE = "python.multiplatform.reflection.FunctionTableFragment" +private const val EXPOSED_CALLABLE = "python.multiplatform.reflection.ExposedCallable" +private const val TYPE_TAG = "python.multiplatform.reflection.TypeTag" +private const val CALLABLE_KIND = "python.multiplatform.reflection.CallableKind" +private const val UPCALL_TABLE = "python.multiplatform.reflection.UpcallTable" + +/** + * One binding taken out of a compiled artefact. + * + * Mirrors `python.multiplatform.ksp.CallableEntryModel` field for field, minus what a jar cannot + * answer: there is no `isSuspend` (a `suspend` function's JVM shape takes a `Continuation`, which + * [boundaryTypeOf] declines, so no suspending declaration ever reaches here). [kind] is `FUNCTION` + * for every call, `STATIC_GETTER` for an object's constant, and `GETTER`/`SETTER` for a property read + * off an instance (issue #38) -- instance *methods* are still not bound. + * + * `Serializable`: [KlibScanWorkAction] hands one of these back across the `WorkerExecutor` isolation + * boundary it runs [KlibScanner] behind. + */ +internal data class ArtifactCallable( + val name: String, + val arity: Int, + val paramTags: List, + val returnTag: String, + val lambdaBody: String, + /** `import ... as ...` lines [lambdaBody] depends on -- non-empty only for a Kotlin extension + * function, which [lambdaBody] calls as `receiver.alias(...)`. Kotlin has no fully-qualified + * call syntax for an extension (the receiver can never be a positional argument), so the alias + * has to come from an import instead; see `ArtifactScanner`'s KDoc. */ + val imports: List = emptyList(), + /** + * The Kotlin type this declaration extends, or `null` when it is not an extension function. + * + * For a `GETTER`/`SETTER` (issue #38) it is the type the property is **read on** -- the owner of a + * member property, the receiver of an extension property -- which is what the binding layer + * keys a proxy's properties on. It is not a slot there (`CallableKind.GETTER.hasReceiver` puts + * the receiver in `args[0]` uncounted), so [renderEntry] writes `isExtension = false` for one. + * + * Carried rather than recomputed because it is not recoverable from anything else here: the + * receiver is [ArtifactCallable.paramTags]'s slot 0, and a tag says how a value is marshalled, + * not what it is. `docs/archive/pythonx-adapter-design.md` §2.4 row 3 records this exact loss -- + * "`ArtifactScanner` has both at the moment it constructs the call expression and discards them + * one line later" -- and §4.2 records that `docs/design/kotlin-extensions-in-python.md` §4.1 + * (an extension becomes a method on its receiver's proxy) has no input without it. + */ + val receiverTypeName: String? = null, + /** The declaration's parameter names, receiver slot included as ``. */ + val paramNames: List = emptyList(), + /** The Kotlin type each parameter was declared as, receiver slot included -- **not** the type it + * marshals as. A `Dp` parameter is `paramTags` `FLOAT` and `paramTypeNames` + * `androidx.compose.ui.unit.Dp`. */ + val paramTypeNames: List = emptyList(), + /** The declared Kotlin return type. */ + val returnTypeName: String? = null, + /** + * What [returnTypeName] **is a**, nearest first -- the ancestry `pythonx._coerce` needs to let a + * `BitmapPainter` fill a `Painter` slot. Empty whenever the return does not cross as a handle, + * and whenever it has no public supertype worth naming. + * + * Kept beside [returnTypeName] rather than folded into it, so that everything reading a *type* + * keeps reading one: [renderEntry] is the single place the two are joined, and the fragment's + * `returnTypeName` literal is where they travel together. See [SUPERTYPE_SEPARATOR]. + */ + val returnSupertypes: List = emptyList(), + val kind: String = "FUNCTION", + /** + * Whether each parameter may be **left out of a call**, which is a statement about [lambdaBody] + * and not quite about the declaration. + * + * `ArtifactScanner.applyDefaultOmission` sets it from the omission sets it actually generated a + * call for; the two answers differ where it declined to (a declaration past + * `ArtifactScanner.MAX_OMITTABLE_PARAMETERS`, or one whose parameters have no writable names). + * `pythonx._bind` fills a slot this marks with the `null` sentinel [lambdaBody] branches on, so + * the body's answer is the one that has to be told. [omittableSlotsOf] is where that invariant is + * enforced for both producers. + */ + val paramHasDefault: List = emptyList(), + /** + * Set only for a `@Composable`: the JVM call [lambdaBody] delegates to, because Kotlin source + * cannot make it. See [ComposableThunks.kt] for the two measurements that close every other + * route. + * + * `null` for everything else, which is every binding that existed before composables did -- + * their [lambdaBody] is a real Kotlin call expression and needs no `.class` beside it. + */ + val thunk: ThunkSpec? = null, + /** Which `t` of its fragment's thunk class [thunk] is, so that the generated `.kt` and the + * generated `.class` cannot disagree about the index. `-1` when [thunk] is `null`. */ + val thunkIndex: Int = -1, +) : Serializable + +/** One artefact's worth of bindings: what becomes a single `FunctionTableFragment` object. */ +internal data class ArtifactFragment( + val objectName: String, + val moduleName: String, + val entries: List, +) + +/** + * A Kotlin string literal for [this]. + * + * The `$` escape is not decoration: a composable's synthetic parameter names are literally + * `$composer`, `$changed` and `$default` (`ComposableShape.syntheticParameterNames`), and an + * unescaped one in generated Kotlin is a *template expression* -- `"$composer"` compiles as a + * reference to a variable named `composer` and fails with "Unresolved reference", which is exactly + * how this was found. + */ +private fun String.quoted(): String = + "\"" + replace("\\", "\\\\").replace("\"", "\\\"").replace("$", "\\$") + "\"" + +/** + * A Kotlin identifier for a resolved artefact's coordinates. + * + * `python.multiplatform.ksp.sanitiseModuleName`'s rule, widened: a `ComponentIdentifier`'s display + * name is `group:name:version` for a module and `project :path` for a project, so spaces and colons + * have to go the way periods and hyphens do. + */ +internal fun artifactFragmentObjectName(coordinates: String): String = + "ArtifactFragment_" + coordinates.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("") + +/** + * `paramHasDefault` is a statement about [ArtifactCallable.lambdaBody], and this is where that is + * enforced rather than assumed. + * + * The column used to be inert -- carried into the table and read by nothing -- so a producer could + * fill it from the *declaration* and be right enough. It is not inert any more: `pythonx._bind` + * fills a slot it marks with `null`, and a body that has no branch for that `null` casts it and + * throws. `ArtifactScanner` sets the column from what it generated; [KlibScanner] still sets it from + * `AbiValueParameter.hasDefaultArg` while emitting a body that passes every argument, which is the + * old, now-wrong pairing. + * + * Rather than trust two producers to agree, the flag is derived here from the one thing that cannot + * lie about the body: whether the body tests for the sentinel at all. A klib entry therefore reaches + * Python as "every argument required", which is what it is. + * + * **A `@Composable` omits by a second mechanism and so has a second piece of evidence.** Its body has + * no sentinel test and never will: the `$default` bitmask is a *declared trailing parameter* of the + * JVM method (`docs/archive/pythonx-adapter-design.md` §5.2), so `pythonx` computes an integer instead of + * writing `None` into a slot, and there is no branch for the body to have. Carrying a [ThunkSpec] is + * that evidence -- it is set by exactly one path, the one that emits the mask parameter -- so the + * rule stays "derived from the body", with two bodies to derive from rather than one. + */ +internal fun omittableSlotsOf(entry: ArtifactCallable): List = + if ("== null" in entry.lambdaBody || entry.thunk != null) entry.paramHasDefault else entry.paramHasDefault.map { false } + +/** + * What separates a declared return type from the types it **is a**, inside the one string the + * boundary already carries for it. + * + * ### Why the answer travels in the type name + * + * The same reason `ArtifactScanner.functionSlotTypeName`'s does, and its KDoc makes the argument in + * full: `ExposedCallable` already carries a declared type name to every target, and a second column + * that only some producers fill would be one more thing that can disagree with the first. `<:` is not + * a character sequence a Kotlin fully-qualified name can contain -- neither `<` nor `:` is legal in + * one -- so nothing legitimate can collide with the grammar, and `pythonx._split_supertypes` is the + * single reader. + * + * It also keeps the change where the two ends of it are. `ExposedCallable` is the runtime's model and + * is written by KSP as well; a walked jar is the only producer that *has* a class hierarchy to read, + * and this is the only shape that lets it say so without every other producer growing a field it + * would leave empty. + */ +internal const val SUPERTYPE_SEPARATOR = "<:" + +/** `returnTypeName` as the fragment writes it: the declared type, then its ancestry. */ +internal fun renderedReturnTypeName(entry: ArtifactCallable): String? = + entry.returnTypeName?.let { (listOf(it) + entry.returnSupertypes).joinToString(SUPERTYPE_SEPARATOR) } + +private fun renderEntry(entry: ArtifactCallable): String { + val paramTags = entry.paramTags.joinToString(", ") { "$TYPE_TAG.$it" } + val paramNames = entry.paramNames.joinToString(", ") { it.quoted() } + val paramTypeNames = entry.paramTypeNames.joinToString(", ") { it.quoted() } + val paramHasDefault = omittableSlotsOf(entry).joinToString(", ") + return """ + |$EXPOSED_CALLABLE( + | name = ${entry.name.quoted()}, + | arity = ${entry.arity}, + | paramTypes = listOf($paramTags), + | returnType = $TYPE_TAG.${entry.returnTag}, + | kind = $CALLABLE_KIND.${entry.kind}, + | paramNames = listOf($paramNames), + | paramTypeNames = listOf($paramTypeNames), + | returnTypeName = ${renderedReturnTypeName(entry)?.quoted() ?: "null"}, + | isExtension = ${entry.receiverTypeName != null && entry.kind == "FUNCTION"}, + | receiverTypeName = ${entry.receiverTypeName?.quoted() ?: "null"}, + | paramHasDefault = listOf($paramHasDefault), + | callable = ${entry.lambdaBody}, + |) + """.trimMargin() +} + +/** + * The stand-in [ArtifactScanner] writes into a composable's body for the thunk class, resolved here + * because only the fragment knows its own name. + * + * A token rather than the real name because the walk that builds the body does not yet know which + * artefact's fragment it will land in -- and passing the name into `scanJar` would put a Gradle + * concern (what a coordinate is called) inside the walker. Substituted in exactly one place, so the + * `.kt` and the `.class` [generateThunkClass] emits cannot name different classes. + */ +internal const val THUNK_CLASS_TOKEN = "%THUNKS%" + +/** Every thunk one fragment needs, in the order [ArtifactCallable.thunkIndex] promises. */ +internal fun thunkSpecsOf(entries: List): List { + val thunked = entries.filter { it.thunk != null }.sortedBy { it.thunkIndex } + require(thunked.mapIndexed { position, entry -> entry.thunkIndex == position }.all { it }) { + "thunk indices must be dense and start at 0: ${thunked.map { it.thunkIndex }}" + } + return thunked.map { it.thunk!! } +} + +/** + * Renders one artefact's fragment. + * + * The same file shape `python-multiplatform-ksp`'s `renderFragmentSource` emits, because the + * runtime must not be able to tell the two producers apart: `UpcallTable`, `CallableHandle` and + * `PythonProxySource` all see a `FunctionTableFragment` and nothing more. + * + * `DEPRECATION` joins `UNCHECKED_CAST` in the file-level suppression for a reason KSP's version does + * not have: the declarations here are somebody else's. JUnit 4's own `junit.framework.Assert` is + * deprecated, and a consumer who asked for a binding should not be handed a warning about source + * they did not write and cannot change. + * + * `OPT_IN_USAGE_ERROR` is there for the same reason one size up. Compose marks a large part of its + * public API with `@ExperimentalFoundationApi` and friends, which are `RequiresOptIn` at `ERROR` + * level, so a fragment binding `Modifier.combinedClickable` would fail to compile in a consumer's + * build unless that consumer happened to have opted in. The *marker* is not knowable here -- it is + * one annotation among any number a library may invent -- so the diagnostic is suppressed rather + * than an `@OptIn` guessed at. What that costs is real and worth stating: a consumer gets no warning + * that a bound declaration is experimental. What it buys is that asking for a package does not fail + * the build over which of its declarations someone else marked. + */ +/** + * How many entries one generated method may build before [renderArtifactFragmentSource] splits. + * + * The JVM caps a method at 64 KB of bytecode, and every entry's four per-slot lists are built inline. + * A composable is the widest case -- its declared parameters plus `$composer`/`$changed`/`$default`, + * each costing roughly 30 bytes across the four lists -- so about 1 KB for the widest Material 3 + * signatures. 40 of those stays well under the cap; the common small entry is a fraction of that. + */ +internal const val FRAGMENT_ENTRIES_PER_METHOD = 40 + +internal fun renderArtifactFragmentSource(fragment: ArtifactFragment): String = buildString { + appendLine("// GENERATED by python-multiplatform-gradle-plugin. Do not edit.") + appendLine( + "@file:Suppress(\"UNCHECKED_CAST\", \"DEPRECATION\", \"DEPRECATION_ERROR\", " + + "\"OPT_IN_USAGE\", \"OPT_IN_USAGE_ERROR\")", + ) + appendLine() + appendLine("package $ARTIFACTS_PACKAGE") + appendLine() + // Deduplicated and sorted: two entries binding the same extension function (possible once an + // ambiguous-overload group is what prevents it, not the walker's enumeration order) must not + // produce two conflicting `as` aliases for the same import. + fragment.entries.flatMap { it.imports }.toSortedSet().forEach { appendLine(it) } + if (fragment.entries.any { it.imports.isNotEmpty() }) appendLine() + appendLine("object ${fragment.objectName} : $FRAGMENT_INTERFACE {") + appendLine(" override val moduleName: String = ${fragment.moduleName.quoted()}") + appendLine() + val thunks = thunkClassQualifiedName(fragment.objectName) + fun StringBuilder.appendList(entries: List) { + entries.forEach { + appendLine(renderEntry(it).replace(THUNK_CLASS_TOKEN, thunks).prependIndent(" ") + ",") + } + appendLine(" )") + } + if (fragment.entries.size <= FRAGMENT_ENTRIES_PER_METHOD) { + appendLine(" override fun entries(): List<$EXPOSED_CALLABLE> = listOf(") + appendList(fragment.entries) + } else { + // Split so no one method passes the JVM's 64 KB limit -- see [FRAGMENT_ENTRIES_PER_METHOD]. + val chunks = fragment.entries.chunked(FRAGMENT_ENTRIES_PER_METHOD) + appendLine(" override fun entries(): List<$EXPOSED_CALLABLE> =") + appendLine(" " + chunks.indices.joinToString(" + ") { "entries$it()" }) + chunks.forEachIndexed { index, chunk -> + appendLine() + appendLine(" private fun entries$index(): List<$EXPOSED_CALLABLE> = listOf(") + appendList(chunk) + } + } + appendLine("}") +} + +/** + * Renders the aggregator over every walked artefact. + * + * A second list beside KSP's `FunctionTable` rather than an extension of it. The reasoning is in + * `ArtifactTableRenderingTest`'s class doc, and the short form is in the generated doc comment + * below so that a reader who only ever sees the output gets it too. + */ +internal fun renderArtifactTableSource(fragmentObjectNames: List): String = buildString { + appendLine("// GENERATED by python-multiplatform-gradle-plugin. Do not edit.") + appendLine("package $ARTIFACTS_PACKAGE") + appendLine() + appendLine("/**") + appendLine(" * Every resolved artefact this module asked to have bound.") + appendLine(" *") + appendLine(" * Not merged into `python.multiplatform.generated.FunctionTable`: that one means \"every") + appendLine(" * module in this build graph that was compiled with the processor\", and a walked") + appendLine(" * third-party jar is not one. Install both:") + appendLine(" *") + appendLine(" * UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments)") + appendLine(" */") + appendLine("object ArtifactTable {") + appendLine(" val fragments: List<$FRAGMENT_INTERFACE> = listOf(") + fragmentObjectNames.forEach { appendLine(" $it,") } + appendLine(" )") + appendLine() + appendLine(" /** Adds these to whatever is already installed, leaving it in place. */") + appendLine(" fun registerInto() {") + appendLine(" fragments.forEach { $UPCALL_TABLE.register(it) }") + appendLine(" }") + appendLine("}") +} 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 new file mode 100644 index 000000000..23dfffb47 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt @@ -0,0 +1,2390 @@ +package python.multiplatform.gradle.artifact + +import kotlin.metadata.ClassKind +import kotlin.metadata.KmClassifier +import kotlin.metadata.KmType +import kotlin.metadata.KmVariance +import kotlin.metadata.Visibility +import kotlin.metadata.isNullable +import kotlin.metadata.isSuspend +import kotlin.metadata.isValue +import kotlin.metadata.isVar +import kotlin.metadata.jvm.KotlinClassMetadata +import kotlin.metadata.jvm.fieldSignature +import kotlin.metadata.jvm.getterSignature +import kotlin.metadata.jvm.setterSignature +import kotlin.metadata.kind +import kotlin.metadata.visibility +import org.objectweb.asm.Opcodes +import org.objectweb.asm.tree.ClassNode +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import java.io.File +import java.util.jar.JarFile + +/** + * `docs/design/ecosystem.md` §5b's second producer: bindings taken out of the artefacts a build resolves, + * rather than out of the source it compiles. + * + * ### Why this exists at all + * + * KSP only sees declarations being compiled, so a third-party binary -- `androidx.compose.material3` + * being the case that motivated it -- can never have a fragment. `PyREPL` already solved the reading + * half of this: `app/build.gradle.kts` makes a resolvable copy of each source set's + * `implementation`/`api` configurations and walks the resulting jars with ASM. This is that walk, + * pointed at a table instead of at `.pyi` stubs. + * + * ### Where this parts company with PyREPL's filters + * + * PyREPL drops private and protected members, ``, `Companion`, and any name containing `-`. + * Judged against what a *binding* needs rather than what a stub needs: + * + * | 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 | + * | 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 | + * | (none) | drop non-`static` | an instance method needs a receiver; see [ArtifactCallable] | + * | (none) | drop unbindable types | see [boundaryTypeOf] and `resolveKotlinType` | + * | (none) | **rename** ambiguous overloads rather than drop them | see [disambiguateOverloads] | + * | (none) | drop `suspend` | its JVM shape takes a `Continuation`, and `resolveKotlinType`/`@Metadata` both know this before `boundaryTypeOf` would have to discover it by rejecting the parameter | + * + * **Why the `-` filter is gone rather than kept.** A hyphen in a JVM method name is Kotlin's + * value-class mangling suffix, and it was dropped on the reasoning that a value class *erases to the + * type it wraps*: `Duration` is a `long`, so `getInWholeSeconds-impl(J)J` would pass [boundaryTypeOf]'s + * type filter while meaning something entirely different from `long -> long`. That reasoning is sound + * only if the walker calls the JVM method by its mangled name -- which it never does. It generates + * *Kotlin source* (`renderArtifactFragmentSource`) and lets `kotlinc` compile it, the same as a + * human would write `Modifier.padding(16.dp)`; mangling is something the compiler does on the way + * out, not a name this walker's own generated code ever spells. Filtering on it discarded the + * declaration a value class exists to describe correctly, for a reason that only applied to a + * different (rejected) design. + * + * What replaced it: `resolveKotlinType` (`KotlinMetadata.kt`), which reads `@Metadata`'s `d1`/`d2` + * payload to learn the *Kotlin* type a parameter or return actually has -- `Meters`, not the `Double` + * it erases to -- and, when that type is a value class, whether its constructor and its accessor are + * public enough to construct and unwrap from generated code outside the module. + * `kotlin.time.Duration` (`getInWholeSeconds-impl`, the case that motivated the old filter) is + * *still* declined under the new rule, but for a fact rather than a heuristic: `Duration`'s + * constructor is `internal`, so no Kotlin source outside `kotlin-stdlib` can ever build one, in + * either direction. See `ValueClassFixtures.kt`'s `Meters` for the same shape with a public + * constructor, and `ArtifactScannerTest` for both proved against real bytecode. + * + * ### What it takes to reach Compose, and what each piece was worth + * + * `docs/design/kotlin-extensions-in-python.md` §3 measured this walker binding **zero** declarations from + * all 19 Compose Multiplatform desktop jars, and identified three separate rules each of which + * produced that zero on its own. All three are now gone, and the measurement that replaced it -- + * `ArtifactScannerTest.composeModifierExtensionsSurviveBothGates`, over the same jars -- is + * **788 declarations, 157 of them `Modifier` extensions** (314/104 when the three rules below were + * the whole story; composables and then function-typed slots account for the rest): + * + * | rule | what it cost | where it went | + * |---|---|---| + * | the metadata-kind gate: only `k=1` (an ordinary class) was dispatched on | **all 1,411** public top-level Compose functions, because a Kotlin top-level function compiles into a file facade (`k=2`) or a multi-file part (`k=5`) | the `when` in [scanClassNode], which now dispatches on `FileFacade` and `MultiFileClassFacade` too | + * | the type gate: no boundary type for an ordinary object | every remaining declaration, `Modifier` being an interface | `resolveKotlinType`'s object-handle case (`KotlinMetadata.kt`) | + * | drop a name carried by more than one binding | 33 of `Modifier`'s 130 names, `padding`/`size`/`background`/`border`/`clickable` among them | [disambiguateOverloads] | + * + * A fourth rule cost most of what was left. Of the 46 public top-level `Modifier` extensions still + * declined, **44 declared a function-typed parameter**, because `resolveKotlinType` looks a + * classifier up as a public class file and Kotlin's built-in `FunctionN` has none. That is + * [functionSlotOrNull]'s subject, and the same test now measures **157 `Modifier` extensions bound + * and 8 declined, 6 of them function-typed** -- `clickable`, `combinedClickable`, `semantics`, + * `drawBehind`, `toggleable` and 45 others where there were none. + * + * What is left is not a metadata problem either, and each remaining case has a reason rather than an + * absence: a `suspend` lambda (`pointerInput`), a `@Composable` lambda inside a declaration that is + * not one (`composed`), a type argument that is a type *parameter* (`swipeable`). See + * [functionSlotOrNull]. + * + * ### Facades, parts, and extension receivers + * + * Compose's public surface is not reached by widening [boundaryTypeOf] alone. Two more ASM-only + * limits stood in the way, and `KotlinMetadata.kt` is what removes them: + * + * 1. **Kotlin top-level functions hide behind a facade.** `kotlin.text.trimIndent` compiles onto + * the package-private `StringsKt__IndentKt`; the public class `StringsKt` (a + * `MULTI_FILE_CLASS_FACADE`) declares no methods of its own, only a list of part class names. + * ASM alone can see that the facade exists and that it is public, and nothing past that -- + * `ArtifactScannerTest.theWholeJUnitJarYieldsExactlyTheseDeclarations`'s Kotlin-jar counterpart + * used to pin exactly this limit. `KotlinClassMetadata.MultiFileClassFacade.partClassNames` + * supplies the part names; each part's own `@Metadata` (`MultiFileClassPart.kmPackage`) supplies + * the real declarations and their true (unmangled) Kotlin names. + * 2. **An extension receiver is invisible in a JVM descriptor.** `fun Modifier.padding(Dp): Modifier` + * compiles to a static method whose *first* JVM parameter is the receiver, indistinguishable by + * descriptor alone from an ordinary parameter of the same erased type. `KmFunction + * .receiverParameterType` is the only place that survives. Kotlin has no fully-qualified call + * syntax for an extension (see [ArtifactCallable.imports]'s KDoc), so a bound extension is called + * `receiver.alias(...)` after an `import owner.name as alias` -- the only Kotlin can address it. + * + * ### What is still declined, on purpose + * + * A value-class *receiver* on a true member of that class (not an extension declared elsewhere) -- + * `Duration.inWholeSeconds` is one -- is declined by [resolveKotlinType] refusing an unreadable + * constructor, but even a value class with a public one would still be declined here: a member + * function's own JVM shape puts the (unboxed) instance in JVM parameter position 0 with **no** + * declared Kotlin parameter to account for it, which the arity check in [scanClassNode] catches as a + * mismatch. Only a class's plain statics (`@JvmStatic`, a Java `static`) and package-level top-level + * declarations are bound; instance dispatch is `docs/design/ecosystem.md` §5b's next step, same as before. + * `suspend` is declined explicitly via `@Metadata` rather than relying on [boundaryTypeOf] rejecting + * its `Continuation` parameter, per AGENTS.md's "제외한 것을 조용히 빠뜨리지 마라". + */ +/** + * One declaration, seen by both consumers of a walk at the moment it is read. + * + * [callable] is `null` exactly when the binder declined it, and [declaration] says why. Keeping them + * together up to the end of the walk is what makes `docs/design/pyi-generation-design.md` §2.2's "the two + * renderers cannot drift" a property of the code rather than a convention. + * + * **Top-level, not nested in [ArtifactScanner], because there are two producers.** [KlibScanner] + * builds these too and hands them to [ArtifactScanner.disambiguateOverloads], so that the `name__` + * spelling has exactly one implementation: both producers' output lands in one + * `python.multiplatform.generated.artifacts.ArtifactTable`, and a consumer that had to learn two + * meanings for `__` depending on which walker found the declaration would be reading a table that is + * only accidentally consistent. It is also what gives a klib declaration a + * [DeclarationModel] and therefore a `.pyi` stub. + */ +internal data class Candidate( + val callable: ArtifactCallable?, + val declaration: DeclarationModel, + /** + * Set only by [ArtifactScanner]'s own Kotlin path, and only until + * [ArtifactScanner.applyDefaultOmission] has consumed it. + * + * Whether a defaulted argument may be left out of a generated call is not decidable where the + * call is built -- see [ArtifactScanner.DefaultOmissionPlan] -- so the body is built twice: once + * passing everything, and again once the whole walk is visible. A candidate that reaches + * [ArtifactScanner.disambiguateOverloads] still carrying one has not been through that pass; + * that constructor drops it, which is correct, because by then the body is settled. + */ + val plan: ArtifactScanner.DefaultOmissionPlan? = null, + val isConstructor: Boolean = false, +) + +internal object ArtifactScanner { + + /** `kotlin.Metadata` is `RUNTIME`-retained, so ASM reads it as an ordinary visible annotation -- + * no metadata library needed for the *kind*, only for the payload. */ + private const val KOTLIN_METADATA_DESCRIPTOR = "Lkotlin/Metadata;" + + /** `k = 1`: an ordinary class, object or interface. Every other kind is a compiler-invented + * carrier -- 2 file facade, 3 synthetic class, 4 multi-file facade, 5 multi-file part -- whose + * JVM name has no Kotlin spelling. Used only by [metadataKinds], which reads just the kind byte + * without decoding the rest of the payload; [scanClassNode] dispatches on the full + * `KotlinClassMetadata` instead. */ + private const val KOTLIN_KIND_CLASS = 1 + + /** What `kotlin.Any` gives every object and an `object` may override; not part of its API. */ + private val ANY_MEMBER_SIGNATURES = setOf( + "toString" to "()Ljava/lang/String;", + "hashCode" to "()I", + "equals" to "(Ljava/lang/Object;)Z", + ) + + /** + * Every binding this jar (or, for a test fixture, this directory of `.class` files -- see + * `ArtifactClasspath`) offers, sorted by name. + * + * @param includePrefixes package or class names, each matched as a *namespace*: `junit.runner` + * covers `junit.runner.Version` but `junit.run` covers nothing. Empty means every class, + * which is what the type filter makes affordable. + * @param classpath every root a value-class parameter or an extension receiver might need to be + * resolved against, [target] included. Defaults to `[target]` alone, which is correct whenever + * a value class used by [target] is also declared in [target] -- true for this walker's own + * test fixtures, false for `androidx.compose.foundation.layout`, whose `Dp` lives in + * `androidx.compose.ui.unit`. [PythonArtifactBindingsTask] passes the whole resolved + * configuration. + */ + fun scanJar(target: File, includePrefixes: List = emptyList(), classpath: List = listOf(target)): List = + scan(target, includePrefixes, classpath).mapNotNull { it.callable }.sortedBy { it.name } + + /** + * The same walk, with the half `docs/design/pyi-generation-design.md` §2.2 asks for kept: one + * [DeclarationModel] per public declaration this walker considered, **including the ones it + * declined**, each carrying the table key the binder gave it or the reason there is none. + * + * Not a second scan. §2.2 rejects "two readers of the same jars that can disagree", so every + * model here is built at the point its binding is built, from the same `ResolvedFunction`; the + * two results of one walk are separated only at the end. `DeclarationModelTest + * .theModelAndTheBindingsAgreeBecauseTheyComeFromTheSameWalk` pins that they cannot drift. + */ + fun scanDeclarations( + target: File, + includePrefixes: List = emptyList(), + classpath: List = listOf(target), + ): List = scan(target, includePrefixes, classpath) + .map { it.declaration } + .sortedWith(compareBy({ it.owner }, { it.simpleName }, { it.bindingName ?: "" })) + + private fun scan(target: File, includePrefixes: List, classpath: List): List { + val artifactClasspath = ArtifactClasspath(classpath) + val entries = mutableListOf() + forEachClassEntry(target) { relativePath, bytes -> + // A nested class's binary name uses `$`, which is not how Kotlin spells the qualified + // name (`Outer.Inner`), and `$1` anonymous classes have no Kotlin name at all. Skipped + // wholesale, as PyREPL does. + if ('$' in relativePath) return@forEachClassEntry + val qualified = binaryNameToQualified(relativePath.removeSuffix(".class")) + if (!matchesInclude(qualified, includePrefixes)) return@forEachClassEntry + val node = readClassNodeOrNull(bytes) ?: return@forEachClassEntry + entries += scanClassNode(node, artifactClasspath) + } + // Both passes are over the whole walk rather than per class, and for the same reason: an + // overload set is a property of a *package*, and Kotlin lets one live in two files. + // `docs/design/kotlin-extensions-in-python.md` §2.5 counts 11 such pairs in Compose alone -- two + // file facades, two `ClassNode`s, one Kotlin name -- which a per-class grouping cannot see. + // It would have emitted them twice under one table key, and it would have generated calls + // that do not compile (see [applyDefaultOmission]). + // + // Defaults first: [disambiguateOverloads] renames entries, and the sibling test needs the + // Kotlin name they still share. + // + // Properties (issue #38) take no part in any of the four passes: a `GETTER`/`SETTER` is not a + // call anybody spells by name, it is served as an attribute of its receiver's proxy, so it + // neither claims a constructor's namespace (`TextStyle.color` must not cost `TextStyle(...)` + // its binding) nor joins an overload set. [settlePropertyNames] gives them the one check + // they do need, against the finished names of everything else. + val (properties, functions) = entries.partition { it.declaration.kind in PROPERTY_KINDS } + val settledFunctions = assignThunkIndices(disambiguateOverloads(applyDefaultOmission(dropCollidingConstructors(functions)))) + return (settledFunctions + settlePropertyNames(properties, settledFunctions)) + // The ancestry the binding carries is the model's too (issue #31): copied at the end, from the + // callable that was actually bound, so the two cannot be read differently. + .map { candidate -> + val callable = candidate.callable ?: return@map candidate + // ... and so is what the binding lets Python leave out. The model's `declaresDefault` is + // Kotlin's own view until [applyDefaultOmission] narrows it, and that pass returns a + // candidate untouched when nothing is omittable (past [MAX_OMITTABLE_PARAMETERS], an + // unwritable name, an ambiguous sibling): the binding then requires every argument + // while the stub would still offer `= ...`. One view, taken from the binding. + val omittable = omittableSlotsOf(callable) + // A property's receiver is not one of its slots (`CallableKind.GETTER.hasReceiver`). + val receiverIndex = if (callable.receiverTypeName != null && callable.kind !in PROPERTY_KINDS) 1 else 0 + candidate.copy( + declaration = candidate.declaration.copy( + returnSupertypes = callable.returnSupertypes, + parameters = candidate.declaration.parameters.mapIndexed { index, parameter -> + parameter.copy(declaresDefault = omittable.getOrElse(index + receiverIndex) { false }) + }, + ), + ) + } + } + + /** + * Two rules, both "a constructor cannot claim a name something else bound needs", and both + * caught here rather than by restricting *which classes* [constructorCandidates] attempts: + * + * 1. **Exact-name collision with a factory function.** A top-level factory function (like + * `BitmapPainter`) is already what Python users call. A constructor sharing that exact bare + * name would force both into `name__` mangling and break existing Python code, so the + * constructor yields. + * 2. **Prefix collision with a constant the class itself publishes.** [objectConstantCandidates] + * binds `TextStyle.Default` under the dotted name `"TextStyle.Default"`, which needs the bare + * name `TextStyle` free to act as the namespace that constant hangs off of. A constructor + * bound to `TextStyle` claims that name as a callable instead, and `TextStyle.Default` becomes + * unreachable. `TextStyle` has this problem and `Typography`/`Shapes` do not, which is the + * measured fact this rule generalises (`ArtifactScannerTest + * .aFocusRequesterConstructorIsDroppedBecauseItWouldShadowFocusRequesterDefault` pins the rule + * itself against a class where it is the one doing the work; `TextStyle` gets closed by the + * other check below instead, see [constructorCandidates]) -- replacing what used to be two + * hard-coded class names in [constructorCandidates] with a structural check that does not grow + * a line per class someone wants opened next. + * + * Both must run BEFORE [applyDefaultOmission] so a dropped constructor's presence doesn't cost + * the surviving bound declaration its omittable defaults. + */ + private fun dropCollidingConstructors(candidates: List): List { + // A property never claims a constructor's name (issue #38): `TextStyle.color` is read off a + // `TextStyle` value, it is not a constant `TextStyle` publishes under its own name. [scan] + // already keeps them out of this pass; this keeps the rule true for any other caller. + val (properties, others) = candidates.partition { it.declaration.kind in PROPERTY_KINDS } + if (properties.isNotEmpty()) return dropCollidingConstructors(others) + properties + val (bound, declined) = candidates.partition { it.callable != null } + val byName = bound.groupBy { it.callable!!.name } + val boundNames = byName.keys + + val dropReasons = mutableMapOf() + for (group in byName.values) { + if (group.any { !it.isConstructor }) { + group.filter { it.isConstructor }.forEach { + dropReasons.putIfAbsent(it, "constructor name collides with a factory function") + } + } + } + for (candidate in bound) { + if (!candidate.isConstructor) continue + val name = candidate.callable!!.name + if (boundNames.any { it != name && it.startsWith("$name.") }) { + dropReasons.putIfAbsent(candidate, "constructor name collides with a constant this class publishes under its own name") + } + } + + val remainingBound = bound.filter { it !in dropReasons } + val newlyDeclined = dropReasons.map { (candidate, reason) -> + candidate.copy( + callable = null, + declaration = candidate.declaration.copy(bindingName = null, declineReason = reason), + ) + } + return remainingBound + declined + newlyDeclined + } + + /** + * The `k` of each named class's `kotlin.Metadata`, or absent from the map when the class carries + * none. Exposed for `ArtifactScannerTest` to pin against a real Kotlin artefact, since it is the + * fact that bounds what [scanClassNode] can dispatch on. + * + * @param binaryNames internal names, e.g. `kotlin/text/StringsKt`. + */ + fun metadataKinds(jar: File, binaryNames: List): Map { + val wanted = binaryNames.toSet() + val kinds = mutableMapOf() + JarFile(jar).use { file -> + wanted.forEach { name -> + val entry = file.getJarEntry("$name.class") ?: return@forEach + val node = file.getInputStream(entry).use { readClassNodeOrNull(it.readBytes()) } ?: return@forEach + kotlinMetadataKind(node)?.let { kinds[name] = it } + } + } + return kinds + } + + private fun forEachClassEntry(root: File, action: (relativePath: String, bytes: ByteArray) -> Unit) { + if (root.isDirectory) { + root.walkTopDown() + .filter { it.isFile && it.name.endsWith(".class") } + .forEach { file -> action(file.relativeTo(root).invariantSeparatorsPath, file.readBytes()) } + } else { + JarFile(root).use { jarFile -> + jarFile.entries().asSequence() + .filter { it.name.endsWith(".class") } + .forEach { entry -> action(entry.name, jarFile.getInputStream(entry).use { it.readBytes() }) } + } + } + } + + private fun kotlinMetadataKind(node: ClassNode): Int? { + val annotation = node.visibleAnnotations?.firstOrNull { it.desc == KOTLIN_METADATA_DESCRIPTOR } ?: return null + val values = annotation.values ?: return KOTLIN_KIND_CLASS + // `values` is a flat name/value list. `k` is absent when it holds its default, which is 1. + for (i in 0 until values.size - 1 step 2) { + if (values[i] == "k") return values[i + 1] as? Int + } + return KOTLIN_KIND_CLASS + } + + private fun binaryNameToQualified(binaryName: String): String = binaryName.replace('/', '.') + + private fun packageNameOf(binaryName: String): String = + binaryNameToQualified(binaryName.substringBeforeLast('/', missingDelimiterValue = "")) + + /** A namespace match, not a text one: `junit.run` is a prefix of the string `junit.runner` and + * of no package or class. */ + private fun matchesInclude(qualifiedName: String, includePrefixes: List): Boolean = + includePrefixes.isEmpty() || includePrefixes.any { + qualifiedName == it || qualifiedName.startsWith("$it.") + } + + private fun scanClassNode(node: ClassNode, classpath: ArtifactClasspath): List { + if (!node.access.hasFlag(Opcodes.ACC_PUBLIC)) return emptyList() + if (node.access.hasFlag(Opcodes.ACC_SYNTHETIC) or node.access.hasFlag(Opcodes.ACC_ANNOTATION)) return emptyList() + + // Ambiguity is counted over what *would be bound*, over the whole group a facade + // contributes -- not per class -- so ambiguity introduced by splitting one Kotlin name + // across two multi-file parts is still caught. See this object's KDoc. + val candidates: List = when (val metadata = kotlinClassMetadataOf(node)) { + // No `kotlin.Metadata` at all: a Java class (or a Kotlin class whose metadata payload + // this library could not decode -- declined the same way unparsable bytecode is, not + // fatal). Bound the same way this walker always has, by JVM descriptor alone. + null -> javaStaticCandidates(node) + is KotlinClassMetadata.Class -> kotlinCandidates( + owner = binaryNameToQualified(node.name), + ownerIsClass = true, + functions = functionsOf(metadata.kmClass).filterNot { it.isExtension }, + ownerNode = node, + classpath = classpath, + // The rule `objectConstantCandidates` uses: Kotlin visibility from metadata, never the + // JVM flags (an `internal object` is a JVM-public class). + bindObjectMembers = metadata.kmClass.visibility == Visibility.PUBLIC, + ) + constructorCandidates(metadata.kmClass, node, classpath) + + objectConstantCandidates(metadata.kmClass, node, classpath) + + memberPropertyCandidates(metadata.kmClass, node, classpath) + is KotlinClassMetadata.FileFacade -> { + val owner = kotlinPackageNameOverrideOf(node) ?: packageNameOf(node.name) + kotlinCandidates( + owner = owner, + ownerIsClass = false, + functions = functionsOf(metadata.kmPackage), + ownerNode = node, + classpath = classpath, + ) + extensionPropertyCandidates(metadata.kmPackage, node, owner, classpath) + } + is KotlinClassMetadata.MultiFileClassFacade -> metadata.partClassNames.flatMap { partBinaryName -> + val partNode = classpath.classNode(partBinaryName) ?: return@flatMap emptyList() + val partMetadata = kotlinClassMetadataOf(partNode) as? KotlinClassMetadata.MultiFileClassPart + ?: return@flatMap emptyList() + val owner = kotlinPackageNameOverrideOf(partNode) ?: packageNameOf(partBinaryName) + kotlinCandidates( + owner = owner, + ownerIsClass = false, + functions = functionsOf(partMetadata.kmPackage), + ownerNode = partNode, + classpath = classpath, + ) + extensionPropertyCandidates(partMetadata.kmPackage, partNode, owner, classpath) + } + // A lone part is package-private and never reaches here on its own (the `ACC_PUBLIC` + // guard above already excluded it) -- only through the facade case, which fetches it by + // name. `SyntheticClass` and `Unknown` carry no Kotlin declarations this walker binds. + else -> emptyList() + } + + return candidates + } + + /** + * Gives every member of an overload set a name of its own, instead of dropping the set. + * + * ### What changed, and what did not + * + * The old rule dropped a Kotlin name outright as soon as more than one binding would carry it. + * Its reasoning was about **arbitration** and is still right: `org.junit.Assert.assertEquals` has + * eight bindable overloads, and letting a sort order pick one means `assertEquals(3, 3)` from + * Python silently calls the deprecated `(double, double)` that always fails. Nothing here picks. + * + * What changed is that there is now a third option between "arbitrate" and "drop". The old rule + * predates `@Metadata`: with only JVM descriptors there was no *Kotlin* parameter type to name an + * overload by, and 108 mangled JVM names are ambiguous within their own class + * (`docs/design/kotlin-extensions-in-python.md` §2.3), so the JVM name could not do it either. Metadata + * supplies the declared Kotlin types, so the overloads can be **told apart** rather than + * arbitrated between. + * + * The price of not doing so was measured: **33 of `Modifier`'s 130 names** + * (`docs/design/kotlin-extensions-in-python.md` §3.1), and the casualty list is the API's centre of + * gravity -- `padding`, `size`, `background`, `border`, `clickable`, `width`, `height`. For + * `padding` specifically, the *only* unmangled overload is the `PaddingValues` one nobody wants, + * so "keep whichever the descriptor-era filter happened to leave" was also the worst answer. + * + * ### The rule + * + * The bare name is bound only for a group of one. A group of more than one gets one name per + * member, `name__`, under the first of three schemes that separates the group: + * + * 1. the simple names of the declared **value parameters** -- `padding__Dp`, `padding__Dp_Dp`, + * `padding__PaddingValues`; + * 2. the **receiver** joined to them, for overloads that differ only in what they extend + * (`Int.times` and `Double.times`, both in `androidx.compose.ui.unit`); + * 3. **fully qualified** names, for the case where two parameter types share a simple name. + * + * A group no scheme separates is dropped, which is the old rule surviving as the floor: two + * declarations this walker genuinely cannot tell apart must not both claim a table key, and + * neither may be picked. + * + * ### Why a name and not a Python-side dispatcher + * + * A dispatcher is the better surface and it cannot be built here. `UpcallTable` is keyed by name + * and `ExposedCallable` carries one fixed `arity` that `UpcallTrampoline.unmarshalArguments` + * enforces exactly, so one name reaches one signature by construction; and + * `PythonProxySource.renderOne` publishes an entry by `setattr`ing its *leaf* name onto a module, + * so two entries sharing a leaf would silently overwrite each other. A dispatcher therefore lives + * in `pythonx` (`docs/archive/pythonx-adapter-design.md` §4.1) and selects among these names -- which is + * why they have to exist and be distinguishable, and why `ExposedCallable` now carries + * `paramNames` and `paramTypeNames` for it to select on. This layer's job is to make the choice + * *possible*, not to make it. + */ + internal fun disambiguateOverloads(candidates: List): List { + val schemes: List<(ArtifactCallable) -> String> = listOf( + { it.overloadSuffix(includeReceiver = false, qualified = false) }, + { it.overloadSuffix(includeReceiver = true, qualified = false) }, + { it.overloadSuffix(includeReceiver = true, qualified = true) }, + ) + // A property is never an overload (issue #38): it has no parameters to tell members apart by, + // and it is not reached by a name the dispatcher selects among. Passed through untouched. + val (properties, others) = candidates.partition { it.declaration.kind in PROPERTY_KINDS } + if (properties.isNotEmpty()) return disambiguateOverloads(others) + properties + // A candidate the binder already declined has no name to be ambiguous about; it passes + // through so that `docs/design/pyi-generation-design.md` §2.2's "what is declined stays visible" + // survives this stage too. + val (bound, declined) = candidates.partition { it.callable != null } + + val disambiguated = bound.groupBy { it.callable!!.name }.values.flatMap { group -> + if (group.size == 1) return@flatMap group + val scheme = schemes.firstOrNull { scheme -> group.mapTo(HashSet()) { scheme(it.callable!!) }.size == group.size } + ?: return@flatMap group.map { candidate -> + candidate.copy( + callable = null, + declaration = candidate.declaration.copy( + bindingName = null, + declineReason = "one of ${group.size} overloads no naming scheme separates", + ), + ) + } + group.map { candidate -> + val name = "${candidate.callable!!.name}__${scheme(candidate.callable)}" + candidate.copy( + callable = candidate.callable.copy(name = name), + declaration = candidate.declaration.copy(bindingName = name), + ) + } + } + return disambiguated + declined + } + + /** + * The `__`-suffix for one member of an overload set. + * + * The receiver is *not* included by default even though it is `paramTypeNames` slot 0: including + * it always would spell every `Modifier` extension `padding__Modifier_Dp`, and the receiver is + * the one parameter a reader already knows from where the name is attached + * (`docs/design/kotlin-extensions-in-python.md` §4.1). It joins only when it is what separates the + * group. + */ + private fun ArtifactCallable.overloadSuffix(includeReceiver: Boolean, qualified: Boolean): String { + val skipReceiver = if (receiverTypeName != null && !includeReceiver) 1 else 0 + // A composable's synthetic slots are the same for every member of its overload set -- + // `Composer_Int_Int_Int` on all of them -- so they separate nothing and would only make the + // Python name unreadable. Dropped, which leaves exactly the declared parameters the two + // overloads actually differ in. + val declaredEnd = paramNames.indexOf(COMPOSER_PARAMETER_NAME).takeIf { it >= 0 } ?: paramTypeNames.size + val parts = paramTypeNames.take(declaredEnd).drop(skipReceiver) + // A zero-parameter member of a group -- there can be at most one, so this only ever has to + // be distinct from the others, not descriptive. + if (parts.isEmpty()) return "0" + return parts.joinToString("_") { name -> + val chosen = if (qualified) name else name.substringAfterLast('.') + chosen.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("") + } + } + + private fun javaStaticCandidates(node: ClassNode): List { + val owner = binaryNameToQualified(node.name) + return node.methods + .filter { it.access.hasFlag(Opcodes.ACC_PUBLIC) && it.access.hasFlag(Opcodes.ACC_STATIC) } + .filter { !it.access.hasFlag(Opcodes.ACC_SYNTHETIC) && !it.access.hasFlag(Opcodes.ACC_BRIDGE) } + .filter { !it.name.startsWith("<") } + // `$` is a Kotlin/Java synthetic (`fn$default`, `access$000`). `-` would be value-class + // mangling, but nothing reaches this branch *with* Kotlin metadata to interpret it by -- + // a name like that here is unreadable, not merely unsupported, so it stays declined. + .filter { '$' !in it.name && '-' !in it.name } + .mapNotNull { candidateFromDescriptor(owner, it.name, it.desc) } + } + + private fun kotlinCandidates( + owner: String, + ownerIsClass: Boolean, + functions: List, + ownerNode: ClassNode, + classpath: ArtifactClasspath, + bindObjectMembers: Boolean = false, + ): List { + val bySignature = functions.associateBy { it.jvmSignature.name to it.jvmSignature.descriptor } + // A Kotlin `object`'s functions are instance methods of its singleton (issue #53: + // `Arrangement.spacedBy`); generated source reaches them as `Owner.name(...)`, so they bind + // like a static. A composable one is declined below -- its thunk is a static call. + val isObject = ownerIsClass && bindObjectMembers && ownerNode.fields.any { it.name == "INSTANCE" && it.access.hasFlag(Opcodes.ACC_STATIC) } + val staticSignatures = ownerNode.methods.filter { it.access.hasFlag(Opcodes.ACC_STATIC) }.map { it.name to it.desc }.toSet() + return ownerNode.methods + .filter { it.access.hasFlag(Opcodes.ACC_PUBLIC) } + .filter { + it.access.hasFlag(Opcodes.ACC_STATIC) || + (isObject && (it.name to it.desc) !in staticSignatures && (it.name to it.desc) !in ANY_MEMBER_SIGNATURES) + } + .filter { !it.access.hasFlag(Opcodes.ACC_BRIDGE) } + .filter { !it.name.startsWith("<") && '$' !in it.name } + .mapNotNull { method -> + if (method.access.hasFlag(Opcodes.ACC_SYNTHETIC)) { + // A synthetic method is never bound. One case still reaches the model, declined: + // a public `inline fun ` compiles to a synthetic stub that only throws + // (the body exists only inlined), and B-1 says a reified declaration is + // *declined*, which `docs/design/pyi-generation-design.md` §2.2 means visibly. + // Every other synthetic (`@Deprecated(level = HIDDEN)`, compiler helpers) stays + // out silently, as before issue #38. + val reified = bySignature[method.name to method.desc]?.takeIf { it.hasReifiedTypeParameter } + ?: return@mapNotNull null + return@mapNotNull declinedCandidate( + owner, + ownerIsClass, + reified, + classpath, + "a reified type parameter: its type argument is read at run time, so kotlin.Any? cannot stand for it", + isComposable(method), + ) + } + val function = bySignature[method.name to method.desc] + // A `suspend` declaration's JVM shape carries a trailing `Continuation`, so its + // descriptor never matches the one metadata records and it would fall out here + // anyway. Looked up by Kotlin name instead so that it is declined *explicitly* + // and reaches the model flagged -- AGENTS.md's "제외한 것을 조용히 빠뜨리지 마라". + ?: return@mapNotNull functions + .firstOrNull { it.isSuspend && it.jvmSignature.name == method.name } + ?.let { suspending -> + declinedCandidate(owner, ownerIsClass, suspending, classpath, "suspend", isComposable(method)) + } + if (function.isSuspend) return@mapNotNull declinedCandidate(owner, ownerIsClass, function, classpath, "suspend", isComposable(method)) + if (!method.access.hasFlag(Opcodes.ACC_STATIC) && isComposable(method)) { + return@mapNotNull declinedCandidate( + owner, ownerIsClass, function, classpath, + "a @Composable member of an object: its generated call is a static invocation", true, + ) + } + // A member whose Kotlin-declared parameter count does not match its JVM parameter + // count has an implicit JVM parameter metadata does not account for -- a value + // class's own instance turned into an unboxed receiver, or a composable's synthetic + // `$composer`/`$changed`. There is no declared parameter to bind it to, so it is + // declined here rather than misread as one fewer parameter than the method actually + // takes. See this object's KDoc. + val (paramDescriptors, returnDescriptor) = splitMethodDescriptor(method.desc) + if (function.allParameterTypes.size != paramDescriptors.size) { + // A composable's extra JVM parameters are not *unaccounted for* -- they are + // `$composer`, the `$changed` masks and the `$default` mask, in that order, and + // `ComposableShape` reads them off the descriptor. Bound as ordinary slots + // Python fills; everything else with an arity mismatch is still an implicit + // receiver nothing can supply, and still declined. + if (isComposable(method)) { + composableCandidate( + owner, ownerIsClass, function, classpath, paramDescriptors, returnDescriptor, method.name, + ownerNode.name, + )?.let { return@mapNotNull it } + } + return@mapNotNull declinedCandidate( + owner, + ownerIsClass, + function, + classpath, + "the JVM signature carries ${paramDescriptors.size - function.allParameterTypes.size} " + + "parameter(s) metadata does not declare", + isComposable(method), + ) + } + candidateFromFunction(owner, ownerIsClass, function, classpath, paramDescriptors, isComposable(method)) + } + } + + /** + * A class's own public constructors, bound the same way a top-level function is: [constructorsOf] + * gives each one a [ResolvedFunction] whose name is the class and whose "return type" is the + * class itself, so the ordinary non-extension call [candidateFromFunction] already writes -- + * `owner.SimpleName(args)` -- is a valid fully-qualified constructor call with no change to that + * function. `owner` here is the **package**, not the class (contrast [kotlinCandidates]'s own + * `owner`, which for this same [ownerNode] is the class): a constructor is not a static member of + * its class, it is the name a caller writes in the class's place, so it publishes the way a + * top-level function does -- see `docs/design/pyi-generation-design.md` §2.2's `DeclarationModel.owner` + * KDoc for why that distinction is what selects the `sys.modules` entry a binding lands on. + * + * `` is filtered out of [kotlinCandidates] by that function's own `ACC_STATIC` check (a + * constructor is never `static`), so nothing here duplicates a declaration that path already + * handles. + * + * ### Why this never looks at [ownerNode]'s own `` bytecode + * + * Every other producer in this file cross-checks a [ResolvedFunction] against the real + * `MethodNode`: the JVM parameter count can exceed the declared one (a composable's synthetic + * slots, a value class's own member with an implicit unboxed receiver), and only the bytecode + * says so. A constructor has no such gap -- `KmConstructor.signature`'s descriptor is compiled + * *from* `valueParameters`, so [ResolvedFunction.allParameterTypes] and the descriptor + * [candidateFromFunction] needs can both come from the one [KmConstructor], with nothing left for + * a `MethodNode` to add. + * + * Looking one up would actively break value classes. `@JvmInline value class Meters(val value: + * Double)` declares its constructor with no visibility modifier -- Kotlin-public, same as any + * other -- but `javap` on the compiled box shows `private fixture.artifactvalueclass.Meters + * (double)`: the compiler makes the *box's* `` private on purpose, to push every external + * caller through `constructor-impl`/`box-impl` instead. Matching against `ACC_PUBLIC` bytecode + * would decline every value class's constructor for a visibility the *language* never gave it -- + * caught by [aValueClasssOwnConstructorRoundTripsThroughItsUnderlyingPrimitive] running red + * first. Generated Kotlin source is unaffected either way: `Meters(3.0)` is source calling a + * constructor, and the compiler lowers it through the private box the same as it would inside the + * declaring module -- nothing here ever spells `` or reads its access flags. + */ + /** + * Public properties of an `object` declaration or a companion, bound as zero-argument getters. + * + * These are the declarations Python reaches as `Alignment.Center` -- a singleton instance behind + * a name, not a function. Nothing bound them before, which is why two downstream files sat empty + * with a note saying no bound declaration produces an object handle. This is that declaration. + * + * **A companion loses `Companion` from its bound name and keeps it in its call.** Kotlin source + * writes `Alignment.Center`, and that is the name a caller expects; the JVM holds the value on + * `Alignment.Companion`, and that is what the generated body has to say. The two differ, so both + * are computed rather than one derived from the other. + * + * A property whose *return type's* class is not public is declined even when the property is, + * because the generated body names that type's owner and could not be compiled outside its + * module. The property's own visibility is checked as well, and so is the owner's. + */ + private fun objectConstantCandidates( + kmClass: kotlin.metadata.KmClass, + ownerNode: ClassNode, + classpath: ArtifactClasspath, + ): List { + if (kmClass.visibility != Visibility.PUBLIC) return emptyList() + val qualified = binaryNameToQualified(ownerNode.name) + + // An `object` declaration holds its values on itself. + val ownProperties = if (ownerNode.fields.any { it.name == "INSTANCE" && it.access.hasFlag(Opcodes.ACC_STATIC) }) { + constantsOf(kmClass, ownerNode, boundOwner = qualified, callOwner = qualified, classpath) + } else { + emptyList() + } + + // A companion's values are reached through the outer class, and the walk never meets the + // companion itself: `scan` skips every binary name containing `$`, so `Outer$Companion` is + // never read as an entry. It is loaded from here instead, by the name the outer class's own + // metadata gives it -- which is also why the bound name can drop `Companion` while the + // generated call keeps it. + val companionProperties = kmClass.companionObject?.let { companionName -> + val companionNode = classpath.classNode(ownerNode.name + "\$" + companionName) ?: return@let null + val companionMetadata = kotlinClassMetadataOf(companionNode) as? KotlinClassMetadata.Class ?: return@let null + if (companionMetadata.kmClass.visibility != Visibility.PUBLIC) return@let null + constantsOf( + companionMetadata.kmClass, + companionNode, + boundOwner = qualified, + callOwner = "$qualified.$companionName", + classpath, + ) + }.orEmpty() + + return ownProperties + companionProperties + } + + /** + * Public, receiverless properties of a singleton holder, as zero-argument getters. + * + * These are the declarations Python reaches as `Alignment.Center` -- a value behind a name, not + * a function. Nothing bound them before, which is why two files downstream sat empty under a + * note saying no bound declaration produces an object handle. This is that declaration. + * + * [boundOwner] and [callOwner] differ for a companion and are therefore both passed: a caller + * writes `Alignment.Center`, and the generated body has to say `Alignment.Companion.Center`. + * + * A property whose *return type* is not public is declined even when the property is, because + * the body names that type and could not compile outside its module. + */ + private fun constantsOf( + kmClass: kotlin.metadata.KmClass, + declaringNode: ClassNode, + boundOwner: String, + callOwner: String, + classpath: ArtifactClasspath, + ): List = kmClass.properties.mapNotNull { property -> + if (property.visibility != Visibility.PUBLIC) return@mapNotNull null + if (property.receiverParameterType != null) return@mapNotNull null + // A `@Composable get()` reads the composition, so its value does not exist outside one -- + // `MaterialTheme.colorScheme` is the shape. The generated body is an ordinary lambda, and + // Kotlin rejects the call there: "@Composable invocations can only happen from the context + // of a @Composable function". Found by generating them and watching the fixture fail to + // compile, which is also why the check reads the getter's own annotations rather than the + // property's -- metadata does not carry them for a release build. + val getterName = "get" + property.name.replaceFirstChar { it.uppercase() } + if (declaringNode.methods.any { it.name == getterName && isComposable(it) }) return@mapNotNull null + val returnModel = kotlinTypeModelOf(property.returnType, classpath) ?: return@mapNotNull null + val returnType = resolveKotlinType(property.returnType, classpath, BoundaryDirection.RETURN) + ?: return@mapNotNull null + val classifier = property.returnType.classifier as? KmClassifier.Class + val returnMetadata = classifier?.name?.let { classpath.classNode(it) } + ?.let { kotlinClassMetadataOf(it) } as? KotlinClassMetadata.Class + if (returnMetadata != null && returnMetadata.kmClass.visibility != Visibility.PUBLIC) return@mapNotNull null + + val name = "$boundOwner.${property.name}" + val call = "$callOwner.${property.name}" + Candidate( + callable = ArtifactCallable( + name = name, + arity = 0, + paramTags = emptyList(), + returnTag = returnType.tag, + lambdaBody = "{ ${returnType.wrapReturn(call)} }", + returnTypeName = kotlinClassifierNameOf(property.returnType), + returnSupertypes = returnSupertypesOf(property.returnType, returnType.tag, classpath), + paramNames = emptyList(), + paramTypeNames = emptyList(), + paramHasDefault = emptyList(), + kind = "STATIC_GETTER", + ), + declaration = DeclarationModel( + simpleName = property.name, + owner = boundOwner, + ownerIsClass = true, + receiver = null, + parameters = emptyList(), + returnType = returnModel, + isComposable = false, + isSuspend = false, + kind = "STATIC_GETTER", + bindingName = name, + returnBoundaryTag = returnType.tag, + ), + ) + } + + // -------------------------------------------------------------------------- properties (#38) + + /** + * A public class's or interface's own **member** properties, as `GETTER` entries -- and, for a + * `var` whose setter is public, `SETTER` entries -- under `Owner.prop` and `Owner.prop=`. + * + * ### Why these are entries of their own kind + * + * A property is read off a value, not called: `state.value`, `style.fontSize`. The walker used to + * bind statics only, so the one way to reach `MutableState.value` from Python was a hand-written + * top-level function per property (the compose fixture's `writeRoot`), which is the per-declaration + * wrapper `docs/archive/pythonx-adapter-design.md` §7 rules out. `CallableKind.GETTER`/`SETTER` already + * say exactly this shape -- `args[0]` is the receiver, resolved from the proxy's handle, and is not + * counted in `arity` -- so nothing about the boundary is new; only a producer was missing. + * + * The binding layer serves them as Python properties of the receiver's proxy + * (`PythonxAdapter._attach`); `receiverTypeName` is the type they are read on, which for a member + * property is the owner itself. + * + * ### What is bound + * + * - the class is Kotlin-public and is a `class`, `interface` or `enum class`. An `object`'s + * properties are already `STATIC_GETTER`s ([objectConstantCandidates]), and an annotation has + * no instances to read; + * - the class **crosses as an object handle**. A value class the boundary opens (`Dp`, `Meters`) + * reaches Python as its raw primitive, so no proxy of it can ever exist to read a property on; + * - every supertype of the class is on the walk's (= the consumer's compile) classpath + * ([unreachableSupertypeOf]); otherwise each property is declined with the missing name; + * - the property is public, declares no extension receiver of its own (a member extension needs + * two receivers), is not a `@Composable get()` ([constantsOf] gives the measured reason), and + * its accessor is a real, non-synthetic method (a `@Deprecated(level = HIDDEN)` one is + * synthetic, and source cannot name it); + * - its type resolves at the boundary, a class type parameter being read as `kotlin.Any?` + * ([substituteAnyFor]); a bounded one declines the property. + * + * A **setter** additionally needs a public setter and **every** class type parameter unbounded, + * because it writes through `Owner`, which is only within bounds then; a getter + * reads through `Owner<*, ...>`, which always is. + */ + private fun memberPropertyCandidates( + kmClass: kotlin.metadata.KmClass, + ownerNode: ClassNode, + classpath: ArtifactClasspath, + ): List { + if (kmClass.visibility != Visibility.PUBLIC) return emptyList() + if (kmClass.kind !in PROPERTY_OWNER_KINDS) return emptyList() + if (kmClass.properties.isEmpty()) return emptyList() + val owner = binaryNameToQualified(ownerNode.name) + val ownerType = KmType().apply { classifier = KmClassifier.Class(ownerNode.name) } + val receiverBoundary = resolveKotlinBoundary(ownerType, classpath, BoundaryDirection.PARAMETER) ?: return emptyList() + if (receiverBoundary.boundary.tag != OBJECT_TAG) return emptyList() + val receiverModel = kotlinTypeModelOf(ownerType, classpath) ?: return emptyList() + + val typeParameterCount = kmClass.typeParameters.size + // Generic: the receiver is cast with its type arguments written out, which the boundary's + // own read expression (a bare class name) cannot do. A generic value class would need both, + // and nothing measured is one. + if (typeParameterCount > 0 && kmClass.isValue) return emptyList() + val unbounded = substitutableTypeParameterIds(kmClass.typeParameters) + val readReceiver = if (typeParameterCount == 0) receiverBoundary.boundary.read("args[0]") + else "(args[0] as $owner${List(typeParameterCount) { "*" }.joinToString(", ", "<", ">")})" + val writeReceiver = when { + typeParameterCount == 0 -> receiverBoundary.boundary.read("args[0]") + unbounded == null -> null + else -> "(args[0] as $owner${List(typeParameterCount) { "kotlin.Any?" }.joinToString(", ", "<", ">")})" + } + // Each class type parameter that may be read as `Any?`; a bounded one stays a type parameter, + // and a property mentioning it is declined by [substituteAnyFor] answering `null`. + val readableIds = kmClass.typeParameters.filter { parameter -> + substitutableTypeParameterIds(listOf(parameter)) != null + }.mapTo(HashSet()) { it.id } + // Every property is read through `(args[0] as Owner)`, which needs all of `Owner`'s + // supertypes on the consumer's compile classpath ([unreachableSupertypeOf]). + val receiverDecline = classpath.unreachableSupertypeOf(ownerNode.name) + ?.let { missing -> unreachableReceiverReason(missing, owner) } + + return kmClass.properties.flatMap { property -> + if (property.visibility != Visibility.PUBLIC) return@flatMap emptyList() + if (property.receiverParameterType != null) return@flatMap emptyList() + propertyCandidates( + receiverDecline = receiverDecline, + property = property, + ownerNode = ownerNode, + classpath = classpath, + key = "$owner.${property.name}", + modelOwner = owner, + ownerIsClass = true, + receiverTypeName = owner, + receiverModel = receiverModel, + typeIds = readableIds, + readAccess = { "$readReceiver.${writeParameterName(property.name)}" }, + writeAccess = writeReceiver?.let { receiver -> { "$receiver.${writeParameterName(property.name)}" } }, + imports = emptyList(), + ) + } + } + + /** + * A file facade's (or multi-file part's) **extension property getters** -- the scope addition to + * issue #38. `Icons.Default.Add` is the case: material-icons-core declares every icon as + * `val Icons.Filled.Add: ImageVector`, compiled to `AddKt.getAdd(Icons$Filled)`, and the walker + * read a facade's `functions` only, so nothing at all had `Icons.Filled` as its receiver. + * + * Bound as `GETTER` entries under the package-level Kotlin name (`...icons.filled.Add`), with the + * receiver's type as `receiverTypeName` -- which is all the binding layer needs to serve it as an + * attribute of the receiver's proxy, the same way it serves a member property. The read is + * `receiver.alias` after `import pkg.name as alias`, for the reason [candidateFromFunction] gives + * for an extension function: Kotlin has no fully-qualified spelling for one. + * + * Getters only, as decided: an extension `var` is still reached through nothing. Declined with a + * reason: a receiver that does not cross as an object handle (`val Int.dp` -- no proxy of an `Int` + * exists to read it on), a generic extension property (property syntax has no place to write a + * type argument), and a `@Composable get()`. + */ + private fun extensionPropertyCandidates( + kmPackage: kotlin.metadata.KmPackage, + ownerNode: ClassNode, + owner: String, + classpath: ArtifactClasspath, + ): List = kmPackage.properties.flatMap { property -> + if (property.visibility != Visibility.PUBLIC) return@flatMap emptyList() + val receiver = property.receiverParameterType ?: return@flatMap emptyList() + val key = "$owner.${property.name}" + val receiverModel = kotlinTypeModelOf(receiver, classpath) ?: return@flatMap emptyList() + val propertyModel = kotlinTypeModelOf(property.returnType, classpath) + fun declined(reason: String): List { + val returns = propertyModel ?: return emptyList() + return listOf( + Candidate( + callable = null, + declaration = DeclarationModel( + simpleName = property.name, + owner = owner, + ownerIsClass = false, + receiver = receiverModel, + parameters = emptyList(), + returnType = returns, + kind = GETTER_KIND, + declineReason = reason, + ), + ), + ) + } + if (property.typeParameters.isNotEmpty()) { + return@flatMap declined("a generic extension property: property syntax has no place to write its type argument") + } + val receiverBoundary = resolveKotlinBoundary(receiver, classpath, BoundaryDirection.PARAMETER) + ?: return@flatMap declined("no boundary type for receiver ${kotlinClassifierNameOf(receiver) ?: receiver.classifier}") + if (receiverBoundary.boundary.tag != OBJECT_TAG) { + return@flatMap declined("its receiver crosses as ${receiverBoundary.boundary.tag}, so no proxy of it exists to read the property on") + } + // The same rule a member property's owner gets: `receiver.alias` resolves against the + // receiver's member scope too, which needs every supertype of it. + val receiverName = (receiver.classifier as? KmClassifier.Class)?.name + if (receiverName != null) { + val missing = classpath.unreachableSupertypeOf(receiverName.replace('.', '$')) + if (missing != null) { + return@flatMap declined(unreachableReceiverReason(missing, receiverName.replace('/', '.'))) + } + } + val alias = "artifact_ext_" + key.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("") + propertyCandidates( + property = property, + ownerNode = ownerNode, + classpath = classpath, + key = key, + modelOwner = owner, + ownerIsClass = false, + receiverTypeName = kotlinClassifierNameOf(receiver) ?: return@flatMap emptyList(), + receiverModel = receiverModel, + typeIds = emptySet(), + readAccess = { "${receiverBoundary.boundary.read("args[0]")}.$alias" }, + writeAccess = null, + imports = listOf("import $owner.${property.name} as $alias"), + ) + } + + /** + * The `GETTER` (and, when [writeAccess] is given and the setter qualifies, the `SETTER`) for one + * property, or the declined model saying why there is none. + * + * @param key the getter's table key; the setter's is `key=`, the convention + * `PythonProxySource.setterFor` already reads. + * @param typeIds the type parameters [property]'s type may mention, each read as `kotlin.Any?`. + * @param readAccess the Kotlin expression that reads the property off `args[0]`. + * @param writeAccess the assignable expression a setter writes, or `null` for none. + * @param receiverDecline set when nothing may be read on the receiver at all + * ([unreachableSupertypeOf]); the property is then declined with it, model still built. + */ + private fun propertyCandidates( + property: kotlin.metadata.KmProperty, + ownerNode: ClassNode, + classpath: ArtifactClasspath, + key: String, + modelOwner: String, + ownerIsClass: Boolean, + receiverTypeName: String, + receiverModel: KotlinTypeModel, + typeIds: Set, + readAccess: () -> String, + writeAccess: (() -> String)?, + imports: List, + receiverDecline: String? = null, + ): List { + if (!isWritableParameterName(property.name)) return emptyList() + val type = property.returnType.substituteAnyFor(typeIds) ?: return emptyList() + val typeModel = kotlinTypeModelOf(type, classpath) ?: return emptyList() + val getterModel = DeclarationModel( + simpleName = property.name, + owner = modelOwner, + ownerIsClass = ownerIsClass, + receiver = receiverModel, + receiverBoundaryTag = OBJECT_TAG, + parameters = emptyList(), + returnType = typeModel, + kind = GETTER_KIND, + ) + fun declined(reason: String) = listOf(Candidate(null, getterModel.copy(declineReason = reason))) + if (receiverDecline != null) return declined(receiverDecline) + + // The accessor has to be a method source can name. Looked up by the signature `@Metadata` + // records for it -- read for its flags, never called by it (AGENTS.md §12.4: the generated + // body is Kotlin source, and `kotlinc` resolves the property access itself). + val getter = property.getterSignature?.let { signature -> + ownerNode.methods.firstOrNull { it.name == signature.name && it.desc == signature.descriptor } + } + val field = property.fieldSignature?.let { signature -> + ownerNode.fields.firstOrNull { it.name == signature.name && it.desc == signature.descriptor } + } + when { + getter != null -> { + if (!getter.access.hasFlag(Opcodes.ACC_PUBLIC) || getter.access.hasFlag(Opcodes.ACC_SYNTHETIC)) { + return declined("its getter is not a public, non-synthetic method (hidden or deprecated)") + } + if (isComposable(getter)) { + return declined("a @Composable getter: its value exists only inside a composition") + } + } + // `@JvmField`: no accessor, a public field that source reads as the property. + field != null && field.access.hasFlag(Opcodes.ACC_PUBLIC) && !field.access.hasFlag(Opcodes.ACC_SYNTHETIC) -> Unit + else -> return declined("no public accessor or field carries it") + } + + val returns = resolveKotlinType(type, classpath, BoundaryDirection.RETURN) + ?: return declined("no boundary type for property type ${kotlinClassifierNameOf(type) ?: type.classifier}") + val typeName = kotlinClassifierNameOf(type) ?: return declined("unnameable property type") + val getterCandidate = Candidate( + callable = ArtifactCallable( + name = key, + arity = 0, + paramTags = emptyList(), + returnTag = returns.tag, + lambdaBody = "{ args -> ${returns.wrapReturn(readAccess())} }", + imports = imports, + receiverTypeName = receiverTypeName, + paramNames = emptyList(), + paramTypeNames = emptyList(), + returnTypeName = typeName, + returnSupertypes = returnSupertypesOf(type, returns.tag, classpath), + paramHasDefault = emptyList(), + kind = GETTER_KIND, + ), + declaration = getterModel.copy(bindingName = key, returnBoundaryTag = returns.tag), + ) + + val setterCandidate = writeAccess?.let { write -> + if (!property.isVar) return@let null + if (property.setter?.visibility != Visibility.PUBLIC) return@let null + val setterMethod = property.setterSignature?.let { signature -> + ownerNode.methods.firstOrNull { it.name == signature.name && it.desc == signature.descriptor } + } + val writable = when { + setterMethod != null -> setterMethod.access.hasFlag(Opcodes.ACC_PUBLIC) && !setterMethod.access.hasFlag(Opcodes.ACC_SYNTHETIC) + field != null -> getter == null && !field.access.hasFlag(Opcodes.ACC_FINAL) + else -> false + } + if (!writable) return@let null + val value = resolveKotlinType(type, classpath, BoundaryDirection.PARAMETER) ?: return@let null + // Metadata records a setter's parameter only when the source wrote one; a default + // setter's is the compiler's ``, which no keyword can spell. + val parameterName = property.setterParameter?.name?.takeIf { isWritableParameterName(it) } ?: "value" + val setterKey = "$key=" + Candidate( + callable = ArtifactCallable( + name = setterKey, + arity = 1, + paramTags = listOf(value.tag), + returnTag = "UNIT", + // An assignment is not an expression; the lambda answers `Unit` after it. + lambdaBody = "{ args -> ${write()} = ${value.read("args[1]")}; Unit }", + imports = imports, + receiverTypeName = receiverTypeName, + paramNames = listOf(parameterName), + paramTypeNames = listOf(typeName), + returnTypeName = UNIT_TYPE_NAME, + paramHasDefault = listOf(false), + kind = SETTER_KIND, + ), + declaration = getterModel.copy( + kind = SETTER_KIND, + bindingName = setterKey, + parameters = listOf(DeclaredParameter(parameterName, typeModel, declaresDefault = false, boundaryTag = value.tag)), + returnType = KotlinTypeModel(UNIT_TYPE_NAME), + returnBoundaryTag = "UNIT", + ), + ) + } + return listOfNotNull(getterCandidate, setterCandidate) + } + + /** + * The one name check a property needs, run after every other name is final. + * + * A property's key is not chosen by [disambiguateOverloads] and must not collide with a key that + * was: `UpcallTable` refuses a duplicate name outright, and the binding layer would then hold two + * rows under one `_TABLE` key. So a property whose key another binding (or another property -- + * two extension properties of one name, on different receivers, in one package) already carries + * is declined, by name and with the reason, rather than shadowing or being shadowed. A setter + * whose getter was declined goes with it: a write-only property is not a Kotlin shape. + */ + private fun settlePropertyNames(properties: List, settled: List): List { + val taken = settled.mapNotNullTo(HashSet()) { it.callable?.name } + val counts = properties.mapNotNull { it.callable?.name }.groupingBy { it }.eachCount() + val firstPass = properties.map { candidate -> + val name = candidate.callable?.name ?: return@map candidate + if (name in taken || counts.getValue(name) > 1) { + candidate.copy( + callable = null, + declaration = candidate.declaration.copy( + bindingName = null, + declineReason = "property key $name collides with another binding", + ), + ) + } else { + candidate + } + } + val boundGetters = firstPass.mapNotNullTo(HashSet()) { it.callable?.takeIf { c -> c.kind == GETTER_KIND }?.name } + return firstPass.map { candidate -> + val callable = candidate.callable ?: return@map candidate + if (callable.kind == SETTER_KIND && callable.name.removeSuffix("=") !in boundGetters) { + candidate.copy( + callable = null, + declaration = candidate.declaration.copy(bindingName = null, declineReason = "its getter is not bound"), + ) + } else { + candidate + } + } + } + + /** + * Why a property is not read on a receiver whose ancestry the consumer's compile classpath does not + * fully carry. Declined rather than bound, because the alternative is not a failing entry but a + * generated file `kotlinc` rejects as a whole -- every other binding of the artefact with it. + */ + private fun unreachableReceiverReason(missing: String, receiver: String): String = + "its receiver $receiver has a supertype, $missing, that is not on the compile classpath, " + + "so generated Kotlin cannot read a member of it" + + internal const val GETTER_KIND = "GETTER" + internal const val SETTER_KIND = "SETTER" + + /** The two kinds [scan] keeps out of the function passes; see [settlePropertyNames]. */ + internal val PROPERTY_KINDS = setOf(GETTER_KIND, SETTER_KIND) + + private val PROPERTY_OWNER_KINDS = setOf(ClassKind.CLASS, ClassKind.INTERFACE, ClassKind.ENUM_CLASS) + + private fun constructorCandidates( + kmClass: kotlin.metadata.KmClass, + ownerNode: ClassNode, + classpath: ArtifactClasspath, + ): List { + // An abstract class cannot be instantiated -- `Cannot create an instance of an abstract + // class` would appear in the generated fragment. An internal class's name cannot be spelled + // in source outside its module -- the same rule `resolvedFunctionOrNull` applies to + // individual functions via their visibility. Both are checked on the *class*, not the + // constructor: `constructorsOf` / `resolvedConstructorOrNull` already checks the + // constructor's own visibility separately. + if (ownerNode.access.hasFlag(Opcodes.ACC_ABSTRACT)) return emptyList() + if (kmClass.visibility != Visibility.PUBLIC) return emptyList() + // Every public class's constructor is attempted, value class or not -- what a constructor + // could overshadow (a factory function already claiming the bare name, or a constant this + // same class publishes under a dotted continuation of it, `TextStyle.Default`) is a fact + // about the *whole walk*, not about this class in isolation, so it is caught once, after the + // walk, by [dropCollidingConstructors] -- not by guessing which classes are safe here. This + // used to special-case two class names (`Typography`, `Shapes`) for the same reason `Meters` + // needs none: neither publishes a same-named constant, and [dropCollidingConstructors] now + // says so structurally instead of by name. + // A generic one cannot be called without naming its type argument, and Python has no way to + // supply one: `SessionMutex()` does not compile, it needs `SessionMutex()`. Surfaced by + // widening the walked packages, which brought the first generic value class into range -- + // the same unspellable-type-parameter limit the modifier wrappers already work around by + // fixing a concrete type. + if (kmClass.typeParameters.isNotEmpty()) return emptyList() + + // `@Deprecated(level = DeprecationLevel.HIDDEN)` on a constructor keeps its *metadata* entry + // (for binary compatibility, ABI consumers still need to see it existed) while the compiler + // makes the constructor it actually describes JVM-`private` and publishes only a synthetic + // `$default`-masked bridge (`DefaultConstructorMarker` trailing) as the public one -- with a + // *different* descriptor metadata never records. Found widening constructor binding past + // value classes to `androidx.compose.ui.input.pointer.PointerInputChange`, whose old + // `consumed: ConsumedData` constructor is exactly this shape: `e: None of the following + // candidates is applicable` against a generated call naming a constructor Kotlin source can + // no longer resolve. Issue #73 narrowed this: a constructor with a value-class parameter has + // the same synthetic-bridge shape *without* being hidden (`TextFieldState`), and only the + // `kotlin.Deprecated(level = HIDDEN)` on the bridge tells the two apart -- see + // [realPublicInitDescriptorsOf]. + // + // Checked for an **ordinary class only**. A value class's own box constructor is the other + // case this object's KDoc already warns bytecode cannot judge -- "the compiler makes the + // *box's* `` private on purpose, to push every external caller through + // `constructor-impl`/`box-impl` instead" -- so the same bytecode check that catches a + // hidden-deprecated ordinary constructor would wrongly decline every public value class + // constructor, `Meters` included. `kmClass.isValue` is exactly the fact that tells these two + // "the real `` is not the one this metadata entry means" cases apart. + val owner = packageNameOf(ownerNode.name) + return constructorsOf(kmClass, ownerNode.name) + .filter { kmClass.isValue || it.jvmSignature.descriptor in realPublicInitDescriptorsOf(ownerNode) } + .mapNotNull { function -> + val (paramDescriptors, _) = splitMethodDescriptor(function.jvmSignature.descriptor) + candidateFromFunction(owner, false, function, classpath, paramDescriptors, isComposable = false) + ?.let { it.copy(isConstructor = true, declaration = it.declaration.copy(isConstructor = true)) } + } + } + + /** + * The JVM descriptors of the ``s Kotlin source outside the module can reach. + * + * ### Two synthetic shapes, and only one of them is hidden (issue #73) + * + * A constructor with a **value-class parameter** compiles to a JVM-`private` `` taking the + * unboxed values, behind a public `ACC_SYNTHETIC` bridge whose last parameter is a + * `DefaultConstructorMarker` -- and that bridge is the descriptor `@Metadata` records. `javap -v` on + * `androidx/compose/foundation/text/input/TextFieldState` (foundation-desktop 1.11.1): the metadata + * constructor `(initialText: String, initialSelection: TextRange)` is + * `(Ljava/lang/String;JLkotlin/jvm/internal/DefaultConstructorMarker;)V`, `ACC_PUBLIC, ACC_SYNTHETIC`, + * no annotation. Rejecting every synthetic `` therefore dropped `TextFieldState`'s only public + * constructor -- silently, so its stub class came out with no way to create one. + * + * A `@Deprecated(level = HIDDEN)` constructor with a value-class parameter has **the same** shape, + * and is told apart only by the annotation the compiler leaves on the bridge: + * `PointerInputChange`'s `(…, ConsumedData, …, DefaultConstructorMarker)` and four of `TextStyle`'s + * bridges carry `RuntimeVisibleAnnotations: kotlin.Deprecated(level = DeprecationLevel.HIDDEN)`, + * while their non-hidden siblings carry none. Source cannot call a hidden one at all ("Unresolved + * reference"), so it stays out -- without a model, like every other hidden declaration + * ([kotlinCandidates]). A plain synthetic `` without the marker (an ordinary hidden + * constructor) stays out as before. + */ + private fun realPublicInitDescriptorsOf(ownerNode: ClassNode): Set = + ownerNode.methods + .filter { it.name == "" && it.access.hasFlag(Opcodes.ACC_PUBLIC) } + .filter { method -> + !method.access.hasFlag(Opcodes.ACC_SYNTHETIC) || + (method.desc.endsWith(DEFAULT_CONSTRUCTOR_MARKER_TAIL) && !isHiddenDeprecated(method)) + } + .mapTo(mutableSetOf()) { it.desc } + + /** How a value-class constructor's public bridge ends; see [realPublicInitDescriptorsOf]. */ + private const val DEFAULT_CONSTRUCTOR_MARKER_TAIL = "Lkotlin/jvm/internal/DefaultConstructorMarker;)V" + + /** `kotlin.Deprecated` is `RUNTIME`-retained, so a `level = HIDDEN` on a bridge is in the visible list. */ + private fun isHiddenDeprecated(method: org.objectweb.asm.tree.MethodNode): Boolean = + method.visibleAnnotations.orEmpty().any { annotation -> + if (annotation.desc != "Lkotlin/Deprecated;") return@any false + val values = annotation.values ?: return@any false + (0 until values.size - 1 step 2).any { i -> + values[i] == "level" && + (values[i + 1] as? Array<*>)?.let { it.getOrNull(0) == "Lkotlin/DeprecationLevel;" && it.getOrNull(1) == "HIDDEN" } == true + } + } + + /** + * Whether this JVM method is a `@Composable`. + * + * ### It is an *invisible* annotation, and reading the visible list found none, ever + * + * This used to read `MethodNode.visibleAnnotations` on the stated grounds that + * "`@Composable` is `RUNTIME`-retained, so ASM reads it as an ordinary visible annotation". That + * is wrong, and `javap -v androidx/compose/material3/TextKt.class` says so directly: every + * composable in the jar carries + * + * RuntimeInvisibleAnnotations: + * androidx.compose.runtime.Composable + * + * because `androidx.compose.runtime.Composable` is declared + * `@Retention(AnnotationRetention.BINARY)`. `RuntimeInvisible*` is exactly what ASM puts in + * `invisibleAnnotations`, so the old predicate answered `false` for **every real composable**, + * and `DeclarationModel.isComposable` -- which drives `docs/archive/pyi-generation-pythonic-stubs.md` §3.6's + * PascalCase rule -- was dead. Nothing caught it because the only composables reaching it were + * being declined anyway, and a declined entry's flag is not asserted anywhere. + * + * Both lists are read rather than just the invisible one: retention is the annotation author's + * choice and a future Compose could change it, whereas a method carrying the descriptor in + * either list means the same thing. + * + * ### Why not `@Metadata` + * + * `KmFunction` has an annotations list, but `kotlin-metadata-jvm` only populates it when the + * compiler was asked to keep annotations in metadata, which Compose's release build was not. + * The class file is the fact here; metadata is how the *parameters* are then interpreted. + * + * Two consumers: the name rule above, and [composableCandidate], which is what stops the arity + * check from declining every composable. + */ + private fun isComposable(method: org.objectweb.asm.tree.MethodNode): Boolean = + sequenceOf(method.visibleAnnotations, method.invisibleAnnotations) + .filterNotNull() + .any { list -> list.any { it.desc == ComposableShape.COMPOSABLE_ANNOTATION_DESCRIPTOR } } + + /** + * A `@Composable`, bound as an ordinary function whose `$composer`, `$changed` and `$default` + * slots are Python's to fill. + * + * ### Why the synthetic parameters are exposed rather than hidden + * + * `docs/archive/pythonx-adapter-design.md` §4.5 rejected every way of *hiding* them, and each rejection + * still stands: an arity-prefix entry cannot express "pass `text`, skip `modifier`", presence + * branching costs 2^15 call expressions for `Text`, `Text$default` does not exist, and a + * generated wrapper cannot restate defaults metadata never carries. What none of those noticed is + * that the mask is a **declared trailing parameter** (§5.2) -- so it does not have to be a + * compile-time constant at all. Compute it at run time and every one of those problems is + * somebody else's: `$default` bit *i* set means "parameter *i* was not passed, substitute its + * default", which is a fact about one integer rather than about 2^15 branches. + * + * Where that integer is computed matters and is not here. `pythonx` calls `androidx`, never the + * other way round, so the mask is Python's arithmetic over which arguments the caller wrote; + * this layer's job is to leave a slot for it. `$composer` is the one value Python cannot invent + * -- it exists only inside a composition -- and comes from the single hand-written entry point. + * + * ### An extension composable, and the numbering that used to be guessed at + * + * This used to decline one outright, on the stated grounds that "Compose's `$changed` slots count + * receivers and its `$default` bits are assigned over value parameters, so a receiver shifts one + * numbering and not the other. Nothing here has measured which." It is measured now, out of the + * callee, the way `docs/archive/pythonx-adapter-design.md` §5.2 measured the mask in the first place -- + * `javap -c androidx/compose/material3/NavigationBarKt`, whose `NavigationBarItem` is + * `RowScope.NavigationBarItem(selected, onClick, icon, modifier = …, …)`: + * + * iload $default; ldc -2147483648; iand; ifeq … ; aload_0 -- the RowScope + * iload $default; iconst_1; iand; ifeq … ; iload_1 -- `selected` + * … + * iload $default; bipush 8; iand; ifeq +11 + * getstatic androidx/compose/ui/Modifier.Companion + * astore 4 ; `modifier`, JVM local 4 + * + * Both halves of the guess are answered, and they answer differently: + * + * | | does the receiver shift it | + * |---|---| + * | `$changed` | **yes** -- the receiver is slot 0, so value parameter *i* occupies bits `3(i+1)`…`3(i+1)+2` | + * | `$default` | **no** -- bit *i* is value parameter *i*; `modifier` is the 4th value parameter and is guarded by `& 8` | + * + * The receiver is not left out of `$default` altogether: it is given **bit 31**, the top bit of + * the first word, which is what `& -2147483648` above is. It is never set by a caller -- a + * receiver cannot be omitted -- so it costs this binding nothing, and the same encoding appears + * on `SegmentedButton` (two `$changed` words, `MultiChoiceSegmentedButtonRowScope` receiver). + * + * Only the `$default` answer is load-bearing here, because `pythonx` passes `$changed` as `0` + * unconditionally (it is a claim about staticness that this caller cannot make); so the whole of + * what an extension composable needed was for the mask arithmetic to count value parameters + * rather than slots. `ComposableShape` was already right: `changedCountFor` is computed from + * `allParameterTypes.size`, which **includes** the receiver, which is what the `$changed` + * numbering does. + * + * ### What is still declined here, and why + * + * - **A shape [ComposableShape.of] does not recognise**, which then falls back to the decline + * this method was reached from. A future lowering that adds a fourth kind of synthetic + * parameter degrades to "not bound" rather than to a call with the wrong arguments. + * - **A `char` parameter**, for `boundaryTypeOf`'s reason: Python has no character type. + * - **A composable with more than 31 value parameters *and* a receiver**, which would need the + * receiver's bit 31 and a 32nd value parameter's bit 31 at once. `ComposableShape` predicts two + * `$default` words there and Compose would emit one, so the shape check declines it rather than + * writing a mask into a slot that does not exist. Nothing measured declares one -- the widest + * composable in three jars takes 17. + */ + private fun composableCandidate( + owner: String, + ownerIsClass: Boolean, + function: ResolvedFunction, + classpath: ArtifactClasspath, + paramDescriptors: List, + returnDescriptor: String, + jvmMethodName: String, + ownerInternalName: String, + ): Candidate? { + // A composable's bounded type parameter is left exactly as it was before issue #38: its + // declared slots are typed from the erased descriptor where metadata names no class, and its + // call is bytecode, which needs no type argument (`composableDeclaredSlot`). Only an + // *unbounded* one is substituted, and that changes a slot's declared name, never its shape. + val declaredCount = function.allParameterTypes.size + val shape = ComposableShape.of(declaredCount, paramDescriptors) ?: return null + if (shape.totalCount != paramDescriptors.size) return null + + val model = declarationModelOf(owner, ownerIsClass, function, classpath, isComposable = true) ?: return null + // The declared slots are typed from `@Metadata`, the synthetic ones from the descriptor -- + // see [composableDeclaredSlot] for why the pairing is safe and what refuses it. + val declaredSlots = (0 until declaredCount).map { index -> + composableDeclaredSlot(function.allParameterTypes[index], paramDescriptors[index], classpath) ?: return null + } + val syntheticTags = paramDescriptors.drop(declaredCount).map { composableSlotTagOf(it) ?: return null } + val paramTags = declaredSlots.map { it.tag } + syntheticTags + val returnTag = if (returnDescriptor == "V") "UNIT" else composableSlotTagOf(returnDescriptor) ?: return null + val declaredTypeNames = function.allParameterTypes.mapIndexed { index, type -> + val declared = kotlinClassifierNameOf(type) ?: return null + functionSlotTypeName(type, declared, paramDescriptors[index]) ?: declared + } + + val syntheticNames = shape.syntheticParameterNames() + val paramNames = (0 until declaredCount).map { function.allParameterNames.getOrNull(it) ?: "p$it" } + syntheticNames + val paramTypeNames = declaredTypeNames + syntheticNames.map { + if (it == COMPOSER_PARAMETER_NAME) COMPOSER_TYPE_NAME else "kotlin.Int" + } + val qualifiedName = "$owner.${function.kotlinName}" + + return Candidate( + callable = ArtifactCallable( + name = qualifiedName, + arity = paramDescriptors.size, + paramTags = paramTags, + returnTag = returnTag, + // Filled by [assignThunkIndices], which is the only place that knows which `t` + // this is -- and therefore the only place the `.kt` and the `.class` can agree. + lambdaBody = "", + // Slot 0 when there is one, exactly as for a plain extension: that is what puts the + // declaration on `_BY_RECEIVER` and makes it a method on the scope's proxy, which is + // the only spelling that can reach it -- a Python caller has no other way to produce + // a `RowScope`. + receiverTypeName = function.receiverType?.let { kotlinClassifierNameOf(it) }, + paramNames = paramNames, + paramTypeNames = paramTypeNames, + returnTypeName = kotlinClassifierNameOf(function.returnType), + returnSupertypes = returnSupertypesOf(function.returnType, returnTag, classpath), + // The declaration's own answer, unlike every other producer here -- and it is the + // honest one, because the mask reaches *every* default with no cap and no branch to + // decline. A synthetic slot is never omittable: `pythonx` always computes all three. + paramHasDefault = (0 until declaredCount).map { function.allParameterDefaults.getOrElse(it) { false } } + + List(syntheticNames.size) { false }, + thunk = ThunkSpec( + ownerInternalName, + jvmMethodName, + "(${paramDescriptors.joinToString("")})$returnDescriptor", + valueClassUnboxOwners = declaredSlots.map { it.unboxOwner } + List(syntheticTags.size) { null }, + ), + ), + // `paramTags` is slot-aligned, so an extension's receiver is slot 0 and `model.parameters` + // (receiver excluded) starts at slot 1. Reading it unshifted gave `NavigationBarItem`'s + // `colors` the tag of `alwaysShowLabel`: `bool` in the stub (issue #53). + declaration = model.copy( + bindingName = qualifiedName, + returnBoundaryTag = returnTag, + receiverBoundaryTag = if (function.isExtension) paramTags.firstOrNull() else null, + parameters = model.parameters.mapIndexed { index, parameter -> + parameter.copy(boundaryTag = paramTags.getOrNull(index + (if (function.isExtension) 1 else 0))) + }, + ), + ) + } + + /** The name of the slot the composer arrives in. Not a Kotlin identifier on purpose -- it is the + * compiler's own spelling, it is what `pythonx` matches on to recognise a composable, and no + * keyword argument can collide with it. */ + internal const val COMPOSER_PARAMETER_NAME = "\$composer" + private const val COMPOSER_TYPE_NAME = "androidx.compose.runtime.Composer" + + /** + * The name a **function-typed** parameter of a composable is reported under, or `null` when this + * is not one and the declared name should stand. + * + * ### Why the declared name alone is not enough + * + * `androidx.compose.foundation.layout.Column` declares + * `content: @Composable ColumnScope.() -> Unit`, which `@Metadata` records as `kotlin.Function1` + * -- an extension function type over `ColumnScope`. Its JVM descriptor is + * `Lkotlin/jvm/functions/Function3;`, because the Compose plugin appends a `Composer` and one + * `$changed` to a composable function *type* exactly as it appends them to a composable + * function. `androidx.compose.material3.Button` declares `onClick: () -> Unit`, which is + * `kotlin.Function0` in **both**. + * + * `pythonx` has to hand Kotlin an object implementing the right interface, so it needs the + * compiled arity; and it has to know whether to thread a composer through the invocation, so it + * needs to know which of the two cases this is. Both are answered by comparing the two arities + * the walker already has, and neither is guessed: + * + * | | declared | compiled | reported | + * |---|---|---|---| + * | `Button.onClick` | `Function0` | `Function0` | `kotlin.Function0()->kotlin.Unit` | + * | `Column.content` | `Function1` | `Function3` | `kotlin.Function3@Composable(…ColumnScope)->kotlin.Unit` | + * | `Slider.onValueChange` | `Function1` | `Function1` | `kotlin.Function1(kotlin.Float)->kotlin.Unit` | + * + * ### Why the compiled arity is not the whole answer + * + * It was, for as long as the only thing a Python callable could be was a `content` that takes + * nothing. Two arities say *which interface* to implement and *whether a composer is threaded*, + * and say nothing at all about **what to do with the arguments that interface is invoked with**. + * `Column.content`'s `Function3` and `Slider.onValueChange`'s `Function1` both have arguments to + * forward, and forwarding one needs two facts the arity cannot carry: + * + * - **whether it is a value or a Kotlin object.** A `Float` reaches Python as a number and a + * `ColumnScope` as a proxy over a handle; the erased `invoke(Object)` makes them the same + * thing at the call site, and only the declared type argument tells them apart. This is why + * the receiver of a scoped content could not be forwarded before -- not a different problem + * from `(Float) -> Unit`, the *same* one. + * - **what Kotlin type the proxy is over**, which is what `_BY_RECEIVER` is keyed on and + * therefore the whole of whether `ColumnScope`'s extensions are reachable from the value. + * + * The **return** type is here for the opposite reason: so that a slot whose lambda has to give + * something back keeps being refused. `Function1` is the spelling of both `(Float) -> Unit` and + * `(Float) -> Boolean`, and the second cannot be bound -- `TypeTag` has one `INT` for `Byte` + * through `Long` and one `FLOAT` for both floating widths, so nothing here can say which boxed + * type the caller will cast the answer to. 19 of the 560 function-typed slots three Compose jars + * declare return something other than `Unit` (`ComposableBindingTest.functionTypedSlotsCarry…`); + * without the return type in the name they would be indistinguishable from the 541 that do, and + * would fail as a `ClassCastException` inside Compose rather than as a refusal at the call. + * + * ### Why the answer travels in the type name + * + * The same reason `$composer` travels in the *parameter* name: `ExposedCallable` already carries + * a per-slot declared type name to every target, and a column that only composables use would be + * a second thing that can disagree with the first. Neither `@` nor `(` nor `>` is a character a + * Kotlin fully-qualified name can contain, so nothing legitimate can collide with the grammar, + * and `pythonx._function_slot` is the single reader. + * + * Anything the walker cannot fully describe returns `null`, so the slot keeps its bare declared + * name and `pythonx` declines it as an ordinary object handle rather than casting a wrapper to an + * interface it does not implement. That covers an arity relationship neither rule predicts, and + * also a type argument with no classifier -- `SwipeableKt.rememberSwipeableState`'s + * `(T) -> Boolean` is the one such slot in the three jars, and a type *parameter* has no name a + * proxy could be built over. + */ + internal fun functionSlotTypeName(type: kotlin.metadata.KmType, declaredName: String, descriptor: String): String? { + if (!declaredName.startsWith(KOTLIN_FUNCTION_PREFIX)) return null + val declaredArity = declaredName.removePrefix(KOTLIN_FUNCTION_PREFIX).toIntOrNull() ?: return null + val jvmArity = jvmFunctionArityOf(descriptor) ?: return null + val composable = when (jvmArity) { + declaredArity -> false + declaredArity + COMPOSABLE_LOWERED_SLOTS -> true + else -> return null + } + // `Function1` -- the declared arity plus one, the last being the return. + // Read off the type rather than the name because the name is only the classifier. + val arguments = type.arguments + if (arguments.size != declaredArity + 1) return null + val names = arguments.map { kotlinClassifierNameOf(it.type ?: return null) ?: return null } + val mark = if (composable) COMPOSABLE_TYPE_MARK else "" + return KOTLIN_FUNCTION_PREFIX + jvmArity + mark + + "(" + names.dropLast(1).joinToString(",") + ")" + FUNCTION_RETURNS + names.last() + } + + /** Separates a function slot's forwarded argument types from what it has to give back. Not a + * character a Kotlin classifier name can contain, so the grammar cannot be ambiguous. */ + internal const val FUNCTION_RETURNS: String = "->" + + /** The only return type a Python callable can currently stand in for; see [functionSlotTypeName]. */ + internal const val UNIT_TYPE_NAME: String = "kotlin.Unit" + + /** How many parameters the Compose plugin appends to a composable function *type*: the + * `Composer` and one `$changed`. Unlike a composable *function*, whose `$changed` count grows + * with its parameters, a lowered lambda carries exactly one -- checked against every composable + * three Compose jars declare by `ComposableBindingTest.everyFunctionTypedSlotOfAComposableIsEitherPlainOrLoweredByTwo`. */ + private const val COMPOSABLE_LOWERED_SLOTS = 2 + + private const val KOTLIN_FUNCTION_PREFIX = "kotlin.Function" + + /** Must match `pythonx`'s `_COMPOSABLE_MARK`. */ + internal const val COMPOSABLE_TYPE_MARK = "@Composable" + + /** The arity of `Lkotlin/jvm/functions/FunctionN;`, or `null` for anything else. */ + internal fun jvmFunctionArityOf(descriptor: String): Int? { + if (!descriptor.startsWith(JVM_FUNCTION_PREFIX) || !descriptor.endsWith(";")) return null + return descriptor.substring(JVM_FUNCTION_PREFIX.length, descriptor.length - 1).toIntOrNull() + } + + private const val JVM_FUNCTION_PREFIX = "Lkotlin/jvm/functions/Function" + + /** + * What one function-typed parameter of a **non**-`@Composable` declaration becomes: the slot name + * `pythonx` reads, the boundary type generated Kotlin casts through, or the reason there is + * neither. + * + * `null` from [functionSlotOrNull] means "not a function type at all", which is the ordinary + * path; an instance with a [declineReason] means "a function type this walker will not bind". + */ + private class FunctionSlot( + val typeName: String? = null, + val boundary: BoundaryType? = null, + val declineReason: String? = null, + ) + + /** + * The gap `a179b747` left, and where it was. + * + * That commit made a function-typed slot describable and fillable, and both halves are producer- + * independent: [functionSlotTypeName] writes the signature and `pythonx._make_function` builds a + * `FunctionN` from it. What is **not** shared is how a slot's type reaches a call: + * + * | producer | the call site | what the type has to be | + * |---|---|---| + * | `@Composable` | [generateThunkClass]'s bytecode | erased -- a `CHECKCAST` to `kotlin/jvm/functions/FunctionN` needs no Kotlin name | + * | everything else | generated Kotlin source | **spellable** -- `args[i] as (Float) -> Unit` | + * + * So the composable path never asked `resolveKotlinType` about a function type and this one did, + * and got the only answer that function can give: `objectBoundaryTypeOrNull` requires the + * classifier to exist as a public class file, and Kotlin's built-in `FunctionN` has none anywhere + * -- they live in `.kotlin_builtins`. 44 of the 46 declined public top-level `Modifier` + * extensions declined for exactly that (`ArtifactScannerTest`), `clickable` among them. + * + * `objectBoundaryTypeOrNull` is deliberately **not** widened. Its rule is right for what it can + * see: a classifier with no class file is one generated source must not name, and it has no + * access to the JVM descriptor that says a `kotlin.Function1` slot is compiled as `Function1` + * rather than as a lowered `Function3`. The exception is opted into here, by the one caller that + * holds both facts. + * + * ### What stays declined + * + * - **`suspend`.** `Modifier.pointerInput(block: suspend PointerInputScope.() -> Unit)` records + * its type as `Function2, Any?>`, which [functionSlotTypeName] + * describes perfectly well -- and Kotlin will not let generated source assign a `Function2` to a + * `suspend` function type, nor can a Python callable answer `COROUTINE_SUSPENDED`. It is a + * *different* limit from the one this method opens, which is why it is named rather than left + * to fail as a compile error in somebody's generated fragment. + * - **`@Composable`, inside a declaration that is not.** `Modifier.composed(factory:)` is the + * case. The fragment is compiled without the Compose plugin (`:ksp-fixtures:artifact`'s + * `build.gradle.kts` says so, and it is the whole shape of that fixture's claim), so + * `@Composable Modifier.() -> Modifier` written there is an ordinary `Function1` while the slot + * is a lowered `Function3`. **This one does not fail as a compile error**, which is why it is + * declined by name rather than left to the compiler: removing this guard and asking for + * `androidx.compose.ui.ComposedModifierKt` produces a fragment that compiles, casting + * `args[2] as kotlin.Function1` beside a slot reported as + * `kotlin.Function3@Composable(…)->…` -- so `pythonx` would build a `Function3` from the name and + * hand it to a `Function1` call site, and the disagreement would surface inside Compose. The + * thunk path has no such problem, which is why a composable *declaration* binds these and a + * non-composable one cannot. + * - **anything [renderKotlinFunctionType] cannot write**, which is a type argument that is a type + * *parameter* or a star projection (`Modifier.swipeable`'s `thresholds`), a use-site variance + * this walker will not reproduce, or a classifier that is not a nameable public class. + * + * A slot returning something other than `kotlin.Unit` is **not** declined here, and that is the + * same judgement `a179b747` made for composables rather than a new one: the return type is in the + * name precisely so that `pythonx._coerce` can refuse to fill it *with a reason* -- `TypeTag` + * carries one `INT` for `Byte` through `Long`, so neither side can say which boxed type the callee + * will cast the erased answer to. Binding the declaration around it costs nothing and buys a + * caller of `Modifier.onKeyEvent` a message about the slot instead of an `AttributeError` about + * the whole name. + */ + private fun functionSlotOrNull(type: KmType, descriptor: String, classpath: ArtifactClasspath): FunctionSlot? { + val declared = kotlinClassifierNameOf(type) ?: return null + if (!declared.startsWith(KOTLIN_FUNCTION_PREFIX)) return null + if (type.isSuspend) { + return FunctionSlot( + declineReason = "a suspend function-typed parameter ($declared): its compiled shape takes a " + + "Continuation and answers COROUTINE_SUSPENDED, which no Python callable can do", + ) + } + val name = functionSlotTypeName(type, declared, descriptor) + ?: return FunctionSlot( + declineReason = "a function-typed parameter this walk cannot describe ($declared): a type " + + "argument has no name a proxy could be built over", + ) + if (name.startsWith(KOTLIN_FUNCTION_PREFIX + jvmFunctionArityOf(descriptor) + COMPOSABLE_TYPE_MARK)) { + return FunctionSlot( + declineReason = "a @Composable function-typed parameter of a declaration that is not itself " + + "@Composable: generated Kotlin is compiled without the Compose plugin, so it cannot " + + "produce the lowered function type the callee takes", + ) + } + val rendered = renderKotlinFunctionType(type, classpath) + ?: return FunctionSlot( + declineReason = "a function-typed parameter generated Kotlin cannot spell ($declared): some " + + "part of its signature is not a nameable public type", + ) + // The same OBJECT boundary an ordinary handle crosses on -- what Python puts in the slot is + // the handle `pythonx._make_function` got back from `PythonCallables.newFunction`, and the + // trampoline has already resolved it to the wrapper by the time this cast runs. The cast is + // unchecked (the wrapper is a `Function1` whatever the slot said), which the + // fragment's file-level `@Suppress("UNCHECKED_CAST")` already covers. + return FunctionSlot(typeName = name, boundary = BoundaryType("OBJECT", "(%s as $rendered)", "(%s)")) + } + + /** The Kotlin-source spelling of a function type: `kotlin.Function1`. + * Written as the `FunctionN` classifier rather than as `(Float) -> Unit` because the two are the + * same type and the first needs no bracket rules; assignability to an extension function type + * (`Modifier.() -> Unit`) holds either way, since Kotlin distinguishes those only at declaration + * sites. */ + private fun renderKotlinFunctionType(type: KmType, classpath: ArtifactClasspath): String? = + renderKotlinSourceType(type, classpath, depth = 0) + + /** + * A type this walker is willing to **write into generated Kotlin**, or `null`. + * + * Deliberately stricter than `renderKotlinTypeName`, which serves an `as` cast on an ordinary + * object handle and therefore only has to be a name. This one lands inside a generic argument + * list, where three things it can ignore become wrong answers: + * + * - **nullability of an argument.** `Function1` is contravariant in its argument, so + * `Function1` is not assignable to `Function1` + * -- `Modifier.onFocusedBoundsChanged` is the case, and a cast rebuilt from the slot *name* + * (which drops `?`, being a key) would not compile. + * - **use-site variance.** A projection this walker reproduced by dropping it would silently be a + * different type; declined instead, since nothing measured needs one. + * - **a Kotlin built-in.** `kotlin.Float` and `kotlin.Unit` have no class file and are perfectly + * writable, which is the one direction `isNameablePublicClass` is too strict in -- + * [kotlinPrimitiveBoundaryTypeOf] is the list of the ones this boundary already knows, plus + * `kotlin.Any`, which is what an unconstrained argument becomes. + */ + private fun renderKotlinSourceType(type: KmType, classpath: ArtifactClasspath, depth: Int): String? { + if (depth > 4) return null + val classifier = type.classifier as? KmClassifier.Class ?: return null + val internal = classifier.name + val writable = internal == KOTLIN_ANY_INTERNAL_NAME || + // The function type itself, which is the whole point and is the one classifier + // `isNameablePublicClass` is guaranteed to reject: `kotlin.Function1` is mapped onto a + // JVM interface and has no class file of its own under that name anywhere. + internal.startsWith(KOTLIN_FUNCTION_INTERNAL_PREFIX) || + kotlinPrimitiveBoundaryTypeOf(internal) != null || + classpath.isNameablePublicClass(internal) + if (!writable) return null + val suffix = if (type.isNullable) "?" else "" + val base = internal.replace('/', '.') + if (type.arguments.isEmpty()) return base + suffix + val arguments = type.arguments.map { projection -> + if (projection.variance != KmVariance.INVARIANT) return null + val argument = projection.type ?: return null + renderKotlinSourceType(argument, classpath, depth + 1) ?: return null + } + return "$base<${arguments.joinToString(", ")}>$suffix" + } + + private const val KOTLIN_ANY_INTERNAL_NAME = "kotlin/Any" + + /** [KOTLIN_FUNCTION_PREFIX] as `KmClassifier.Class` spells it. */ + private const val KOTLIN_FUNCTION_INTERNAL_PREFIX = "kotlin/Function" + + /** + * One **declared** slot of a composable: how it marshals, and what the thunk has to do to the + * object the boundary sends before the erased JVM signature will take it. + * + * @param unboxOwner the `@JvmInline value class` box the boundary carries, which the thunk + * unwraps with `unbox-impl`; `null` when the slot needs no unwrapping. + * @param upgraded whether the answer differs from what the JVM descriptor alone would have said. + * Carried for the measurement in `ComposableValueClassSlotTest` and for nothing else: a slot + * that was never upgraded proves the fix reached nothing. + */ + internal class ComposableSlot(val tag: String, val unboxOwner: String?, val upgraded: Boolean) + + /** + * **The wall `ebe3365f` pinned**: the same walker typed `Color` two ways, and a `@Composable`'s + * `Color` slot refused the very handle an ordinary declaration accepted. + * + * ### The two views, and why there were two + * + * An ordinary declaration is typed by [resolveKotlinType] from `@Metadata`, which knows `Color` + * is a `@JvmInline value class` over a `ULong` whose own constructor is `internal` -- so it can + * neither be built from a raw number nor unwrapped, and crosses as an object handle. A + * composable's parameters were typed by [composableSlotTagOf] from the **JVM descriptor**, where + * the same `Color` is the letter `J`. `INT` and `OBJECT` for one type, in one jar. + * + * The descriptor is not gratuitous. A composable's `$composer`, `$changed` and `$default` slots + * are added by the Compose plugin during IR lowering and are **not declared anywhere in + * metadata**, so the JVM parameter list is genuinely longer than the Kotlin one and only the + * descriptor can say what the trailing slots are. What did not follow -- and was the defect -- is + * that the *declared* slots had to be read from it too. + * + * ### How the two lists are paired + * + * Positionally, over the first [ComposableShape.declaredCount] JVM parameters, and that is a + * *checked* relation rather than an assumption: [ComposableShape.of] has already established + * that JVM parameter `declaredCount` is the `Composer` and that every parameter after it is an + * `int`, and [composableCandidate] additionally requires `shape.totalCount` to equal the JVM + * parameter count. Two arities, related in exactly one admissible way -- the same argument + * [functionSlotTypeName] makes for a function-typed slot, where a declared `Function1` may be a + * compiled `Function1` or a compiled `Function3` and nothing else. + * + * ### And what stops a wrong pairing from being silent + * + * The arity relation says the lists are the same length; it cannot say that slot *i* of one is + * slot *i* of the other. So every pair is **cross-checked** here, and there are only three ways + * for a pair to be admitted: + * + * | the two views | what it means | what happens | + * |---|---|---| + * | the same tag | a primitive against its own descriptor, an object against a reference, a `Dp` (`FLOAT`) against an `F` | bound, unchanged | + * | metadata says `OBJECT`, and the carrier's own `unbox-impl` returns **exactly** this descriptor | a value class the walker cannot open, erased into the slot | bound as a handle, and the thunk unwraps it | + * | metadata resolves to nothing at all | a nullable primitive, a built-in with no class file, a function type | the descriptor's answer stands, which is what shipped before | + * + * Anything else -- a `Modifier` against a `J`, a `Color` against a `Ljava/lang/String;` -- returns + * `null` and **declines the whole declaration**. It cannot be bound to "whichever view was + * consulted last", because that is precisely the failure this method exists to remove. + * + * `ComposableValueClassSlotTest.everyComposableSlotAgreesBetweenMetadataAndDescriptorWhenPairedInOrder` + * measures that no composable in three Compose jars is declined by that rule, and its sibling + * shifts the pairing by one slot to show the rule is not vacuous. + */ + internal fun composableDeclaredSlot( + type: KmType, + descriptor: String, + classpath: ArtifactClasspath, + ): ComposableSlot? { + val descriptorTag = composableSlotTagOf(descriptor) + val resolved = resolveKotlinBoundary(type, classpath, BoundaryDirection.PARAMETER) + // Nothing metadata can say about this type, so there is nothing to disagree with: a + // nullable primitive, a Kotlin built-in with no class file, `kotlin.FunctionN`. + ?: return descriptorTag?.let { ComposableSlot(it, unboxOwner = null, upgraded = false) } + val metadataTag = resolved.boundary.tag + if (metadataTag == descriptorTag) return ComposableSlot(metadataTag, unboxOwner = null, upgraded = false) + + val carrier = resolved.carrier + if (metadataTag == OBJECT_TAG && carrier != null && + classpath.valueClassUnboxDescriptor(carrier) == descriptor + ) { + return ComposableSlot(OBJECT_TAG, unboxOwner = carrier, upgraded = true) + } + // Metadata says an object and the slot is erased, but nothing on this classpath can turn the + // one into the other -- the box class is not resolvable. Declining would take away a binding + // that exists today, so the descriptor's answer stands and `pythonx` keeps refusing the call + // with the message it already gives. Counted by the sibling test rather than hidden. + if (metadataTag == OBJECT_TAG && descriptorTag != null && !descriptor.startsWith("L") && !descriptor.startsWith("[")) { + return ComposableSlot(descriptorTag, unboxOwner = null, upgraded = false) + } + return null + } + + private const val OBJECT_TAG = "OBJECT" + + /** + * The ancestry of one declaration's return type, or nothing at all. + * + * Asked **only** when the return crosses as a handle, which is the only case in which the answer + * can ever be read: `pythonx._coerce` consults it for an owned `OBJECT` value on its way into + * another slot, and a `Dp` result -- `FLOAT`, a raw number -- has no identity to carry an + * ancestry on. That single condition is also what keeps the cost down; see + * [nameablePublicSupertypesOf] and `ReturnSupertypeTest` for what it comes to. + */ + private fun returnSupertypesOf(returnType: KmType, returnTag: String, classpath: ArtifactClasspath): List { + if (returnTag != OBJECT_TAG) return emptyList() + val classifier = returnType.classifier as? KmClassifier.Class ?: return emptyList() + return classpath.nameablePublicSupertypesOf(classifier.name) + } + + /** A boundary tag for one JVM slot of a composable, which is the **compiled** shape rather than + * the declared one: the thunk calls the erased signature, so a `Color` parameter is the `long` + * it erases to. `null` declines the whole declaration. Still the answer for the **synthetic** + * slots, which metadata does not declare at all; see [composableDeclaredSlot] for the declared + * ones. */ + private fun composableSlotTagOf(descriptor: String): String? = when { + descriptor == "C" || descriptor == "V" -> null + else -> boundaryTypeOf(descriptor)?.tag + ?: if (descriptor.startsWith("L") || descriptor.startsWith("[")) "OBJECT" else null + } + + /** + * Numbers the thunks of one walk and writes each composable's body around its number. + * + * Deliberately the last pass and deliberately over the whole walk: [disambiguateOverloads] can + * turn a bound candidate into a declined one, and a thunk emitted for a declaration nothing calls + * would be a method in the generated class with no caller -- harmless, but it would also make + * [thunkSpecsOf]'s density check a lie. + */ + private fun assignThunkIndices(candidates: List): List { + var next = 0 + return candidates.map { candidate -> + val callable = candidate.callable ?: return@map candidate + if (callable.thunk == null) return@map candidate + val index = next++ + candidate.copy( + callable = callable.copy( + thunkIndex = index, + lambdaBody = "{ args -> $THUNK_CLASS_TOKEN.${thunkMethodName(index)}(args) }", + ), + ) + } + } + + /** The model for a declaration that will not be bound, with the reason. The Kotlin types are + * still read: a stub generator has to be able to say *what* was declined. */ + private fun declinedCandidate( + owner: String, + ownerIsClass: Boolean, + function: ResolvedFunction, + classpath: ArtifactClasspath, + reason: String, + isComposable: Boolean, + ): Candidate? { + val declaration = declarationModelOf(owner, ownerIsClass, function, classpath, isComposable) ?: return null + return Candidate(callable = null, declaration = declaration.copy(bindingName = null, declineReason = reason)) + } + + /** + * The declared shape of one Kotlin function, independent of whether the boundary can carry it. + * + * `null` when some part of the signature has no name a stub could write -- a type *parameter*, a + * flexible type. `docs/archive/pyi-generation-pythonic-stubs.md` §3.1's last row and §7: `BindingPolicy` rejects + * generic declarations, and stubbing what cannot be called would be a lie. + */ + private fun declarationModelOf( + owner: String, + ownerIsClass: Boolean, + function: ResolvedFunction, + classpath: ArtifactClasspath, + isComposable: Boolean, + ): DeclarationModel? { + val receiverIndex = if (function.isExtension) 1 else 0 + val receiverModel = function.receiverType?.let { kotlinTypeModelOf(it, classpath) ?: return null } + val parameters = function.allParameterTypes.drop(receiverIndex).mapIndexed { index, type -> + DeclaredParameter( + name = function.allParameterNames.getOrNull(index + receiverIndex), + type = kotlinTypeModelOf(type, classpath) ?: return null, + declaresDefault = function.allParameterDefaults.getOrNull(index + receiverIndex) ?: false, + ) + } + return DeclarationModel( + simpleName = function.kotlinName, + owner = owner, + ownerIsClass = ownerIsClass, + receiver = receiverModel, + parameters = parameters, + returnType = kotlinTypeModelOf(function.returnType, classpath) ?: return null, + isComposable = isComposable, + isSuspend = function.isSuspend, + ) + } + + private fun candidateFromFunction( + owner: String, + ownerIsClass: Boolean, + function: ResolvedFunction, + classpath: ArtifactClasspath, + paramDescriptors: List, + isComposable: Boolean, + ): Candidate? { + val model = declarationModelOf(owner, ownerIsClass, function, classpath, isComposable) ?: return null + val declined = { reason: String -> Candidate(null, model.copy(declineReason = reason)) } + val receiverIndex = if (function.isExtension) 1 else 0 + if (function.hasUnsubstitutableTypeParameters) { + return declined("a bounded or reified type parameter: kotlin.Any? cannot stand for it") + } + // Issue #66: `receiver.alias(...)` resolves against the receiver's member scope, which needs + // every supertype of it -- the rule an extension property's receiver already gets. + if (function.isExtension) { + val receiverName = (function.receiverType?.classifier as? KmClassifier.Class)?.name + if (receiverName != null) { + val missing = classpath.unreachableSupertypeOf(receiverName.replace('.', '$')) + if (missing != null) { + return declined(unreachableReceiverReason(missing, receiverName.replace('/', '.'))) + } + } + } + + // Read before `resolveKotlinType` is asked anything, because for a function-typed parameter + // it is the one that would answer -- and would answer "no". See [functionSlotOrNull]. + val functionSlots = function.allParameterTypes.mapIndexed { index, type -> + functionSlotOrNull(type, paramDescriptors[index], classpath) + } + functionSlots.firstNotNullOfOrNull { it?.declineReason }?.let { return declined(it) } + + val resolvedParams = function.allParameterTypes.mapIndexed { index, type -> + functionSlots[index]?.boundary + ?: resolveKotlinType(type, classpath, BoundaryDirection.PARAMETER) + ?: return declined("no boundary type for parameter ${kotlinClassifierNameOf(type) ?: type.classifier}") + } + val returnType = resolveKotlinType(function.returnType, classpath, BoundaryDirection.RETURN) + ?: return declined("no boundary type for return ${kotlinClassifierNameOf(function.returnType) ?: function.returnType.classifier}") + // Declared, not marshalled: a `Dp` parameter's tag is FLOAT and its declared name is + // `androidx.compose.ui.unit.Dp`. `docs/archive/pythonx-adapter-design.md` §2.4 row 4. A function slot + // is the one exception and is the reason [functionSlotTypeName] exists: its declared name + // alone (`kotlin.Function1`) says neither what the lambda is invoked with nor what it must + // give back, and `pythonx` needs both to build a wrapper at all. + val paramTypeNames = function.allParameterTypes.mapIndexed { index, type -> + functionSlots[index]?.typeName + ?: kotlinClassifierNameOf(type) + ?: return declined("unnameable parameter type") + } + val returnTypeName = kotlinClassifierNameOf(function.returnType) + + val qualifiedName = "$owner.${function.kotlinName}" + val argumentExpressions = resolvedParams.mapIndexed { index, type -> type.read("args[$index]") } + + // Kotlin has no syntax to call an extension function by fully qualifying it the way an + // ordinary top-level function or a Java static can be (`pkg.fn(args)`) -- the receiver can + // never be a positional argument, only `receiver.fn(args)`. An import is the only way to + // name it without writing the receiver's own type out, which this walker does not otherwise + // need to know. + val alias = "artifact_ext_" + qualifiedName.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("") + val imports = + if (function.isExtension) listOf("import $owner.${function.kotlinName} as $alias") else emptyList() + + // Issue #38: a generic declaration's type arguments are written, never inferred -- each one + // `kotlin.Any?` (see `substitutableTypeParameterIds`). `structuralEqualityPolicy()` has no + // argument to infer `T` from, and would not compile without them. + val typeArguments = typeArgumentsOf(function) + + /** The call that passes everything, positionally -- byte for byte what this generator + * emitted before defaults existed, and still the `else`-less first branch below. */ + val call = if (function.isExtension) { + "${argumentExpressions.first()}.$alias$typeArguments(${argumentExpressions.drop(1).joinToString(", ")})" + } else { + "$owner.${function.kotlinName}$typeArguments(${argumentExpressions.joinToString(", ")})" + } + + val arity = resolvedParams.size + // The all-present body, and **only** that one, is decided here. Which arguments may be left + // out cannot be: it depends on what else carries this Kotlin name, and an overload set is a + // property of a package that one file does not see (this object's KDoc, and + // `docs/design/kotlin-extensions-in-python.md` §2.5's 11 split pairs). [applyDefaultOmission] runs + // over the finished walk and fills this in. + val callable = ArtifactCallable( + name = qualifiedName, + arity = arity, + paramTags = resolvedParams.map { it.tag }, + returnTag = returnType.tag, + lambdaBody = if (arity == 0) "{ ${returnType.wrapReturn(call)} }" + else "{ args -> ${returnType.wrapReturn(call)} }", + imports = imports, + receiverTypeName = function.receiverType?.let { kotlinClassifierNameOf(it) }, + paramNames = function.allParameterNames, + paramTypeNames = paramTypeNames, + returnTypeName = returnTypeName, + returnSupertypes = returnSupertypesOf(function.returnType, returnType.tag, classpath), + // Deliberately all `false` until [applyDefaultOmission] says otherwise: this column is a + // statement about **this body**, and this body passes everything. + paramHasDefault = List(resolvedParams.size) { false }, + ) + val omittable = omittableParameterIndices(function, receiverIndex) + return Candidate( + callable = callable, + declaration = model.copy( + bindingName = qualifiedName, + returnBoundaryTag = returnType.tag, + receiverBoundaryTag = if (function.isExtension) resolvedParams.first().tag else null, + parameters = model.parameters.mapIndexed { index, parameter -> + parameter.copy(boundaryTag = resolvedParams[index + receiverIndex].tag) + }, + ), + plan = if (omittable.isEmpty()) null else DefaultOmissionPlan( + omittable = omittable.sorted(), + valueIndices = (receiverIndex until resolvedParams.size).toList(), + declaredDefaults = function.allParameterDefaults, + buildBody = { allowed -> + val branched = presenceBranchedCall( + function, owner, alias, argumentExpressions, receiverIndex, + qualifiedName, omittable.sorted(), call, allowed, + ) + "{ args -> ${returnType.wrapReturn(branched)} }" + }, + ), + ) + } + + /** + * What one bound declaration would need in order to offer default omission, held until the whole + * walk is visible. + * + * The decision cannot be local. Kotlin resolves `receiver.padding()` against **every** overload + * of `padding` that is applicable, so whether a branch that writes no argument compiles at all is + * a fact about the other members of the overload set -- and those may be in another file, another + * facade or another multi-file part. Building the body eagerly and pruning it afterwards was + * tried and is worse: the pruning would be textual. + * + * Measured, not anticipated. Emitting every subset unconditionally failed to compile + * `foundation-layout-desktop-1.6.11` in six places -- `WindowInsets(Dp×4)` against + * `WindowInsets(Int×4)`, `paddingFromBaseline(Dp,Dp)` against `(TextUnit,TextUnit)`, and + * `paddingFrom(AlignmentLine,Dp,Dp)` against its `TextUnit` twin -- each one the branch that + * writes nothing, each one *"Overload resolution ambiguity between candidates"*. + */ + internal class DefaultOmissionPlan( + /** Indices into `allParameterTypes` that declare a default and that this generator is + * willing to write a call without. */ + val omittable: List, + /** Every value-parameter index, the extension receiver excluded. */ + val valueIndices: List, + /** What the *declaration* says, as opposed to what the binding will offer -- the sibling test + * needs Kotlin's own view, because Kotlin is what resolves the generated call. */ + val declaredDefaults: List, + /** @param allowed the omission sets to generate a call for. Everything else becomes a branch + * that refuses at run time; see [presenceBranchedCall]. */ + val buildBody: (allowed: Set>) -> String, + ) + + /** + * How many defaulted parameters one declaration may have before the generator stops offering + * omission at all. + * + * Presence branching costs **one generated call expression per subset** of the defaulted + * parameters, so the cost is 2^n and it is paid at build time, on every build, by whoever asked + * for the package. Measured over everything the walker binds today -- all of + * `foundation-layout-desktop-1.6.11`, `kotlin-stdlib`'s `kotlin.text` and the whole JUnit 4 jar + * -- the widest bound declaration declares **four** defaults (`padding(start, top, end, bottom)` + * and six others), so 64 is well clear of the corpus while bounding an artefact nobody has + * measured. + * + * `docs/archive/pythonx-adapter-design.md` §4.5 rejects presence branching outright on "2^15 branches for + * `Text`". That objection is to an unbounded version of it and, separately, to a case that is not + * in the table: `Text` is a `@Composable`, and the arity check in [kotlinCandidates] declines + * every composable for the synthetic `$composer`/`$changed` parameters its JVM signature carries. + * If composables ever become bindable they will arrive with 15+ defaults and land past this cap + * -- which is the honest outcome, because the mechanism that suits them is the `$default` mask + * §5.2 found is a *declared* trailing parameter there, not this one. + */ + internal const val MAX_OMITTABLE_PARAMETERS = 6 + + /** + * The slots the generated body will let Python leave out, as indices into `allParameterTypes`. + * + * Empty means "this declaration is bound exactly as it was before defaults existed". Four + * separate reasons produce that, and all four are deliberate: + * + * 1. nothing declares a default -- the common case, 43 of `foundation-layout`'s 70 entries; + * 2. more than [MAX_OMITTABLE_PARAMETERS] do (see there); + * 3. a parameter name is missing or is not one a named argument can be written from. Every + * partial branch names what it passes, because an omission that is not a trailing one has no + * positional spelling, so a nameless parameter makes the whole set unusable; + * 4. the receiver, always. It is positional in Kotlin and [RECEIVER_PARAMETER_NAME] is + * deliberately not an identifier. + */ + private fun omittableParameterIndices(function: ResolvedFunction, receiverIndex: Int): Set { + val names = function.allParameterNames + if (names.size != function.allParameterTypes.size) return emptySet() + val indices = function.allParameterDefaults.indices.filter { index -> + index >= receiverIndex && + function.allParameterDefaults.getOrElse(index) { false } && + names[index] != RECEIVER_PARAMETER_NAME + } + if (indices.isEmpty() || indices.size > MAX_OMITTABLE_PARAMETERS) return emptySet() + // Every *passed* argument in a partial branch is named, not only the defaulted ones, so a + // name this cannot write anywhere in the signature sinks the whole set rather than just its + // own slot. + if (names.drop(receiverIndex).any { !isWritableParameterName(it) }) return emptySet() + return indices.toSet() + } + + /** + * Decides, over the finished walk, which omission sets each planned declaration may offer, and + * rewrites its body and its `paramHasDefault` accordingly. + * + * ### The rule + * + * A branch that writes the arguments *W* is refused when **any sibling overload of the same + * Kotlin name would also accept exactly *W***: same receiver, a parameter of the same name and + * declared type for every member of *W*, and a default on everything else it declares. That is + * the shape Kotlin reports as `Overload resolution ambiguity`, and the generated call has no way + * to break the tie -- there are no arguments left to type-annotate, which is exactly why the tie + * exists. + * + * Deliberately **not** a model of Kotlin's specificity rules. Kotlin does resolve some of these + * (it prefers the two-`Dp` `padding` over the four-`Dp` one when nothing is written, on parameter + * count), and reproducing that here would mean a generated call silently binding to a *different + * declaration* than the table entry names. Refusing the branch instead costs one spelling -- + * `padding__Dp_Dp()` with no arguments -- and buys that every branch calls the declaration its + * entry is named after. + * + * ### What a refused branch becomes + * + * Not a missing branch: `pythonx` reads `paramHasDefault` per slot and cannot express "these two + * but not both at once", so the call is reachable and has to answer for itself. It throws, naming + * the declaration and saying what to write, which is the same answer + * [disambiguateOverloads] gives one level up for the same reason. + */ + private fun applyDefaultOmission(candidates: List): List { + if (candidates.none { it.plan != null }) return candidates + val byName = candidates.filter { it.callable != null }.groupBy { it.callable!!.name } + return candidates.map { candidate -> + val plan = candidate.plan ?: return@map candidate + val callable = candidate.callable ?: return@map candidate + val siblings = byName[callable.name].orEmpty().filter { it !== candidate } + val allowed = plan.omittable.powerSet() + .filter { it.isNotEmpty() && !anySiblingAlsoAccepts(candidate, plan, siblings, it) } + .toSet() + if (allowed.isEmpty()) return@map candidate.copy(plan = null) + val omittableNow = plan.omittable.filter { index -> allowed.any { index in it } }.toSet() + Candidate( + callable = callable.copy( + lambdaBody = plan.buildBody(allowed), + // The binding's contract, not the declaration's: exactly the slots the rewritten + // body has a call for that does not mention them. `pythonx._bind` fills the + // sentinel from this, so it has to describe the body and not the Kotlin source. + paramHasDefault = callable.paramHasDefault.indices.map { it in omittableNow }, + ), + declaration = candidate.declaration.copy( + // Kept in step one consumer further out: `PyiRendering` writes `= ...` from this + // field, and a stub promising an omission the binding refuses would type-check at + // the call site and fail at run time -- `docs/design/pyi-generation-design.md` §3.2's + // rule about parameter names, applied to their defaults. + parameters = candidate.declaration.parameters.mapIndexed { index, parameter -> + parameter.copy(declaresDefault = plan.valueIndices[index] in omittableNow) + }, + ), + ) + } + } + + private fun anySiblingAlsoAccepts( + candidate: Candidate, + plan: DefaultOmissionPlan, + siblings: List, + omitted: Set, + ): Boolean { + val callable = candidate.callable ?: return false + val written = plan.valueIndices.filter { it !in omitted } + .map { callable.paramNames[it] to callable.paramTypeNames[it] } + return siblings.any { sibling -> + val other = sibling.callable ?: return@any false + if (other.receiverTypeName != callable.receiverTypeName) return@any false + val otherValues = other.paramNames.indices + .filter { other.paramNames[it] != RECEIVER_PARAMETER_NAME } + .map { Triple(other.paramNames[it], other.paramTypeNames.getOrNull(it), plan.siblingDeclares(sibling, it)) } + written.all { (name, type) -> otherValues.any { it.first == name && it.second == type } } && + otherValues.filterNot { written.any { w -> w.first == it.first && w.second == it.second } } + .all { it.third } + } + } + + /** The sibling's *declared* defaults, which is Kotlin's view and therefore the one that decides + * applicability. `ArtifactCallable.paramHasDefault` is the binding's view and is not it -- at + * this point in the walk it is still all `false` for everyone. */ + private fun DefaultOmissionPlan.siblingDeclares(sibling: Candidate, index: Int): Boolean { + val receiverIndex = if (sibling.callable?.receiverTypeName != null) 1 else 0 + return sibling.declaration.parameters.getOrNull(index - receiverIndex)?.declaresDefault ?: false + } + + private fun List.powerSet(): List> = + (0 until (1 shl size)).map { mask -> filterIndexed { bit, _ -> (mask shr bit) and 1 == 1 }.toSet() } + + /** + * One `when` over which defaulted slots arrived as `null`, with one call expression per subset. + * + * The subject is a bitmask built from the sentinel tests themselves rather than a chain of + * `if`s, so the generated text is 2^n *branches* but only n null tests, and reads as a table of + * the omission sets rather than as nested conditionals. Mask bit `j` is set when the `j`-th + * omittable slot was **left out**, so mask `0` is [allPresentCall] -- unchanged from what this + * generator emitted before -- and the all-omitted mask becomes the `else` Kotlin requires. + * + * A mask outside [allowed] is a call [applyDefaultOmission] refused to generate. It becomes a + * `throw` rather than a silent fallback to the all-present call, which would pass a `null` into + * a cast and fail somewhere else entirely. + */ + private fun presenceBranchedCall( + function: ResolvedFunction, + owner: String, + alias: String, + argumentExpressions: List, + receiverIndex: Int, + qualifiedName: String, + ordered: List, + allPresentCall: String, + allowed: Set>, + ): String { + val names = function.allParameterNames + val subject = ordered.mapIndexed { bit, index -> + "(if (args[$index] == null) ${1 shl bit} else 0)" + }.joinToString(" + ") + + fun callOmitting(omitted: Set): String { + val arguments = argumentExpressions.indices + .filter { it >= receiverIndex && it !in omitted } + .joinToString(", ") { "${writeParameterName(names[it])} = ${argumentExpressions[it]}" } + val typeArguments = typeArgumentsOf(function) + return if (function.isExtension) "${argumentExpressions.first()}.$alias$typeArguments($arguments)" + else "$owner.${function.kotlinName}$typeArguments($arguments)" + } + + val last = (1 shl ordered.size) - 1 + return buildString { + appendLine("when ($subject) {") + for (mask in 0..last) { + val omitted = ordered.filterIndexed { bit, _ -> (mask shr bit) and 1 == 1 }.toSet() + val label = if (mask == last) "else" else "$mask" + val branch = when { + mask == 0 -> allPresentCall + omitted in allowed -> callOmitting(omitted) + else -> "throw IllegalArgumentException(" + + "\"$qualifiedName: leaving out " + + omitted.joinToString(" and ") { names[it] } + + " is ambiguous with another overload of the same name; write ${ + if (omitted.size == 1) "it" else "at least one of them" + }\")" + } + appendLine(" $label -> $branch") + } + append("}") + } + } + + /** `` for a generic declaration, one per type parameter; empty otherwise. */ + private fun typeArgumentsOf(function: ResolvedFunction): String = + if (function.typeArgumentCount == 0) "" else List(function.typeArgumentCount) { "kotlin.Any?" }.joinToString(", ", "<", ">") + + /** Kotlin's hard keywords: a parameter declared with one carries backticks in source and none in + * `@Metadata`, so a named argument written from the metadata name alone would not parse. */ + private val KOTLIN_HARD_KEYWORDS = setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", + "interface", "is", "null", "object", "package", "return", "super", "this", "throw", "true", + "try", "typealias", "typeof", "val", "var", "when", "while", + ) + + private fun isWritableParameterName(name: String): Boolean = + name.isNotEmpty() && name.first().let { it.isLetter() || it == '_' } && + name.all { it.isLetterOrDigit() || it == '_' } + + private fun writeParameterName(name: String): String = + if (name in KOTLIN_HARD_KEYWORDS) "`$name`" else name + + private fun candidateFromDescriptor(owner: String, methodName: String, descriptor: String): Candidate? { + val (paramDescriptors, returnDescriptor) = splitMethodDescriptor(descriptor) + val returnType = boundaryTypeOf(returnDescriptor) ?: return null + val paramTypes = paramDescriptors.map { boundaryTypeOf(it) ?: return null } + if (paramTypes.any { it.isReturnOnly }) return null + + val argumentExpressions = paramTypes.mapIndexed { index, type -> type.read("args[$index]") } + val call = "$owner.$methodName(${argumentExpressions.joinToString(", ")})" + val body = returnType.wrapReturn(call) + val paramTypeNames = paramDescriptors.map { kotlinNameOfAdmittedDescriptor(it) } + val returnTypeName = kotlinNameOfAdmittedDescriptor(returnDescriptor) + val callable = ArtifactCallable( + name = "$owner.$methodName", + arity = paramTypes.size, + paramTags = paramTypes.map { it.tag }, + returnTag = returnType.tag, + // Arity 0 has no `args` to name, exactly as `FragmentScanner`'s static-getter bodies do. + lambdaBody = if (paramTypes.isEmpty()) "{ $body }" else "{ args -> $body }", + // `paramNames` stays empty: a Java class file carries parameter names only when it was + // compiled with `-parameters`, and JUnit 4 was not. Empty means "not supplied" (see + // `ExposedCallable.paramNames`), which is the truth here rather than an invented `arg0`. + paramTypeNames = paramTypeNames, + returnTypeName = returnTypeName, + ) + return Candidate( + callable = callable, + declaration = DeclarationModel( + simpleName = methodName, + owner = owner, + ownerIsClass = true, + receiver = null, + // `docs/design/pyi-generation-design.md` §3.2: the names are genuinely absent, and a wrong + // keyword name is worse than no keyword name because it type-checks at the call site + // and fails at run time. + parameters = paramTypeNames.mapIndexed { index, name -> + DeclaredParameter(null, KotlinTypeModel(name), declaresDefault = false, boundaryTag = paramTypes[index].tag) + }, + returnType = KotlinTypeModel(returnTypeName), + returnBoundaryTag = returnType.tag, + bindingName = "$owner.$methodName", + parameterNamesKnown = false, + ), + ) + } + + /** + * The Kotlin spelling of a descriptor [boundaryTypeOf] has **already admitted**, which is the + * only reason this can be total: the admitted set is the primitives, `String`, `byte[]` and + * `void`, all of which have a Kotlin name. It is not a general descriptor-to-Kotlin mapping -- + * see [boundaryTypeOf]'s KDoc for why no such mapping exists. + */ + private fun kotlinNameOfAdmittedDescriptor(descriptor: String): String = when (descriptor) { + "Z" -> "kotlin.Boolean" + "B" -> "kotlin.Byte" + "S" -> "kotlin.Short" + "I" -> "kotlin.Int" + "J" -> "kotlin.Long" + "F" -> "kotlin.Float" + "D" -> "kotlin.Double" + "Ljava/lang/String;" -> "kotlin.String" + "[B" -> "kotlin.ByteArray" + "V" -> "kotlin.Unit" + else -> error("descriptor $descriptor is not one boundaryTypeOf admits") + } + + private fun Int.hasFlag(flag: Int): Boolean = (this and flag) != 0 +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ComposableThunks.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ComposableThunks.kt new file mode 100644 index 000000000..2e30f0976 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ComposableThunks.kt @@ -0,0 +1,368 @@ +package python.multiplatform.gradle.artifact + +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type +import java.io.Serializable +import kotlin.math.ceil + +/** + * The one call site in this whole generator that `kotlinc` cannot emit, and the measurement that + * says so. + * + * ### Why a `.class` and not generated Kotlin + * + * Every other binding this walker produces is *Kotlin source* that `kotlinc` compiles, precisely so + * that mangling stays the compiler's job (agent-rules §12). A `@Composable` is the one shape where + * that route is closed, for two independent reasons, both measured against + * `material3-desktop-1.6.11` with `kotlinc` 2.4.20-Beta2: + * + * 1. **The synthetic parameters are unreachable from source.** `androidx.compose.material3.Text` + * declares 16 Kotlin parameters and its JVM method takes 20 -- `$composer`, two `$changed` + * bitmasks and one `$default` (`docs/archive/pythonx-adapter-design.md` §5.2, and + * `ComposableBindingTest.everyComposableJvmSignatureIsKotlinParamsThenComposerThenInts` over + * every composable in three jars). The Compose plugin adds them during IR lowering, so at source + * level they are not parameters of anything and cannot be named or passed. Whatever the Compose + * plugin computes for `$default` is a **compile-time constant** derived from which named + * arguments were written -- which is why "let `kotlinc` do it" costs one generated call + * expression per subset, 2^15 for `Text` (§4.5). + * 2. **The declaring class has no Kotlin name.** The obvious escape -- call the JVM method + * directly, `TextKt.\`Text-fLXpl1I\`(...)` -- does not compile: + * + * probe.kt:2:35: error: unresolved reference 'TextKt'. + * + * A Kotlin file facade is not addressable from Kotlin at all, so this is not about the hyphen and + * not about `-Xjvm-default` or backticks. Java cannot spell the method either (a hyphen is not a + * Java identifier), and `Text$default` -- Kotlin's ordinary synthetic for defaulted calls -- does + * **not exist** for a composable, which is what the `$default` parameter replaces. + * + * So the call site is emitted as bytecode: one `public static Object t(Object[])` per bound + * composable, doing nothing but unboxing the boundary's argument array and `INVOKESTATIC`. The + * generated Kotlin fragment calls *that*, by a name this generator chose and therefore can spell. + * + * ### Why this is not "looking a method up by name" + * + * agent-rules §12 retires *runtime* lookup by Kotlin name, because the mangling suffix hashes only + * the value-class signature and `padding`/`size`/`width`/`height` all share `-3ABfNKs`. Nothing here + * derives a name: [ThunkSpec] carries the JVM name and descriptor **copied verbatim from the + * `MethodNode` being walked**, and the resulting `INVOKESTATIC` is resolved by the JVM's own linker + * at class-load time, exactly as the one `kotlinc` would have emitted. There is no reflection, no + * `MethodHandles.lookup`, and nothing for GraalVM's closed-world assumption to object to. + */ +internal data class ThunkSpec( + /** e.g. `androidx/compose/material3/TextKt`. */ + val ownerInternalName: String, + /** The JVM method name, mangling suffix included: `Text-fLXpl1I`. */ + val methodName: String, + /** The JVM method descriptor, synthetic parameters included. */ + val descriptor: String, + /** + * Per JVM parameter, the `@JvmInline value class` **box** the boundary sends for that slot, or + * `null` when the slot needs no unwrapping. Empty means "none of them do", which is every thunk + * emitted before a composable's declared slots were typed from `@Metadata`. + * + * Why a box arrives at a slot the descriptor says is a `long`: `Icon`'s `tint` is a `Color`, and + * `ArtifactScanner.composableDeclaredSlot` types it `OBJECT` because that is what an ordinary + * declaration taking the same `Color` has always done. The value crossing the boundary is + * therefore a handle to an *instance* -- of `kotlin.ULong`, the carrier + * `resolveKotlinBoundary`'s descent stopped at -- while the callee's signature takes the `J` it + * erases to. `unbox-impl` is the one operation that bridges them, and the name recorded here is + * the carrier's, read off its own class file together with the descriptor that method returns. + */ + val valueClassUnboxOwners: List = emptyList(), +) : Serializable + +/** Where generated thunk classes live. Under [ARTIFACTS_PACKAGE] so that + * `python-multiplatform-ksp`'s `ALWAYS_EXCLUDED_PACKAGE_PREFIXES` keeps ignoring it, and in a + * sub-package of its own because these are `.class` files on a classpath rather than sources in a + * source set. */ +internal val THUNK_PACKAGE = "$ARTIFACTS_PACKAGE.thunk" + +internal fun thunkClassQualifiedName(fragmentObjectName: String): String = "$THUNK_PACKAGE.${fragmentObjectName}Thunks" + +internal fun thunkClassInternalName(fragmentObjectName: String): String = + thunkClassQualifiedName(fragmentObjectName).replace('.', '/') + +internal fun thunkMethodName(index: Int): String = "t$index" + +/** + * How a composable's JVM parameter list divides into declared parameters and synthetic ones. + * + * `null` from [of] means "this is not the shape a composable has", which is the honest answer for + * anything the arithmetic below does not predict -- a future Compose lowering, or a non-composable + * that merely carries the annotation. Declining is what the walker did for *every* composable + * before; keeping it as the fallback means an unrecognised shape degrades to that rather than to a + * call with the wrong number of arguments. + */ +internal class ComposableShape( + /** Kotlin-declared parameters, extension receiver included. */ + val declaredCount: Int, + /** How many `$changed` bitmasks follow the `Composer`. */ + val changedCount: Int, + /** How many `$default` bitmasks follow those -- `0` when nothing is defaulted. */ + val defaultCount: Int, +) { + val composerIndex: Int get() = declaredCount + val firstChangedIndex: Int get() = declaredCount + 1 + val firstDefaultIndex: Int get() = declaredCount + 1 + changedCount + val totalCount: Int get() = declaredCount + 1 + changedCount + defaultCount + + /** + * What the synthetic slots are called on the binding, in JVM parameter order. + * + * The compiler's own spellings, and they are load-bearing rather than decorative: `pythonx` + * recognises a composable by finding `$composer` among a declaration's parameter names, and + * computes the mask for the slots named `$default...`. A `$` is not a Python identifier + * character either, so no keyword argument can ever collide with one. + */ + fun syntheticParameterNames(): List = buildList { + add("\$composer") + repeat(changedCount) { add(if (it == 0) "\$changed" else "\$changed$it") } + repeat(defaultCount) { add(if (it == 0) "\$default" else "\$default$it") } + } + + companion object { + /** Compose's `SLOTS_PER_INT`: three bits of `$changed` per parameter plus one, so ten + * parameters fit one `int`. Verified against every composable three Compose jars declare, + * not taken from the compiler's source. */ + private const val SLOTS_PER_INT = 10 + + /** Compose's `BITS_PER_INT`: one `$default` bit per parameter, 31 to an `int`. */ + private const val BITS_PER_INT = 31 + + private fun changedCountFor(declared: Int): Int = + if (declared == 0) 1 else ceil(declared / SLOTS_PER_INT.toDouble()).toInt() + + private fun defaultCountFor(declared: Int): Int = + if (declared == 0) 0 else ceil(declared / BITS_PER_INT.toDouble()).toInt() + + /** + * Reads the shape off the descriptor rather than predicting it: the parameter at + * [declaredCount] must be the `Composer` and everything after it must be `int`. Only *how + * many* of those ints are `$changed` comes from arithmetic, and the two admissible totals + * (with and without `$default`) are far enough apart that the check is not vacuous. + */ + fun of(declaredCount: Int, jvmDescriptors: List): ComposableShape? { + if (declaredCount < 0 || jvmDescriptors.size <= declaredCount) return null + if (jvmDescriptors[declaredCount] != COMPOSER_DESCRIPTOR) return null + val trailing = jvmDescriptors.drop(declaredCount + 1) + if (trailing.any { it != "I" }) return null + val changed = changedCountFor(declaredCount) + val defaults = trailing.size - changed + if (defaults != 0 && defaults != defaultCountFor(declaredCount)) return null + return ComposableShape(declaredCount, changed, defaults) + } + + const val COMPOSER_DESCRIPTOR = "Landroidx/compose/runtime/Composer;" + const val COMPOSABLE_ANNOTATION_DESCRIPTOR = "Landroidx/compose/runtime/Composable;" + } +} + +/** + * One class holding every thunk a single artefact fragment needs. + * + * The thunk bodies themselves have **no branches**, which is why [ClassWriter.COMPUTE_MAXS] is + * enough and no `StackMapTable` has to be computed for them -- `COMPUTE_FRAMES` would need a + * `ClassLoader` that can see Compose, and this runs inside a Gradle worker that deliberately cannot. + * + * The one exception is [visitValueClassUnwrapper], which has exactly one branch and writes its own + * frame by hand for that reason. It is a separate method rather than an inline sequence precisely so + * that the frame it needs is a fixed, trivial one (`F_SAME`, a single `Object` local, empty stack) + * instead of whatever the enclosing thunk's operand stack happened to hold at that point. + */ +internal fun generateThunkClass(fragmentObjectName: String, specs: List): ByteArray { + val internalName = thunkClassInternalName(fragmentObjectName) + val writer = ClassWriter(ClassWriter.COMPUTE_MAXS) + writer.visit( + Opcodes.V1_8, + Opcodes.ACC_PUBLIC or Opcodes.ACC_FINAL or Opcodes.ACC_SUPER, + internalName, + null, + "java/lang/Object", + null, + ) + // One unwrapper per distinct (box, erased type) pair rather than per slot: `Color`'s `tint` + // appears on three `Icon` overloads alone, and every one of them wants the same two instructions. + val unwrappers = LinkedHashMap, String>() + specs.forEach { spec -> + val (parameters, _) = splitMethodDescriptor(spec.descriptor) + parameters.forEachIndexed { slot, descriptor -> + val box = spec.valueClassUnboxOwners.getOrNull(slot) ?: return@forEachIndexed + unwrappers.getOrPut(box to descriptor) { "u${unwrappers.size}" } + } + } + + specs.forEachIndexed { index, spec -> + val method = writer.visitMethod( + Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC, + thunkMethodName(index), + "([Ljava/lang/Object;)Ljava/lang/Object;", + null, + null, + ) + method.visitCode() + val (parameters, returns) = splitMethodDescriptor(spec.descriptor) + parameters.forEachIndexed { slot, descriptor -> + method.visitVarInsn(Opcodes.ALOAD, 0) + method.pushInt(slot) + method.visitInsn(Opcodes.AALOAD) + val box = spec.valueClassUnboxOwners.getOrNull(slot) + if (box == null) { + method.unbox(descriptor) + } else { + method.visitMethodInsn( + Opcodes.INVOKESTATIC, + internalName, + unwrappers.getValue(box to descriptor), + "(Ljava/lang/Object;)$descriptor", + false, + ) + } + } + method.visitMethodInsn(Opcodes.INVOKESTATIC, spec.ownerInternalName, spec.methodName, spec.descriptor, false) + method.box(returns) + method.visitInsn(Opcodes.ARETURN) + method.visitMaxs(0, 0) + method.visitEnd() + } + unwrappers.forEach { (key, name) -> writer.visitValueClassUnwrapper(name, key.first, key.second) } + writer.visitEnd() + return writer.toByteArray() +} + +/** + * `static u(Object)`: the box `HandleTable` handed back, opened into the value the + * callee's descriptor declares -- and **`null` turned into a zero rather than a + * `NullPointerException`**. + * + * ### Why null arrives at all, and why zero is the right answer + * + * A `@Composable`'s omitted argument is not absent: the slot is still a real JVM parameter, and what + * says it was left out is a bit of the `$default` mask (`docs/archive/pythonx-adapter-design.md` §5.2). So + * `pythonx._absent` has to put *something* in the slot, and for an `OBJECT` slot the only thing it + * can put there is `None`. Its own docstring states the invariant that makes any value safe: the + * callee's generated prologue assigns over the slot before its first use, which is what the mask + * *means*. `_absent` already relies on it for the primitive widths (a `0` into a `FLOAT` slot); this + * is the same rule one type further on, and it is needed now because a slot that used to be `INT` + * -- and therefore got a `0` -- is an `OBJECT` since `ArtifactScanner.composableDeclaredSlot` began + * typing declared slots from `@Metadata`. + * + * `Icon(painter, contentDescription = ...)` with no `tint` is exactly that call, and without this it + * is an NPE inside a generated class with no source. + * + * ### The frame + * + * One `IFNULL`, therefore one merge point, therefore one `StackMapTable` entry -- written here + * rather than computed, because `ClassWriter.COMPUTE_FRAMES` resolves types through a `ClassLoader` + * that would have to see Compose and this generator runs in a worker that cannot. The frame is the + * simplest one there is: at the label, the single `Object` parameter is still the only local and the + * stack is empty, which is `F_SAME`. + */ +private fun ClassWriter.visitValueClassUnwrapper(name: String, boxInternalName: String, descriptor: String) { + val method = visitMethod( + Opcodes.ACC_PRIVATE or Opcodes.ACC_STATIC, + name, + "(Ljava/lang/Object;)$descriptor", + null, + null, + ) + method.visitCode() + val absent = org.objectweb.asm.Label() + method.visitVarInsn(Opcodes.ALOAD, 0) + method.visitJumpInsn(Opcodes.IFNULL, absent) + method.visitVarInsn(Opcodes.ALOAD, 0) + method.visitTypeInsn(Opcodes.CHECKCAST, boxInternalName) + method.visitMethodInsn(Opcodes.INVOKEVIRTUAL, boxInternalName, VALUE_CLASS_UNBOX_METHOD, "()$descriptor", false) + method.visitInsn(returnOpcodeOf(descriptor)) + method.visitLabel(absent) + method.visitFrame(Opcodes.F_SAME, 0, null, 0, null) + method.pushZero(descriptor) + method.visitInsn(returnOpcodeOf(descriptor)) + method.visitMaxs(0, 0) + method.visitEnd() +} + +private fun returnOpcodeOf(descriptor: String): Int = when (descriptor) { + "J" -> Opcodes.LRETURN + "D" -> Opcodes.DRETURN + "F" -> Opcodes.FRETURN + "Z", "B", "S", "C", "I" -> Opcodes.IRETURN + else -> Opcodes.ARETURN +} + +private fun MethodVisitor.pushZero(descriptor: String) { + when (descriptor) { + "J" -> visitInsn(Opcodes.LCONST_0) + "D" -> visitInsn(Opcodes.DCONST_0) + "F" -> visitInsn(Opcodes.FCONST_0) + "Z", "B", "S", "C", "I" -> visitInsn(Opcodes.ICONST_0) + else -> visitInsn(Opcodes.ACONST_NULL) + } +} + +private fun MethodVisitor.pushInt(value: Int) { + when { + value <= 5 -> visitInsn(Opcodes.ICONST_0 + value) + value <= Byte.MAX_VALUE -> visitIntInsn(Opcodes.BIPUSH, value) + value <= Short.MAX_VALUE -> visitIntInsn(Opcodes.SIPUSH, value) + else -> visitLdcInsn(value) + } +} + +/** + * Turns the `Object` in an argument slot into what the descriptor wants. + * + * The widths are the boundary's, not the declaration's: `boundaryTypeOf` says `TypeTag.INT` carries + * a `Long` and `TypeTag.FLOAT` carries a `Double`, so every integral slot arrives boxed as `Long` + * and every floating one as `Double`. `java.lang.Number` is what makes one sequence serve all of + * them, and it narrows exactly the way the generated Kotlin's `(args[i] as Long).toInt()` does. + */ +private fun MethodVisitor.unbox(descriptor: String) { + when (descriptor) { + "Z" -> { + visitTypeInsn(Opcodes.CHECKCAST, "java/lang/Boolean") + visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Boolean", "booleanValue", "()Z", false) + } + "B" -> numberValue("byteValue", "()B") + "S" -> numberValue("shortValue", "()S") + "I" -> numberValue("intValue", "()I") + "J" -> numberValue("longValue", "()J") + "F" -> numberValue("floatValue", "()F") + "D" -> numberValue("doubleValue", "()D") + // `C` never reaches here: `composableParamTagOf` declines a `char`, for the reason + // `boundaryTypeOf` gives -- Python has no character type. + else -> visitTypeInsn(Opcodes.CHECKCAST, Type.getType(descriptor).internalName) + } +} + +private fun MethodVisitor.numberValue(name: String, descriptor: String) { + visitTypeInsn(Opcodes.CHECKCAST, "java/lang/Number") + visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Number", name, descriptor, false) +} + +/** + * Boxes the return into what its `TypeTag` promises the trampoline. + * + * A `void` composable -- which is nearly all of them -- returns `kotlin.Unit`, not `null`, because + * `TypeTag.UNIT` is what the generated Kotlin's `wrapReturn` produces for `V` and the trampoline + * maps that to Python's `None`. The integral and floating cases widen for the same reason + * `boundaryTypeOf`'s wrap templates do: `INT` is a `Long` on the wire and `FLOAT` is a `Double`. + */ +private fun MethodVisitor.box(descriptor: String) { + when (descriptor) { + "V" -> visitFieldInsn(Opcodes.GETSTATIC, "kotlin/Unit", "INSTANCE", "Lkotlin/Unit;") + "Z" -> valueOf("java/lang/Boolean", "(Z)Ljava/lang/Boolean;") + "B" -> { visitInsn(Opcodes.I2L); valueOf("java/lang/Long", "(J)Ljava/lang/Long;") } + "S" -> { visitInsn(Opcodes.I2L); valueOf("java/lang/Long", "(J)Ljava/lang/Long;") } + "I" -> { visitInsn(Opcodes.I2L); valueOf("java/lang/Long", "(J)Ljava/lang/Long;") } + "J" -> valueOf("java/lang/Long", "(J)Ljava/lang/Long;") + "F" -> { visitInsn(Opcodes.F2D); valueOf("java/lang/Double", "(D)Ljava/lang/Double;") } + "D" -> valueOf("java/lang/Double", "(D)Ljava/lang/Double;") + else -> Unit // already a reference + } +} + +private fun MethodVisitor.valueOf(owner: String, descriptor: String) { + visitMethodInsn(Opcodes.INVOKESTATIC, owner, "valueOf", descriptor, false) +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/JvmDescriptors.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/JvmDescriptors.kt new file mode 100644 index 000000000..82b48c3fc --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/JvmDescriptors.kt @@ -0,0 +1,101 @@ +package python.multiplatform.gradle.artifact + +/** + * One JVM type as `python.multiplatform.reflection.TypeTag` sees it, plus the two Kotlin + * expressions that get a value across the boundary in each direction. + * + * @param tag the `TypeTag` constant name. A string rather than the enum, for the reason + * `python.multiplatform.ksp.CallableEntryModel` gives for its own: this whole model stays + * independent of the runtime module, so it is reachable from a plugin unit test that has no + * Kotlin Multiplatform anything on its classpath. + * @param readFn given the `args[i]` slot expression, produces the declared parameter type. + * @param wrapFn given the call expression, produces what [tag] promises. + * @param isReturnOnly `void`, which is a legal return descriptor and never a parameter one. + */ +internal class BoundaryType( + val tag: String, + private val readFn: (String) -> String, + private val wrapFn: (String) -> String, + val isReturnOnly: Boolean = false, +) { + /** + * @param readTemplate `%s` is the `args[i]` slot; the result is the declared parameter type. + * @param wrapTemplate `%s` is the call expression; the result is what [tag] promises. + * + * The primitive table below only ever needs a single textual substitution, so it stays on this + * constructor. `KotlinMetadata.kt`'s value-class case needs to *compose* one [BoundaryType] + * inside another (`Meters(%s as Double)` wrapping `%s as Double`) for an arbitrary slot + * expression supplied later, which a second `%s`-replace on an already-substituted string + * cannot do -- hence the function-typed primary constructor these two forward to. + */ + constructor(tag: String, readTemplate: String, wrapTemplate: String, isReturnOnly: Boolean = false) : this( + tag, + readFn = { slot -> readTemplate.replace("%s", slot) }, + wrapFn = { call -> wrapTemplate.replace("%s", call) }, + isReturnOnly = isReturnOnly, + ) + + fun read(slot: String): String = readFn(slot) + fun wrapReturn(call: String): String = wrapFn(call) +} + +/** + * The **descriptor-only** half of the walker's type admission policy: what can be bound from a class + * file that carries no `kotlin.Metadata` at all, i.e. a Java class. + * + * `null` means "this declaration is not bound", and there is still no `OBJECT` fallback here on + * purpose. A `TypeTag.OBJECT` parameter needs a **Kotlin type name** to cast the boundary's handle + * to, and a descriptor is not one: `Ljava/util/List;` has no Kotlin spelling at all (Kotlin maps it + * onto `kotlin.collections.List`, and refuses `java.util.List` written out), a `Ljava/lang/Object;` + * cast checks nothing, and a value class erases to the primitive it wraps so its descriptor actively + * lies about what the method takes. Every one of those produces generated Kotlin that either does + * not compile or compiles into the wrong call. + * + * **What changed, and where.** `resolveKotlinType` (`KotlinMetadata.kt`) *does* have an object case, + * because `@Metadata`'s classifier already **is** the Kotlin name -- `androidx/compose/ui/Modifier`, + * `/` for `.` and nothing else to guess. So the sentence above is not "the walker cannot bind + * objects"; it is "a descriptor cannot name one". The Java path keeps the small, + * obviously-correct subset; the Kotlin path is where Compose is reached. + */ +internal fun boundaryTypeOf(descriptor: String): BoundaryType? = when (descriptor) { + // `TypeTag.INT` carries a Long, so a narrower integral type converts in both directions -- + // the same widening `FragmentScanner` emits for a Kotlin `Int`. + "Z" -> BoundaryType("BOOLEAN", "(%s as Boolean)", "(%s)") + "B" -> BoundaryType("INT", "(%s as Long).toByte()", "(%s).toLong()") + "S" -> BoundaryType("INT", "(%s as Long).toShort()", "(%s).toLong()") + "I" -> BoundaryType("INT", "(%s as Long).toInt()", "(%s).toLong()") + "J" -> BoundaryType("INT", "(%s as Long)", "(%s)") + "F" -> BoundaryType("FLOAT", "(%s as Double).toFloat()", "(%s).toDouble()") + "D" -> BoundaryType("FLOAT", "(%s as Double)", "(%s)") + "Ljava/lang/String;" -> BoundaryType("STRING", "(%s as String)", "(%s)") + "[B" -> BoundaryType("BYTES", "(%s as ByteArray)", "(%s)") + "V" -> BoundaryType("UNIT", "(%s as Unit)", "(%s)", isReturnOnly = true) + // `C` is deliberately absent: Python has no character type, so there is no tag that means one, + // and mapping it onto INT would make a round trip through Python change the declaration's type. + else -> null +} + +/** + * Splits a JVM method descriptor into its parameter descriptors and its return descriptor. + * + * Written out rather than taken from ASM's `Type`: `Type.getArgumentTypes` allocates a `Type` per + * parameter and then this would immediately turn each back into the descriptor string + * [boundaryTypeOf] matches on. + */ +internal fun splitMethodDescriptor(descriptor: String): Pair, String> { + val close = descriptor.indexOf(')') + require(descriptor.startsWith("(") && close > 0) { "not a method descriptor: $descriptor" } + val params = mutableListOf() + var i = 1 + while (i < close) { + val start = i + while (descriptor[i] == '[') i++ + if (descriptor[i] == 'L') { + i = descriptor.indexOf(';', i) + 1 + } else { + i++ + } + params += descriptor.substring(start, i) + } + return params to descriptor.substring(close + 1) +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanWorkAction.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanWorkAction.kt new file mode 100644 index 000000000..5674f73b4 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanWorkAction.kt @@ -0,0 +1,160 @@ +package python.multiplatform.gradle.artifact + +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.workers.WorkAction +import org.gradle.workers.WorkParameters +import org.gradle.workers.WorkerExecutor +import python.multiplatform.gradle.DEFAULT_KLIB_WORKER_COORDINATES +import python.multiplatform.gradle.model.DeclarationModel +import java.io.File +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.Serializable + +/** + * Runs [KlibScanner] behind a `WorkerExecutor` classloader boundary, because running it in this + * plugin's own classloader does not work in a real consumer build. + * + * ### Why a worker at all + * + * `KlibScanner`'s KDoc and `python-multiplatform-gradle-plugin/build.gradle.kts`'s + * `kotlin-compiler-embeddable` dependency comment record the measurement: `LibraryAbiReader` (the + * pinned `2.0.20` classes this plugin is compiled against) and `KotlinLibraryImplKt` (the Kotlin + * Gradle Plugin's `kotlin-util-klib`, a *different* version, reachable from this plugin's own + * classloader in every consumer build) both answer to `org.jetbrains.kotlin.library.*`, and delegation + * serves each class from whichever source resolves it first. The two do not agree on + * `KotlinLibraryImpl`'s constructor, hence `NoSuchMethodError`. + * + * A `WorkerExecutor` submitted with `classLoaderIsolation` builds its action's classloader from an + * explicit classpath with Gradle's own minimal worker infrastructure as its parent -- **not** this + * plugin's classloader and **not** the buildscript scopes around it. `org.jetbrains.kotlin.library.*` + * then has exactly one source inside the worker, provided the explicit classpath itself is clean: see + * [pluginCodeSource] for how a classpath *derived from the plugin classloader* smuggled the collision + * back in, and what it is built from now. + * + * ### What crosses the boundary, and how + * + * Gradle's `WorkParameters` marshal managed property types (`RegularFileProperty`, + * `ListProperty`) across the isolation, but a `WorkAction` has no return value -- there is no + * property Gradle marshals back out. [KlibScanResult] is therefore written to [Params.resultFile] with + * plain `ObjectOutputStream`/`ObjectInputStream` rather than handed back through the worker API: both + * ends load `DeclarationModel`/`ArtifactCallable` from the *same* class file bytes (this plugin's own + * jar, present on both the coordinator's classloader and the worker's classpath via + * [pluginCodeSource]), so + * Java serialization's structural check (`serialVersionUID`, computed from the members when none is + * declared) matches even though the two ends loaded the class through different `ClassLoader` + * instances -- serialization was never identity-based to begin with. + */ +internal abstract class KlibScanWorkAction : WorkAction { + + internal interface Params : WorkParameters { + val klibFile: RegularFileProperty + val includePrefixes: ListProperty + val resultFile: RegularFileProperty + } + + override fun execute() { + val klib = parameters.klibFile.get().asFile + val includes = parameters.includePrefixes.get() + val result = KlibScanResult( + callables = KlibScanner.scanKlib(klib, includes), + declarations = KlibScanner.scanKlibDeclarations(klib, includes), + ) + ObjectOutputStream(parameters.resultFile.get().asFile.outputStream()).use { it.writeObject(result) } + } +} + +/** Both of [KlibScanner]'s results for one klib, carried across [KlibScanWorkAction]'s isolation + * boundary in one round trip rather than two -- the same "one walk" the JVM producer's own + * [Candidate] already keeps to inside a single process. */ +internal data class KlibScanResult( + val callables: List, + val declarations: List, +) : Serializable + +/** + * This plugin's own jar (or classes directory), and nothing else -- the only entry of the worker + * classpath that cannot be resolved from a repository, because it is what carries [KlibScanner], + * [KlibScanWorkAction] and the [python.multiplatform.gradle.model.DeclarationModel] both ends + * deserialize. + * + * ### This used to be the plugin classloader's `.urLs`, and that never isolated anything + * + * The previous version handed the worker `(KlibScanner::class.java.classLoader as URLClassLoader).urLs`, + * reasoning that a `URLClassLoader`'s own URLs are the child scope only, never the parent's. The + * reasoning is right about `URLClassLoader` and wrong about *which* classloader this is. Printed from + * inside a real external consumer (`plugins { kotlin("multiplatform"); id("...bindings") }`), those 28 + * URLs are: + * + * python-multiplatform-gradle-plugin-3.13.0.jar kotlin-metadata-jvm-2.0.20.jar + * symbol-processing-gradle-plugin-2.3.11.jar asm-9.7.1.jar, asm-tree-9.7.1.jar + * kotlin-gradle-plugin-2.4.20-Beta2.jar kotlin-util-klib-2.4.20-Beta2.jar + * kotlin-util-io-2.4.20-Beta2.jar kotlin-native-utils-2.4.20-Beta2.jar + * ... and the rest of the Kotlin Gradle Plugin + * + * Gradle gives every plugin resolved into the *same* `plugins { }` block one classloader, so KGP's own + * jars are not a parent scope out there -- they are on this very list. `kotlin-util-klib` is precisely + * the jar whose `KotlinLibraryImplKt` does not fit this plugin's `LibraryAbiReader`, so copying that + * list into the worker copied the collision in with it, and + * `:generatePythonArtifactBindings` failed with the same + * `NoSuchMethodError: KotlinLibraryImplKt.createKotlinLibrary$default` the isolation was introduced to + * fix. In this repository it happened to work: `ksp-fixtures` resolves the plugin from an *included + * build*, which does get a scope of its own, so no in-repo fixture can see this. + * + * The worker classpath is therefore built from an explicit coordinate list + * (`DEFAULT_KLIB_WORKER_COORDINATES`, resolved by the consumer's project) plus this one file. Nothing + * a consumer's build happens to put on a classloader can reach it. + */ +private fun pluginCodeSource(): File { + val location = KlibScanWorkAction::class.java.protectionDomain?.codeSource?.location + ?: error( + "python-multiplatform-gradle-plugin: KlibScanWorkAction has no code source location, so " + + "the isolated worker cannot be given the plugin's own classes. See KlibScanWorkAction.kt.", + ) + return File(location.toURI()) +} + +/** + * Runs [KlibScanner] over [klib] inside an isolated worker classloader and returns both of its + * results -- see [KlibScanWorkAction]'s KDoc for why this indirection exists at all. + * + * [readerClasspath] is `DEFAULT_KLIB_WORKER_COORDINATES` resolved by the consumer's own project: the + * klib reader (`kotlin-compiler-embeddable`), `kotlin-metadata-jvm` and ASM, which + * [python.multiplatform.gradle.artifact.ArtifactScanner] needs since [KlibScanner] calls into it, plus + * `kotlin-stdlib` behind them. Together with [pluginCodeSource] that is the *whole* worker classpath -- + * see [pluginCodeSource]'s KDoc for the measurement that shows why nothing may be taken from the + * plugin's classloader, and this build's `compileOnly` comment for why the reader is not a dependency + * of the plugin in the first place. + * + * Passed in rather than resolved here: a `Configuration` is a project-scoped object and this runs + * inside a task action, so the resolution has to have been declared at configuration time. The task's + * `klibReaderClasspath` property is that declaration. + * + * [scratchDirectory] holds the round-trip file only; the caller owns cleanup the same way it owns + * `Task.getTemporaryDir()`; this function does not delete anything, so two calls in the same build + * (jars and stubs) don't race each other's cleanup. + */ +internal fun WorkerExecutor.scanKlibIsolated( + klib: File, + includes: List, + scratchDirectory: File, + readerClasspath: Collection, +): KlibScanResult { + require(readerClasspath.isNotEmpty()) { + "python-multiplatform-gradle-plugin: no klib reader on the worker classpath. " + + "`pythonBindings` resolves ${DEFAULT_KLIB_WORKER_COORDINATES.joinToString()} for this; an empty " + + "classpath would fail inside the worker with NoClassDefFoundError: " + + "org/jetbrains/kotlin/library/abi/LibraryAbiReader." + } + scratchDirectory.mkdirs() + val resultFile = File.createTempFile("klib-scan-", ".ser", scratchDirectory) + val queue = classLoaderIsolation { classpath.from(listOf(pluginCodeSource()) + readerClasspath) } + queue.submit(KlibScanWorkAction::class.java) { + klibFile.set(klib) + includePrefixes.set(includes) + this.resultFile.set(resultFile) + } + queue.await() + return ObjectInputStream(resultFile.inputStream()).use { it.readObject() as KlibScanResult } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanner.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanner.kt new file mode 100644 index 000000000..450c95f28 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KlibScanner.kt @@ -0,0 +1,422 @@ +package python.multiplatform.gradle.artifact + +import org.jetbrains.kotlin.library.abi.AbiClassifierReference +import org.jetbrains.kotlin.library.abi.AbiCompoundName +import org.jetbrains.kotlin.library.abi.AbiFunction +import org.jetbrains.kotlin.library.abi.AbiQualifiedName +import org.jetbrains.kotlin.library.abi.AbiType +import org.jetbrains.kotlin.library.abi.AbiTypeArgument +import org.jetbrains.kotlin.library.abi.AbiTypeNullability +import org.jetbrains.kotlin.library.abi.ExperimentalLibraryAbiReader +import org.jetbrains.kotlin.library.abi.LibraryAbiReader +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import java.io.File + +/** + * `docs/design/ecosystem.md` §5b's second producer, adapted for Kotlin/Native klibs -- ROADMAP §16e. + * + * JVM uses ASM (plus `kotlin-metadata-jvm` for the payload `@Metadata` carries). A klib needs + * neither: `LibraryAbiReader` decodes the ABI protobuf directly into Kotlin-typed declarations -- + * proper qualified names, `isSuspend`, extension receivers, unerased parameter and return types -- + * so there is no facade problem and no mangled-name problem to work around. + * + * ### This walker runs behind a worker classloader boundary, not in the calling task's own classloader + * + * Called directly, in the plugin's own classloader, this throws `NoSuchMethodError: + * KotlinLibraryImplKt.createKotlinLibrary$default(...)` in a real consumer build while passing its own + * unit tests -- and the difference between those two is the whole problem: in the plugin's test JVM + * there is one `org.jetbrains.kotlin.library.*` on the classpath, and in a consumer build there are + * two. The Kotlin Gradle Plugin puts `kotlin-util-klib`/`kotlin-util-io` on the **root project's** + * buildscript scope, which is a *parent* of the plugin classloader, so parent-first delegation serves + * half of `org.jetbrains.kotlin.library.*` from KGP's version and half -- the `.abi` half, which only + * `kotlin-compiler-embeddable` ships -- from this plugin's 2.0.20. + * `python-multiplatform-gradle-plugin/build.gradle.kts` records the `CodeSource` measurement that + * proved it and both attempts at aligning the version that failed before classloader isolation was + * tried. [KlibScanWorkAction] is that isolation: every caller of this object goes through + * [WorkerExecutor.scanKlibIsolated][python.multiplatform.gradle.artifact.scanKlibIsolated] rather than + * calling [scanKlib]/[scanKlibDeclarations] directly, and `:ksp-fixtures:klib-artifact + * :generatePythonArtifactBindings`/`:generatePythonStubs` passing is what confirms the isolation + * actually holds in a consumer build, not only in this file's own unit tests. + * + * **Experimental API usage**, kept entirely inside this file so any break across a Kotlin compiler + * bump is contained here: + * - `LibraryAbiReader.readAbiInfo` + * - `LibraryAbi.topLevelDeclarations.declarations` + * - `AbiFunction` (`qualifiedName`, `isSuspend`, `isConstructor`, `valueParameters`, `returnType`, + * `hasExtensionReceiverParameter`, `contextReceiverParametersCount`) + * - `AbiValueParameter` (`type`, `hasDefaultArg`) + * - `AbiQualifiedName` (`packageName`, `relativeName`, `toString`) + * - `AbiType.Simple` (`classifierReference`, `arguments`, `nullability`) and `AbiTypeNullability` + * - `AbiTypeArgument.TypeProjection`/`.StarProjection` + * - `AbiClassifierReference.ClassReference` (`className`) + * + * ### Two results from one walk, the same as the JVM producer + * + * [scanKlib] returns the bindings, [scanKlibDeclarations] returns one [DeclarationModel] per + * declaration *including the declined ones*, and both are `.map`s over the same [Candidate] list -- + * `docs/design/pyi-generation-design.md` §2.2's "one representation, produced by the scanner, consumed by + * two renderers", which [ArtifactScanner] already satisfies and which this file used not to: it + * dropped a declaration at a `filter` before anything could record that it had been seen. That is why + * everything this walker declines is now declined *with a reason* instead of by disappearing, and why + * klib declarations reach `PythonStubsTask` at all. + * + * ### Overload handling matches the JVM producer, not the JVM producer's floor + * + * A klib's ABI gives exact Kotlin types the same way `@Metadata` does for a jar, so + * [ArtifactScanner.disambiguateOverloads] -- the `name__` scheme -- applies unchanged, called + * here rather than reimplemented. An earlier version of this file dropped every overload group + * outright, reasoning that Python cannot dispatch on argument type; that reasoning is about + * arbitration, not naming, and [ArtifactScanner]'s own KDoc already settles it: the JVM producer used + * to drop for the same reason and stopped once metadata gave it a name to disambiguate by. Doing the + * opposite here would mean the same `padding`-shaped overload set spells differently depending on + * which producer found it, which the two producers landing in one + * `python.multiplatform.generated.artifacts.ArtifactTable` cannot afford. + * + * ### What a klib can and cannot fill in the model + * + * | [DeclarationModel] field | klib | how | + * |---|---|---| + * | `simpleName`, `owner` | yes | `AbiQualifiedName`'s two halves, which is *better* than the JVM producer's: no facade to unwrap | + * | `ownerIsClass` | yes, always `false` | only `topLevelDeclarations` is walked; a class's members live under `AbiClass` | + * | `isSuspend`, `parameters[].declaresDefault` | yes | declared on `AbiFunction`/`AbiValueParameter` | + * | `returnType`, `parameters[].type` incl. nullability and type arguments | yes | `AbiType.Simple`'s `nullability` and `arguments` | + * | `parameters[].name` | **no** | 2.0.20's `AbiValueParameter` declares exactly five members (`type`, `isVararg`, `hasDefaultArg`, `isNoinline`, `isCrossinline`) and none is a name -- checked with `javap`. `parameterNamesKnown = false` is exactly the case §3.2 already defines for a Java jar built without `-parameters` | + * | `isComposable` | yes | `AbiAnnotatedEntity.hasAnnotation`; see [COMPOSABLE] for what is and is not proved about it here | + * | `receiver` | **no** -- and the declaration is declined | see below | + * | value-class identity (`KotlinTypeModel.valueClass`) | **no** -- and the declaration is declined | see below | + * + * The last two rows are the point: they are exactly the two things this walker already refuses to + * bind, so nothing here is *bound* on a field it had to leave empty. A declaration whose shape klib + * cannot describe is declined with the reason, not admitted with a hole in it. + * + * ### Value classes are declined, not unwrapped -- for now + * + * [klibBoundaryTypeOf] recognises only `kotlin`'s own primitives, `String`, `ByteArray` and `Unit`. + * A value class parameter or return (`androidx.compose.ui.unit.Dp`, `kotlinx.coroutines.CoroutineName`) + * therefore maps to `null` and the whole declaration is declined -- never partially bound with a raw + * underlying value standing in for the wrapper, which is what would let `16.0` silently decode as + * `Dp.Unspecified` the way a naive JVM-descriptor reader would. + * + * This is the same *safety* property the JVM producer reached with `resolveKotlinType`, but not the + * same *reach*: `resolveKotlinType` goes on to construct and unwrap a value class when its + * constructor and accessor are visible, using `@Metadata`'s payload to know the underlying type and + * visibility. `AbiClass` (a klib declaration alongside `AbiFunction`) carries `isValue`, but not a + * value class's underlying type or its constructor's visibility -- that would mean resolving the + * classifier reference to its own `AbiClass`, finding its constructor among its declarations, and + * reading the *visibility* off it, none of which `AbiDeclarationWithModality` exposes (it carries + * modality -- `open`/`final`/`abstract` -- not visibility). Doing that safely is a second walker, + * not an extension of this one, and is left for the next pass rather than guessed at here. The model + * says so too: `KotlinTypeModel.valueClass` stays `null` for every klib type rather than claiming a + * `ValueClassModel` this walker cannot fill. + * + * ### Extension functions are declined, not bound -- a version floor, verified rather than assumed + * + * The JVM producer binds an extension by reading its receiver's *type* off `KmFunction + * .receiverParameterType` (`kotlin-metadata-jvm`). The klib equivalent would be + * `AbiValueParameterKind.EXTENSION_RECEIVER` surfacing the receiver inside `AbiFunction + * .valueParameters`, typed the same as any other parameter -- and that shape exists, but only from + * `kotlin-compiler-embeddable` **2.2.20** onward. This build pins **2.0.20** + * (`python-multiplatform-gradle-plugin/build.gradle.kts`), whose `AbiValueParameter` carries no + * `kind` at all and whose `AbiFunction` exposes only `hasExtensionReceiverParameter: Boolean` -- + * *that* one exists, but nothing behind it gives the receiver's type. Checked by decompiling both + * jars' `AbiFunction`/`AbiValueParameter` class files with `javap`, not inferred from a changelog: + * `2.0.20`'s `AbiFunction.class` is 1713 bytes against `2.2.20`'s 2417, and the extra bytes are + * exactly `getHasExtensionReceiverParameter$annotations`, `getContextReceiverParametersCount` and + * the `kind`-bearing `AbiValueParameterKind` enum, none of which `2.0.20` has. A function with + * `hasExtensionReceiverParameter == true` is therefore declined outright here, the same way the + * pre-metadata JVM producer declined every extension before `kotlin-metadata-jvm` was added -- + * binding one with the wrong arity (mistaking the receiver for absent, or misreading argument N for + * N+1) would be worse than not binding it. Raising the pin to reach 2.2.20's shape is a real option + * ROADMAP §16e already names as a cost to weigh (a newer, still-`@ExperimentalLibraryAbiReader` + * compiler artefact on the plugin classpath) rather than one to spend inside this pass. + */ +@OptIn(ExperimentalLibraryAbiReader::class) +internal object KlibScanner { + + /** + * `docs/archive/pyi-generation-pythonic-stubs.md` §3.6's name rule (the Pythonic naming, now pythonx-compose's) depends on this: a composable keeps its + * PascalCase spelling where every other function becomes snake_case. + * + * Read here for the same reason [ArtifactScanner.isComposable] reads it off ASM -- carried in the + * model, never *bound* (no composable can be, its `$composer`/`$changed` parameters are not + * boundary types). **Not covered by a fixture:** neither klib this module's tests can reach + * (`kotlin-stdlib`, `kotlinx-coroutines-core`) contains a composable, so what is proved here is + * that reading it is free and cannot mis-bind, not that it returns `true` on a real Compose klib. + */ + private val COMPOSABLE = AbiQualifiedName(AbiCompoundName("androidx.compose.runtime"), AbiCompoundName("Composable")) + + /** + * The marker that put a wrong declaration into a real consumer build before this walker had a + * fixture that actually *compiled* the generated fragment: `kotlinx-coroutines-core`'s + * `checkIndexOverflow` is Kotlin-`internal`, kept in `LibraryAbiReader`'s ABI dump only because + * `@PublishedApi` makes it binary-visible to inline call sites in other modules. That is a + * bytecode-visibility fact, not a source-visibility one: [candidateOrNull]'s generated call + * expression is ordinary Kotlin source in the *consumer's* module, and the Kotlin compiler still + * enforces `internal` there, which `WalkedKlibArtifactPythonImportTest`'s KDoc records finding + * the hard way -- `compileKotlinAndroidNativeArm64` failing with "it is internal in file" the + * first time this walker's output was ever actually compiled, not merely unit-tested. A plain + * (non-`@PublishedApi`) `internal` declaration never reaches here at all: `readAbiInfo`'s default + * filtering already excludes it from `topLevelDeclarations`, which is *why* the one exception + * `@PublishedApi` carves out is the one this walker has to check for by hand. + */ + private val PUBLISHED_API = AbiQualifiedName(AbiCompoundName("kotlin"), AbiCompoundName("PublishedApi")) + + /** Every binding this klib offers, sorted by name -- the counterpart of [ArtifactScanner.scanJar]. */ + fun scanKlib(klib: File, includePrefixes: List): List = + scan(klib, includePrefixes).mapNotNull { it.callable }.sortedBy { it.name } + + /** The same walk with the other half of its result kept -- the counterpart of + * [ArtifactScanner.scanDeclarations], sorted the same way so the two producers' models can be + * concatenated without either one's order depending on which walked first. */ + fun scanKlibDeclarations(klib: File, includePrefixes: List): List = + scan(klib, includePrefixes) + .map { it.declaration } + .sortedWith(compareBy({ it.owner }, { it.simpleName }, { it.bindingName ?: "" })) + + private fun scan(klib: File, includePrefixes: List): List { + val abi = try { + LibraryAbiReader.readAbiInfo(klib) + } catch (e: Exception) { + return emptyList() + } + + val candidates = abi.topLevelDeclarations.declarations + .filterIsInstance() + .filter { matchesInclude(it.qualifiedName, includePrefixes) } + // Not a *top-level* shape at all: a constructor belongs to an `AbiClass` and needs a + // receiver handle to be worth anything. Filtered rather than declined for the same reason + // `ArtifactScanner` never builds a model for ``. + .filter { !it.isConstructor } + .mapNotNull { candidateOrNull(it) } + + // Same rule, same implementation as the JVM producer -- see this object's KDoc. + return ArtifactScanner.disambiguateOverloads(candidates) + } + + /** + * A namespace match against the declaration's *package*, not its full qualified name. + * + * `AbiQualifiedName.toString()` joins `packageName` and `relativeName` with `/` + * (`kotlinx.coroutines.channels/broadcast`), not `.`. Matching `qualifiedName.toString()` against + * an include prefix the way the JVM producer matches a dotted class name silently misses every + * declaration whose package *is* the prefix rather than a sub-package of it: `kotlinx.coroutines` + * (the package) contributes 37 top-level declarations in `kotlinx-coroutines-core`, none of + * which `"kotlinx.coroutines".startsWith` logic on the full string would have matched, because + * the character after the prefix is `/`, not `.`. `packageName.toString()` has no such + * `/`-suffix problem: it is exactly the dotted package, so the JVM producer's own namespace rule + * (`equal, or a dot-bounded prefix`) applies unchanged. + */ + private fun matchesInclude(qualifiedName: AbiQualifiedName, includePrefixes: List): Boolean { + if (includePrefixes.isEmpty()) return true + val pkg = qualifiedName.packageName.toString() + return includePrefixes.any { pkg == it || pkg.startsWith("$it.") } + } + + /** + * One declaration's model and, when the boundary can carry it, its binding. + * + * `null` -- the declaration vanishing entirely rather than being declined -- only when its + * signature has no name a stub could write: a type *parameter* (`fun f(t: T)`), a dynamic or + * error type. That is [ArtifactScanner.declarationModelOf]'s own rule and + * `docs/archive/pyi-generation-pythonic-stubs.md` §3.1's last row: `BindingPolicy` rejects generic declarations + * and stubbing what cannot even be spelled would be a lie, so there is nothing to record. + * + * `internal`, not `private`: no real klib available to this module's tests binds a declaration + * that also declares a default (every one found in `kotlin-stdlib`/`kotlinx-coroutines-core` is + * declined first, for an unrelated reason -- see `KlibScannerTest`'s + * `declaresDefaultIsFalseForABoundDeclarationEvenWhenTheAbiDeclaresADefault`). Proving the + * `declaresDefault`/`paramHasDefault` pairing therefore has to construct the `AbiFunction` by hand + * rather than find one, which means calling this directly with a test double -- `scanKlib`/ + * `scanKlibDeclarations` only ever call `LibraryAbiReader.readAbiInfo` on a real file and have no + * seam to inject one. + */ + internal fun candidateOrNull(function: AbiFunction): Candidate? { + val model = declarationModelOf(function) ?: return null + val declined = { reason: String -> Candidate(null, model.copy(declineReason = reason)) } + + // A `suspend` function's Kotlin/Native shape is not one this walker's generated call + // expression can supply (it needs a `Continuation`), the same reason the JVM producer + // declines it -- but a klib says so directly rather than requiring the JVM producer's + // inference from a transformed descriptor. + if (function.isSuspend) return declined("suspend") + // See PUBLISHED_API's KDoc: `@PublishedApi internal` is binary-visible but not + // source-visible, and the generated call expression is source in the consumer's own module. + if (function.hasAnnotation(PUBLISHED_API)) { + return declined("@PublishedApi internal -- binary-visible for inlining, not part of the module's public Kotlin API") + } + // See this object's KDoc, "Extension functions are declined, not bound": the pinned + // compiler-embeddable version exposes the flag but not the receiver's type, which is also why + // `model.receiver` is left `null` rather than filled with a guess. + if (function.hasExtensionReceiverParameter) { + return declined("an extension receiver whose type kotlin-compiler-embeddable 2.0.20's klib ABI does not expose") + } + // A context receiver has no call-site syntax this walker generates and, on this pinned + // version, no type either (`AbiFunction.contextReceiverParametersCount` is a count, not a + // list) -- declined rather than silently mis-arity'd. + if (function.contextReceiverParametersCount > 0) { + return declined("${function.contextReceiverParametersCount} context receiver(s), whose types this klib ABI does not expose") + } + + val returnType = klibBoundaryTypeOf(model.returnType) + ?: return declined("no boundary type for return ${model.returnType.render()}") + val boundaryTypes = model.parameters.map { parameter -> + klibBoundaryTypeOf(parameter.type) + ?: return declined("no boundary type for parameter ${parameter.type.render()}") + } + if (boundaryTypes.any { it.isReturnOnly }) { + return declined("a parameter of a return-only boundary type") + } + + val qualifiedName = function.qualifiedName.dotted() + val argumentExpressions = boundaryTypes.mapIndexed { index, type -> type.read("args[$index]") } + val body = returnType.wrapReturn("$qualifiedName(${argumentExpressions.joinToString(", ")})") + val arity = boundaryTypes.size + + val callable = ArtifactCallable( + name = qualifiedName, + arity = arity, + paramTags = boundaryTypes.map { it.tag }, + returnTag = returnType.tag, + lambdaBody = if (arity == 0) "{ $body }" else "{ args -> $body }", + // A klib's `AbiValueParameter` carries no name, only a `type` -- unlike + // `kotlin-metadata-jvm`'s `KmValueParameter`, which does. `paramNames` therefore stays + // empty, which is what `ArtifactCallable.paramNames`'s KDoc already means by "not + // supplied"; `DeclarationModel.parameterNamesKnown` says the same thing to the stub side. + paramTypeNames = model.parameters.map { it.type.qualifiedName }, + returnTypeName = model.returnType.qualifiedName, + // Deliberately all `false`, not `model.parameters.map { it.declaresDefault }`. + // `ArtifactCallable.paramHasDefault`'s contract (see its KDoc) is a statement about + // [lambdaBody] -- exactly the slots the generated call may omit -- and never about the + // declaration. `lambdaBody` above passes every argument unconditionally; this scanner has + // no `ArtifactScanner.applyDefaultOmission` pass to earn a narrower answer, because a + // klib walk runs on its own in [KlibScanWorkAction] and never sees the whole-artifact + // overload picture that pass needs. Reporting `declaresDefault` here was the old, now- + // wrong pairing: `pythonx._bind` would have filled a slot with `null` that this body has + // no branch for, and the generated call would have cast a `null` where the declaration + // requires a value. `ArtifactRendering.omittableSlotsOf` already guarded against exactly + // that by reading whether the body branches on the sentinel at all -- true for every klib + // entry, since none of them do -- so this is the same answer moved to where it belongs, + // and that render-time guard stays as the second line of defence for any producer that + // repeats this mistake. + paramHasDefault = List(arity) { false }, + ) + return Candidate( + callable = callable, + declaration = model.copy( + bindingName = qualifiedName, + returnBoundaryTag = returnType.tag, + parameters = model.parameters.mapIndexed { index, parameter -> + parameter.copy( + boundaryTag = boundaryTypes[index].tag, + // Mirrors `paramHasDefault` just above, and for the identical reason: this is + // `ArtifactScanner.applyDefaultOmission`'s rewrite of `declaration.parameters + // [].declaresDefault` (see that function's KDoc), done here instead because a + // klib walk has no separate omission pass to do it in a second step. Left at + // `parameter.declaresDefault` -- the raw `AbiValueParameter.hasDefaultArg` -- + // a bound declaration would describe the Kotlin source's defaults rather than + // the generated binding's, and `PyiRendering`'s `if (parameter.declaresDefault) + // " = ..."` would advertise an omission `lambdaBody` never implements: the + // `.pyi` would type-check a call the runtime binding then rejects. + // `KlibScannerTest.declaresDefaultIsFalseForABoundDeclarationEvenWhenTheAbi + // DeclaresADefault` pins this; `boundDeclarationWithADefaultDoesNotOccurIn + // AvailableRealKlibs` records why that test constructs its own `AbiFunction` + // instead of finding one. + declaresDefault = false, + ) + }, + ), + ) + } + + /** The declared shape of one klib function, independent of whether the boundary can carry it -- + * [ArtifactScanner.declarationModelOf]'s counterpart, and `null` under the same condition. */ + private fun declarationModelOf(function: AbiFunction): DeclarationModel? { + val parameters = function.valueParameters.map { parameter -> + DeclaredParameter( + name = null, + type = typeModelOf(parameter.type) ?: return null, + declaresDefault = parameter.hasDefaultArg, + ) + } + return DeclarationModel( + simpleName = function.qualifiedName.relativeName.toString(), + owner = function.qualifiedName.packageName.toString(), + // Only `topLevelDeclarations` is walked, so every declaration reaching here is package-level. + ownerIsClass = false, + // Never filled: see this object's KDoc. A function that *has* one is declined just below, + // so no bound declaration is ever missing a receiver it should have had. + receiver = null, + parameters = parameters, + returnType = typeModelOf(function.returnType) ?: return null, + parameterNamesKnown = false, + isComposable = function.hasAnnotation(COMPOSABLE), + isSuspend = function.isSuspend, + ) + } + + /** + * A klib type as [KotlinTypeModel] spells it, or `null` when the classifier has no Kotlin name to + * write -- a type-parameter reference, a dynamic or error type. + * + * `valueClass` is always `null`: see this object's KDoc for why a klib cannot answer that + * question on this compiler version, and why leaving it empty is the honest answer rather than a + * gap. Nullability and type arguments, in contrast, *are* on `AbiType.Simple` and are read -- + * they are what lets [klibBoundaryTypeOf] refuse a nullable primitive. + */ + private fun typeModelOf(type: AbiType?): KotlinTypeModel? { + // A `null` return type is how the ABI spells `Unit` for some declarations; anything else + // unrecognised is refused rather than guessed. + if (type == null) return KotlinTypeModel("kotlin.Unit") + if (type !is AbiType.Simple) return null + val reference = type.classifierReference + if (reference !is AbiClassifierReference.ClassReference) return null + val arguments = type.arguments.map { argument -> + when (argument) { + is AbiTypeArgument.StarProjection -> null + is AbiTypeArgument.TypeProjection -> typeModelOf(argument.type) ?: return null + else -> return null + } + } + return KotlinTypeModel( + qualifiedName = reference.className.dotted(), + isNullable = type.nullability == AbiTypeNullability.MARKED_NULLABLE, + arguments = arguments, + ) + } + + /** `packageName/relativeName` is `AbiQualifiedName`'s own spelling; Kotlin's is all dots. */ + private fun AbiQualifiedName.dotted(): String = toString().replace('/', '.') + + private fun KotlinTypeModel.render(): String = qualifiedName + if (isNullable) "?" else "" + + /** + * Maps precise klib types to [BoundaryType]. See this object's KDoc for why an unrecognised type + * -- a value class included -- maps to `null` and declines the whole declaration rather than + * being read as its erased underlying primitive. + * + * A **nullable** one maps to `null` too, which is `resolveKotlinType`'s rule for the JVM producer + * arrived at independently and for the same reason: `TypeTag.INT` carries a `Long` and the + * generated read narrows it (`(args[0] as Long).toInt()`), so a Python `None` for an `Int?` would + * throw inside the cast instead of reaching the declaration. The JVM producer can fall through to + * an object handle in that case; this walker has no object boundary at all yet, so it declines. + * Type *arguments* are ignored rather than refused, because no type this admits has any. + */ + private fun klibBoundaryTypeOf(type: KotlinTypeModel): BoundaryType? { + if (type.isNullable) return null + return when (type.qualifiedName) { + "kotlin.Boolean" -> BoundaryType("BOOLEAN", "(%s as Boolean)", "(%s)") + "kotlin.Byte" -> BoundaryType("INT", "(%s as Long).toByte()", "(%s).toLong()") + "kotlin.Short" -> BoundaryType("INT", "(%s as Long).toShort()", "(%s).toLong()") + "kotlin.Int" -> BoundaryType("INT", "(%s as Long).toInt()", "(%s).toLong()") + "kotlin.Long" -> BoundaryType("INT", "(%s as Long)", "(%s)") + "kotlin.Float" -> BoundaryType("FLOAT", "(%s as Double).toFloat()", "(%s).toDouble()") + "kotlin.Double" -> BoundaryType("FLOAT", "(%s as Double)", "(%s)") + "kotlin.String" -> BoundaryType("STRING", "(%s as String)", "(%s)") + "kotlin.ByteArray" -> BoundaryType("BYTES", "(%s as ByteArray)", "(%s)") + "kotlin.Unit" -> BoundaryType("UNIT", "(%s as Unit)", "(%s)", isReturnOnly = true) + else -> null + } + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt new file mode 100644 index 000000000..961fcf371 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt @@ -0,0 +1,873 @@ +package python.multiplatform.gradle.artifact + +import kotlin.metadata.KmClass +import kotlin.metadata.KmClassifier +import kotlin.metadata.KmConstructor +import kotlin.metadata.KmDeclarationContainer +import kotlin.metadata.KmFunction +import kotlin.metadata.KmType +import kotlin.metadata.KmTypeParameter +import kotlin.metadata.KmTypeProjection +import kotlin.metadata.Visibility +import kotlin.metadata.declaresDefaultValue +import kotlin.metadata.isDefinitelyNonNull +import kotlin.metadata.isNullable +import kotlin.metadata.isReified +import kotlin.metadata.isSuspend +import kotlin.metadata.isValue +import kotlin.metadata.jvm.JvmMethodSignature +import kotlin.metadata.jvm.KotlinClassMetadata +import kotlin.metadata.jvm.signature +import kotlin.metadata.visibility +import org.objectweb.asm.tree.AnnotationNode +import org.objectweb.asm.tree.ClassNode +import java.io.File +import java.util.jar.JarFile + +/** + * The part of the walker that reads `@Metadata`'s `d1`/`d2` payload -- the facts + * [ArtifactScanner]'s KDoc names as ASM's limit: a multi-file facade's part classes, an extension + * function's receiver, a value class's underlying type and public accessor, `suspend`. + * + * Split from `ArtifactScanner` because it has its own dependency (`kotlin-metadata-jvm`, see the + * plugin's `build.gradle.kts`) and its own unit of correctness: given a class's bytes, what did the + * compiler actually declare, independent of what the walker chooses to bind. + */ + +private const val KOTLIN_METADATA_DESCRIPTOR = "Lkotlin/Metadata;" + +/** + * Every jar (or, for this module's own tests, every directory of `.class` files) a build resolved, + * indexed once so a classifier can be looked up regardless of which root it actually lives in. + * + * Why this has to span more than the one jar being walked: `androidx.compose.foundation.layout`'s + * `Modifier.padding(Dp)` declares `Dp` as a parameter, and `Dp` is compiled into + * `androidx.compose.ui.unit`, a different artefact. A value class's underlying type and accessor + * cannot be read without finding the class that declares them, and single-jar resolution would + * decline every cross-artefact value-class parameter -- which is most of Compose's public surface. + */ +internal class ArtifactClasspath(roots: List) { + + private val locationsByBinaryName: Map by lazy { + val map = LinkedHashMap() + roots.forEach { root -> + when { + root.isDirectory -> root.walkTopDown() + .filter { it.isFile && it.name.endsWith(".class") } + .forEach { file -> + map.putIfAbsent(file.relativeTo(root).invariantSeparatorsPath.removeSuffix(".class"), root) + } + root.isFile && root.name.endsWith(".jar") -> runCatching { + JarFile(root).use { jarFile -> + jarFile.entries().asSequence() + .filter { it.name.endsWith(".class") } + .forEach { entry -> map.putIfAbsent(entry.name.removeSuffix(".class"), root) } + } + } + } + } + map + } + + private val nodeCache = mutableMapOf() + + /** @param binaryName e.g. `androidx/compose/ui/unit/Dp` -- no `.class` suffix. */ + fun classNode(binaryName: String): ClassNode? = nodeCache.getOrPut(binaryName) { + val root = locationsByBinaryName[binaryName] ?: return@getOrPut null + val bytes = readBytes(root, binaryName) ?: return@getOrPut null + readClassNodeOrNull(bytes) + } + + private fun readBytes(root: File, binaryName: String): ByteArray? = if (root.isDirectory) { + root.resolve("$binaryName.class").takeIf { it.isFile }?.readBytes() + } else { + runCatching { + JarFile(root).use { jarFile -> + jarFile.getJarEntry("$binaryName.class")?.let { entry -> jarFile.getInputStream(entry).use { it.readBytes() } } + } + }.getOrNull() + } +} + +/** + * A class file the running ASM cannot parse is skipped, not fatal -- see [ArtifactScanner]'s own + * copy of this reasoning. Shared because [ArtifactClasspath] needs it for classes outside the jar + * currently being walked. + */ +internal fun readClassNodeOrNull(bytes: ByteArray): ClassNode? = try { + ClassNode().also { + org.objectweb.asm.ClassReader(bytes).accept( + it, + org.objectweb.asm.ClassReader.SKIP_CODE or org.objectweb.asm.ClassReader.SKIP_DEBUG or org.objectweb.asm.ClassReader.SKIP_FRAMES, + ) + } +} catch (_: IllegalArgumentException) { + null +} + +/** `null` when the class carries no `kotlin.Metadata` at all (a Java class), or when the payload + * cannot be decoded (a metadata version this library does not understand -- declined, not fatal, + * the same policy [readClassNodeOrNull] applies to bytecode ASM cannot parse). */ +internal fun kotlinClassMetadataOf(node: ClassNode): KotlinClassMetadata? { + val annotation = node.visibleAnnotations?.firstOrNull { it.desc == KOTLIN_METADATA_DESCRIPTOR } ?: return null + val header = buildMetadataHeader(annotation) ?: return null + return try { + KotlinClassMetadata.readLenient(header) + } catch (_: Exception) { + null + } +} + +/** + * Reconstructs a real `kotlin.Metadata` annotation instance from ASM's flat name/value view of it, + * so `KotlinClassMetadata.readLenient` -- which wants the annotation, not its fields -- can decode + * it. `kotlin.Metadata` is `RUNTIME`-retained (see [ArtifactScanner]'s KDoc), so every field ASM + * hands back is exactly what the compiler wrote. + */ +private fun buildMetadataHeader(annotation: AnnotationNode): kotlin.Metadata? { + val values = annotation.values ?: return null + var kind: Int? = null + var metadataVersion: IntArray? = null + var data1: Array? = null + var data2: Array? = null + var extraString: String? = null + var packageName: String? = null + var extraInt: Int? = null + var i = 0 + while (i < values.size - 1) { + when (values[i] as? String) { + "k" -> kind = values[i + 1] as? Int + "mv" -> metadataVersion = (values[i + 1] as? List<*>)?.filterIsInstance()?.toIntArray() + "d1" -> data1 = (values[i + 1] as? List<*>)?.filterIsInstance()?.toTypedArray() + "d2" -> data2 = (values[i + 1] as? List<*>)?.filterIsInstance()?.toTypedArray() + "xs" -> extraString = values[i + 1] as? String + "pn" -> packageName = values[i + 1] as? String + "xi" -> extraInt = values[i + 1] as? Int + } + i += 2 + } + return kotlin.metadata.jvm.Metadata( + kind = kind, + metadataVersion = metadataVersion, + data1 = data1, + data2 = data2, + extraString = extraString, + packageName = packageName, + extraInt = extraInt, + ) +} + +/** Which side of the boundary a Kotlin type is being resolved for -- a value class needs a public + * constructor to be read as a parameter, and a public accessor besides to be wrapped as a return. */ +internal enum class BoundaryDirection { PARAMETER, RETURN } + +/** + * What a jar says about one `@JvmInline value class`: its own qualified name, its underlying type + * (recursion base case: `kotlinPrimitiveBoundaryTypeOf` bottoms out, or another value class does), + * and whether the two operations this walker needs -- construct one, read its wrapped value back + * out -- are even public. + * + * `kotlin.time.Duration` is the case that motivates [propertyIsPublic] and [constructorIsPublic] + * both existing rather than one boolean: its constructor is `internal`, so nothing outside the + * stdlib module can ever construct one, in *either* direction. See + * `ValueClassFixtures.kt`'s `Meters` for the shape that has both public. + */ +internal data class ValueClassInfo( + val qualifiedName: String, + val underlyingType: KmType, + val constructorIsPublic: Boolean, + val propertyName: String?, + val propertyIsPublic: Boolean, +) + +internal fun ArtifactClasspath.valueClassInfo(binaryName: String): ValueClassInfo? { + val node = classNode(binaryName) ?: return null + val metadata = kotlinClassMetadataOf(node) as? KotlinClassMetadata.Class ?: return null + val kmClass = metadata.kmClass + if (!kmClass.isValue) return null + val underlyingType = kmClass.inlineClassUnderlyingType ?: return null + val propertyName = kmClass.inlineClassUnderlyingPropertyName + val constructorIsPublic = kmClass.constructors.any { it.visibility == Visibility.PUBLIC && it.valueParameters.size == 1 } + val propertyIsPublic = propertyName != null && + kmClass.properties.any { it.name == propertyName && it.visibility == Visibility.PUBLIC } + return ValueClassInfo( + qualifiedName = binaryName.replace('/', '.'), + underlyingType = underlyingType, + constructorIsPublic = constructorIsPublic, + propertyName = propertyName, + propertyIsPublic = propertyIsPublic, + ) +} + +/** + * A [BoundaryType] together with **what the boundary actually carries** for it. + * + * The two are not the same statement and only one of them can be read off a [BoundaryType]. A `Color` + * parameter's boundary type is a chain -- `Color(x as kotlin.ULong)` -- whose *tag* is `OBJECT` and + * whose read expression is Kotlin source. Generated Kotlin needs the second; a generated **thunk** + * (`ComposableThunks.kt`) needs the first, because it has to turn the object the handle table hands + * back into the erased JVM value the callee's descriptor declares, and to do that it has to know + * which class that object is an instance of. + * + * [carrier] is the classifier [resolveKotlinBoundary]'s descent **stopped** at, in the same + * `/`-separated spelling `KmClassifier.Class` uses, or `null` when the descent bottomed out in a + * Kotlin primitive (the value then arrives boxed as the width `TypeTag` promises -- `Long`, `Double`, + * `Boolean`, `String`, `ByteArray` -- which needs no class name to unbox). + * + * Deliberately produced by the *same* descent rather than by a second function that reproduces it: + * a carrier that disagreed with the tag would be a thunk casting to a class the boundary never sends. + */ +internal class ResolvedBoundary(val boundary: BoundaryType, val carrier: String?) + +/** + * The Kotlin-type half of [boundaryTypeOf]'s policy: a declaration is bound only if every type in + * its signature resolves here, and this is the whole of what "resolves" means -- a primitive Kotlin + * type, or a value class whose relevant operation ([direction]) is public, unwrapped one level + * (recursively, in case that value class wraps another). + * + * Deliberately not "wraps [boundaryTypeOf]": that function keys off a JVM descriptor, which is + * exactly the erased view a value class parameter lies about (see [ArtifactScanner]'s KDoc). This + * keys off the Kotlin type metadata actually declares. + */ +internal fun resolveKotlinType(type: KmType, classpath: ArtifactClasspath, direction: BoundaryDirection): BoundaryType? = + resolveKotlinBoundary(type, classpath, direction)?.boundary + +/** [resolveKotlinType], keeping the carrier the descent stopped at. See [ResolvedBoundary]. */ +internal fun resolveKotlinBoundary( + type: KmType, + classpath: ArtifactClasspath, + direction: BoundaryDirection, +): ResolvedBoundary? { + val classifier = type.classifier as? KmClassifier.Class ?: return null + // Issue #73: a declared `kotlin.CharSequence` **result** crosses as a Python `str`. Kotlin's own + // `toString()` of a `CharSequence` is its characters (`TextFieldState.text` is a + // `TextFieldCharSequence`, a `StringBuilder` is one too), so the generated body converts and the + // boundary carries the `STRING` it already knows. Return only: a `CharSequence` *parameter* stays + // declined, because nothing has asked for it and `kotlin/CharSequence` has no class file to cast + // an object handle to. + if (classifier.name == KOTLIN_CHAR_SEQUENCE) { + if (direction != BoundaryDirection.RETURN) return null + return ResolvedBoundary(if (type.isNullable) NULLABLE_CHAR_SEQUENCE_RESULT else CHAR_SEQUENCE_RESULT, carrier = null) + } + // A nullable *primitive* or value class stays declined. `TypeTag.INT` carries a `Long` and the + // read narrows it (`(args[0] as Long).toInt()`), so a `null` arriving for an `Int?` would throw + // inside the cast rather than reach the declaration -- and a nullable value class boxes, which + // changes what the JVM method takes. A nullable *object* has neither problem: the handle is + // already nullable at the boundary (`UpcallTrampoline.toKotlin` maps Python `None` to `null`) + // and the cast simply carries the `?`. + if (!type.isNullable) { + kotlinPrimitiveBoundaryTypeOf(classifier.name)?.let { return ResolvedBoundary(it, carrier = null) } + } else { + nullablePrimitiveBoundaryTypeOf(classifier.name)?.let { return ResolvedBoundary(it, carrier = null) } + if (kotlinPrimitiveBoundaryTypeOf(classifier.name) != null) return null + // `kotlin.Any?` -- what an unbounded type parameter is read as (issue #38, see + // [substituteAnyFor]), and the one built-in with no class file that is nonetheless a + // complete answer: anything crosses as it. A Kotlin object arrives as the instance its + // handle names, a Python object as a `PyObject` (`UpcallTrampoline.toKotlinObject`), and + // `None` as `null`. Nullable only: a non-null `kotlin.Any` stays declined, because nothing + // here has asked for it and `None` would then be a value the declaration refuses. + if (classifier.name == KOTLIN_ANY) return ResolvedBoundary(NULLABLE_ANY_BOUNDARY, carrier = null) + } + + val info = if (type.isNullable) null else classpath.valueClassInfo(classifier.name) + if (info != null) { + val underlying = resolveKotlinBoundary(info.underlyingType, classpath, direction) + val usable = when (direction) { + BoundaryDirection.PARAMETER -> info.constructorIsPublic + BoundaryDirection.RETURN -> info.propertyIsPublic && info.propertyName != null + } + // Falls through to the object handle below when the wrapper cannot be opened from outside + // its module rather than declining outright: `kotlin.time.Duration` and + // `androidx.compose.ui.unit.TextUnit` still cannot be *built from a raw number* -- which is + // the whole of what an `internal` constructor forbids and what + // `docs/design/kotlin-extensions-in-python.md` §4.4 insists on -- but an instance that came out of + // Kotlin can still be carried back into Kotlin, which is what a handle is for. + if (underlying != null && usable) { + return ResolvedBoundary( + valueClassBoundaryType(info.qualifiedName, underlying.boundary, info.propertyName), + // The wrapper is opened, so what crosses is whatever the *underlying* type crosses + // as: `Color` is unwrapped to its `ULong`, and a `ULong` is what the handle holds. + carrier = underlying.carrier, + ) + } + } + return objectBoundaryTypeOrNull(type, classpath)?.let { ResolvedBoundary(it, carrier = classifier.name) } +} + +/** + * The descriptor `unbox-impl` returns on a `@JvmInline value class`'s **box** class, or `null` when + * this classifier has no such method -- which is every class that is not one. + * + * Read off the class file rather than derived from [ValueClassInfo.underlyingType], because the two + * can differ by a level of erasure that only the bytecode settles: `Color` wraps a `ULong`, `ULong` + * wraps a `Long`, and `Color.unbox-impl()` returns **`J`** -- the fully erased representation, not + * `Lkotlin/ULong;`. A thunk that predicted the intermediate would emit an `INVOKEVIRTUAL` whose + * return type does not match the slot it feeds, and the JVM's verifier would reject the generated + * class at load time with nothing said at build time. + */ +internal fun ArtifactClasspath.valueClassUnboxDescriptor(kotlinInternalName: String): String? { + val node = classNode(kotlinInternalName.replace('.', '$')) ?: return null + val method = node.methods?.firstOrNull { + it.name == VALUE_CLASS_UNBOX_METHOD && + it.desc.startsWith("()") && + (it.access and org.objectweb.asm.Opcodes.ACC_PUBLIC) != 0 && + (it.access and org.objectweb.asm.Opcodes.ACC_STATIC) == 0 + } ?: return null + return method.desc.substringAfter(')') +} + +/** The accessor `kotlinc` emits on every `@JvmInline value class` box. Not a Kotlin name and never + * spelled in generated source -- only in a generated `INVOKEVIRTUAL`, which is resolved by the JVM's + * own linker exactly as `kotlinc`'s would be (`ComposableThunks.kt`'s KDoc, "why this is not looking + * a method up by name"). */ +internal const val VALUE_CLASS_UNBOX_METHOD = "unbox-impl" + +/** + * Every public class and interface [kotlinInternalName] **is a**, nearest first, `java.lang.Object` + * excluded. + * + * ### Why the walker answers this and `pythonx` cannot + * + * `docs/design/kotlin-extensions-in-python.md`'s coercion compares an owned value's declared type name with + * the slot's for equality, so `BitmapPainter` is refused where a `Painter` is wanted. Python holds + * *names*: a handle is an integer, `TypeTag.OBJECT` says nothing about what it points at, and the + * five targets have no shared reflection to ask (agent-rules §12 -- dynamic binding is a retired + * option, and Kotlin/Native has no `KClass.isInstance` for a name). The walker, on the other hand, + * has the class files open already: `ClassNode.superName` and `ClassNode.interfaces` are the answer, + * and reading them costs one already-cached [classNode] per link. + * + * ### Which direction is carried, and why it is the small one + * + * The **value's** ancestry, not the slot's subtypes. A `Painter` slot accepts every `Painter` there + * will ever be, which is unbounded and not knowable from one walk; a produced value has exactly one + * finite chain. So this is asked once per *declared return type* that crosses as a handle, and the + * result rides on that declaration's `returnTypeName`. + * + * ### What is excluded, and why each one + * + * - `java.lang.Object`, which every class has and no slot is ever declared as (`kotlin.Any` is a + * built-in with no class file, so `objectBoundaryTypeOrNull` already declines a parameter of it). + * Keeping it would add one entry to every chain and match nothing. + * - a supertype that is not JVM-public, or that is Kotlin-`internal` -- the same rule + * [isNameablePublicClass] applies, and for the same reason: a name outside the module may not say + * it, so no slot can be declared as it either. + * - anything past [limit] links. Not a defence against a hostile input -- these are jars the build + * resolved -- but the cost is paid at build time on every build, and nothing measured comes close: + * see `ReturnSupertypeTest.theAncestryTheTableCarriesIsMeasuredRatherThanAssumed`. + * + * A binary name becomes a Kotlin one the way metadata spells it: `/` and `$` both become `.`. A JVM + * supertype Kotlin renames (`java.util.List` for `kotlin.collections.List`) therefore comes out under + * its JVM name and simply never matches a slot, which is the honest failure -- a slot declared + * `kotlin.collections.List` is declined by `objectBoundaryTypeOrNull` anyway. + */ +internal fun ArtifactClasspath.nameablePublicSupertypesOf(kotlinInternalName: String, limit: Int = 24): List { + val start = classNode(kotlinInternalName.replace('.', '$')) ?: return emptyList() + val found = LinkedHashSet() + val pending = ArrayDeque() + pending.addLast(start) + while (pending.isNotEmpty() && found.size < limit) { + val node = pending.removeFirst() + val parents = listOfNotNull(node.superName) + node.interfaces.orEmpty() + parents.forEach { binaryName -> + if (binaryName == JAVA_LANG_OBJECT) return@forEach + val parent = classNode(binaryName) ?: return@forEach + if ((parent.access and org.objectweb.asm.Opcodes.ACC_PUBLIC) == 0) return@forEach + val metadata = kotlinClassMetadataOf(parent) as? KotlinClassMetadata.Class + if (metadata != null && metadata.kmClass.visibility != Visibility.PUBLIC) return@forEach + if (found.add(binaryName.replace('/', '.').replace('$', '.'))) pending.addLast(parent) + } + } + return found.toList() +} + +private const val JAVA_LANG_OBJECT = "java/lang/Object" + +/** + * The first supertype of [binaryName] -- direct or inherited, nearest first -- that this walk cannot + * find as a class file, as a qualified name; `null` when every one is there. + * + * ### Why a receiver needs its whole ancestry + * + * Generated Kotlin that reads a member off a value (`(args[0] as Owner).prop`, or an extension + * property's `receiver.alias`) makes `kotlinc` resolve `Owner`'s member scope, and that scope is built + * from every supertype. One that is not on the consumer's compile classpath fails the whole generated + * file: "Cannot access 'androidx.lifecycle.ViewModelStoreOwner' which is a supertype of + * 'androidx.compose.ui.platform.DefaultArchitectureComponentsOwner'. Check your module classpath" + * (ui-desktop 1.11 depends on lifecycle-viewmodel with `implementation` scope, so a consumer's compile + * classpath -- which is what this walk is given -- does not carry it). Nothing `Owner` itself declares + * says so; only walking the chain does. + * + * The JDK's own classes (`java/`, `javax/`, `jdk/`, `sun/`) are never among the roots a walk is given + * and are always there to `kotlinc`, and neither is `kotlin/`: the standard library is on every + * Kotlin compile classpath, while a test fixture's directory walk does not carry it. + */ +internal fun ArtifactClasspath.unreachableSupertypeOf(binaryName: String): String? { + val start = classNode(binaryName) ?: return null + val visited = HashSet() + val pending = ArrayDeque() + pending.addLast(start) + while (pending.isNotEmpty()) { + val node = pending.removeFirst() + val parents = listOfNotNull(node.superName) + node.interfaces.orEmpty() + for (parent in parents) { + if (!visited.add(parent)) continue + if (ALWAYS_ON_A_KOTLIN_COMPILE_CLASSPATH.any { parent.startsWith(it) }) continue + val parentNode = classNode(parent) ?: return parent.replace('/', '.').replace('$', '.') + pending.addLast(parentNode) + } + } + return null +} + +private val ALWAYS_ON_A_KOTLIN_COMPILE_CLASSPATH = listOf("java/", "javax/", "jdk/", "sun/", "kotlin/") + +/** + * The object-handle boundary type: `docs/design/kotlin-extensions-in-python.md` §6's "type gate", and the + * second of the two things that independently held Compose at zero. + * + * ### What crosses + * + * Nothing of the object does. `python.multiplatform.reflection.TypeTag.OBJECT` is already a complete + * marshalling category on both sides of the boundary and has been since the upcall trampoline was + * written: `UpcallTrampoline.marshalResult` puts a returned Kotlin object into + * `python.multiplatform.reflection.HandleTable` and hands Python the resulting integer, and + * `toKotlinObject` resolves that integer back to the very same instance on the way in. Python never + * sees a Kotlin reference, which is the only shape that works on all five targets (see + * `ObjectReference`'s KDoc). KSP's own `tagFor` has emitted `OBJECT` for every non-primitive since it + * existed; this is the walker finally being able to do the same. + * + * ### Why this could not be done from a JVM descriptor + * + * A cast needs a **Kotlin type name**, and that is exactly what [boundaryTypeOf] does not have: a + * descriptor says `Ljava/util/List;`, whose Kotlin spelling is a different name (`kotlin.collections + * .List`) that the compiler refuses to accept written out as `java.util.List`. `@Metadata`'s + * classifier is already the Kotlin name -- `androidx/compose/ui/Modifier`, nested classes spelled + * `Outer.Inner` -- so the only transformation needed is `/` to `.`. That is why this lives here and + * not beside the descriptor table, and why the Java-class path in `ArtifactScanner` still has no + * `OBJECT` case. + * + * ### Lifetime + * + * A handle is a **strong root** and the table cannot tell that Python has finished with it, so + * exactly one of two things has to give it back (`HandleTable`'s own KDoc states the contract): + * + * - a value returned into a **generated proxy class** is released by that class's `__del__`; + * - a value returned from a plain `CallableKind.FUNCTION` -- which is every entry this walker emits + * -- reaches Python as a **bare integer**, and `PythonProxySource`'s KDoc already records that + * such a handle "is the caller's to release": an integer has nothing to hang a finaliser off, so + * the caller must pass it to `_pm_release`. + * + * A chained `Modifier.padding(...).size(...)` therefore leaks one handle per intermediate link until + * the Python surface of `docs/design/kotlin-extensions-in-python.md` §4.1 exists to own them. That is a + * known, bounded cost of this step and not a defect introduced by it -- the same is already true of + * every `OBJECT`-returning KSP entry -- but it is the reason this KDoc says so rather than leaving it + * to be discovered. + * + * ### What is still declined + * + * A classifier this walker cannot **find on the classpath as a public class**. That declines Kotlin's + * built-ins as a side effect, because they have no class file of their own anywhere -- `kotlin.Any`, + * `kotlin.collections.List` and `kotlin.Function1` are all mapped onto JVM types and exist only in + * `.kotlin_builtins` metadata. Declining them is also the right answer independently: a Python + * callable cannot become a `Function1` (the trampoline would hand the cast a `PyObject`), and a + * `List` parameter would need a collection conversion the boundary does not have. Being unable to see + * the class is the mechanism; both would have to be declined anyway. + */ +private fun objectBoundaryTypeOrNull(type: KmType, classpath: ArtifactClasspath): BoundaryType? { + val rendered = renderKotlinTypeName(type, classpath) ?: return null + return BoundaryType("OBJECT", "(%s as $rendered)", "(%s)") +} + +/** + * The Kotlin-source spelling of [type], type arguments included, or `null` if any part of it is + * something generated code must not write. + * + * Type arguments are rendered rather than dropped because a cast to a bare generic name is not valid + * Kotlin ("One type argument expected") -- the same trap `python.multiplatform.ksp.TypeShape.rendered` + * exists for, and one that surfaces as a compile failure of the *generated* file rather than as + * anything the generator could notice. + */ +private fun renderKotlinTypeName(type: KmType, classpath: ArtifactClasspath, isArgument: Boolean = false): String? { + val classifier = type.classifier as? KmClassifier.Class ?: return null + // A type *argument* may be `kotlin.Any` -- which is what an unbounded type parameter becomes + // (issue #38): `mutableStateOf`'s `policy: SnapshotMutationPolicy` is written + // `SnapshotMutationPolicy`. Only as an argument: at the top level a `kotlin.Any` + // slot is [resolveKotlinBoundary]'s own decision, and it opens the nullable one only. + if (isArgument && classifier.name == KOTLIN_ANY) return "kotlin.Any" + if (type.isNullable) "?" else "" + if (!classpath.isNameablePublicClass(classifier.name)) return null + val base = classifier.name.replace('/', '.') + if (type.arguments.isEmpty()) return base + if (type.isNullable) "?" else "" + val rendered = ArrayList(type.arguments.size) + for (argument in type.arguments) { + val argumentType = argument.type + // A star projection is spellable as-is; anything else has to be a nameable type. + rendered += if (argumentType == null) "*" else renderKotlinTypeName(argumentType, classpath, isArgument = true) ?: return null + } + return "$base<${rendered.joinToString(", ")}>" + if (type.isNullable) "?" else "" +} + +/** `kotlin.Any`, as `KmClassifier.Class` spells it. */ +internal const val KOTLIN_ANY = "kotlin/Any" + +/** `kotlin.CharSequence`, as `KmClassifier.Class` spells it. A built-in: it has no class file. */ +internal const val KOTLIN_CHAR_SEQUENCE = "kotlin/CharSequence" + +/** + * What a declared `kotlin.CharSequence` result crosses as (issue #73): the `str` of its characters. + * Return-only -- [resolveKotlinBoundary] never hands it out for a parameter, and its read refuses to be + * written into generated source should anything ever ask. + */ +private val CHAR_SEQUENCE_RESULT = BoundaryType( + tag = "STRING", + readFn = { error("a kotlin.CharSequence boundary is return-only") }, + wrapFn = { call -> "($call).toString()" }, + isReturnOnly = true, +) + +/** The nullable form: `null` stays `null`, which the `STRING` tag already carries as `None`. */ +private val NULLABLE_CHAR_SEQUENCE_RESULT = BoundaryType( + tag = "STRING", + readFn = { error("a kotlin.CharSequence boundary is return-only") }, + wrapFn = { call -> "($call)?.toString()" }, + isReturnOnly = true, +) + +/** What a `kotlin.Any?` slot crosses as: an object handle, cast to nothing narrower. */ +private val NULLABLE_ANY_BOUNDARY = BoundaryType("OBJECT", "(%s as kotlin.Any?)", "(%s)") + +/** + * Which of [parameters] may be read as `kotlin.Any?` -- every one of them, or `null` when even one + * may not (issue #38). + * + * ### The rule + * + * An **unbounded, non-reified** type parameter is substituted by `kotlin.Any?`, and the generated + * call names it explicitly (`mutableStateOf(value = ...)`), so Kotlin never has to infer + * it -- `structuralEqualityPolicy()` has nothing to infer `T` from and would not compile otherwise. + * That is exact rather than approximate: an unbounded `T` admits `Any?`, so the call is one Kotlin + * itself would accept, and the value Python hands in reaches the declaration unchanged. + * + * A **bounded** parameter (`T : Comparable`, `R : Any`) is declined: `Any?` is not within its + * bound and nothing narrower is known. A **reified** one is declined because its type argument is + * *read* at run time (`typeOf()`, `is T`), so `Any?` would change what the body does, not just + * what it is called with. + * + * `T : Any?` written out is the same as no bound, and is treated so. + */ +internal fun substitutableTypeParameterIds(parameters: List): Set? { + val ids = HashSet() + for (parameter in parameters) { + if (parameter.isReified) return null + val bounded = parameter.upperBounds.any { bound -> + (bound.classifier as? KmClassifier.Class)?.name != KOTLIN_ANY || !bound.isNullable + } + if (bounded) return null + ids += parameter.id + } + return ids +} + +/** + * [this] with every type parameter in [ids] replaced by `kotlin.Any?`, or `null` when it mentions a + * type parameter outside [ids] (a bounded one, or one this declaration does not own). + * + * Returns [this] itself when there is nothing to replace, so the common, non-generic signature is not + * copied. A replaced type keeps its own nullability, `suspend`-ness and arguments; a definitely-non-null + * `T & Any` is declined, since `Any?` is exactly what it excludes. + */ +internal fun KmType.substituteAnyFor(ids: Set): KmType? { + if (!mentionsTypeParameter()) return this + val own = classifier + if (own is KmClassifier.TypeParameter) { + if (own.id !in ids || isDefinitelyNonNull) return null + return nullableAnyType() + } + if (outerType != null) return null + val copy = KmType() + copy.classifier = own + copy.isNullable = isNullable + copy.isSuspend = isSuspend + for (projection in arguments) { + val argument = projection.type + copy.arguments += if (argument == null) { + projection + } else { + KmTypeProjection(projection.variance, argument.substituteAnyFor(ids) ?: return null) + } + } + return copy +} + +private fun KmType.mentionsTypeParameter(): Boolean = + classifier is KmClassifier.TypeParameter || + arguments.any { it.type?.mentionsTypeParameter() == true } || + outerType?.mentionsTypeParameter() == true + +/** A fresh `kotlin.Any?`. */ +internal fun nullableAnyType(): KmType = KmType().apply { + classifier = KmClassifier.Class(KOTLIN_ANY) + isNullable = true +} + +/** Whether generated Kotlin in another module may write this classifier's name: it has to exist as + * a class file this walk can see, be JVM-public, and -- when it is Kotlin -- be Kotlin-public too + * (`internal` is JVM-public and is not a name anybody outside the module may say). */ +internal fun ArtifactClasspath.isNameablePublicClass(kotlinInternalName: String): Boolean { + // Metadata spells a nested class `Outer.Inner`; its class file is `Outer$Inner`. + val node = classNode(kotlinInternalName.replace('.', '$')) ?: return false + if ((node.access and org.objectweb.asm.Opcodes.ACC_PUBLIC) == 0) return false + val metadata = kotlinClassMetadataOf(node) as? KotlinClassMetadata.Class ?: return true + return metadata.kmClass.visibility == Visibility.PUBLIC +} + +private fun valueClassBoundaryType(qualifiedName: String, underlying: BoundaryType, propertyName: String?): BoundaryType = + BoundaryType( + tag = underlying.tag, + readFn = { slot -> "$qualifiedName(${underlying.read(slot)})" }, + wrapFn = { call -> + val property = propertyName + ?: error("$qualifiedName has no public accessor for its wrapped value; must not be used in return position") + underlying.wrapReturn("($call).$property") + }, + ) + +/** Kotlin's own names for the types [boundaryTypeOf] admits, keyed the way `KmClassifier.Class.name` + * spells a top-level class: `/`-separated, no leading slash. One table, not two: both this and + * [boundaryTypeOf] resolve to the same [BoundaryType] instances, because a JVM `I` and a Kotlin + * `kotlin.Int` have to widen and narrow identically. */ +/** + * The two entries of [kotlinPrimitiveBoundaryTypeOf] that survive a `?`, and why the others do not. + * + * The nullable branch of [resolveKotlinType] declines every primitive on one stated reason: `TypeTag + * .INT` carries a `Long` and the read *narrows* it (`(args[0] as Long).toInt()`), so a `null` would + * throw inside the cast rather than reach the declaration. That reason is about the numeric widths + * and it does not apply to a `String` or a `ByteArray`: their JVM shape is already a reference, their + * read is a plain cast with nothing to narrow, and `UpcallTrampoline.toKotlin` already answers `null` + * for a Python `None` **whatever the tag says** -- the one line that makes a nullable boundary work at + * all. So the only thing that had to change is the `?` on the cast, and declining them was the rule + * being coarser than its own justification. + * + * Measured cost of that coarseness: `Modifier.clickable`, `combinedClickable` and `mouseClickable` + * were declined for `onClickLabel: String?` alone, after their `onClick` slot became bindable + * (`FunctionSlotBindingTest`). A nullable `Boolean?`/`Int?` stays declined, and `ArtifactScannerTest` + * keeps that pinned. + */ +private fun nullablePrimitiveBoundaryTypeOf(kotlinInternalName: String): BoundaryType? = when (kotlinInternalName) { + "kotlin/String" -> BoundaryType("STRING", "(%s as String?)", "(%s)") + "kotlin/ByteArray" -> BoundaryType("BYTES", "(%s as ByteArray?)", "(%s)") + else -> null +} + +internal fun kotlinPrimitiveBoundaryTypeOf(kotlinInternalName: String): BoundaryType? = when (kotlinInternalName) { + "kotlin/Boolean" -> boundaryTypeOf("Z") + "kotlin/Byte" -> boundaryTypeOf("B") + "kotlin/Short" -> boundaryTypeOf("S") + "kotlin/Int" -> boundaryTypeOf("I") + "kotlin/Long" -> boundaryTypeOf("J") + "kotlin/Float" -> boundaryTypeOf("F") + "kotlin/Double" -> boundaryTypeOf("D") + "kotlin/String" -> boundaryTypeOf("Ljava/lang/String;") + "kotlin/ByteArray" -> boundaryTypeOf("[B") + "kotlin/Unit" -> boundaryTypeOf("V") + else -> null +} + +/** One function's worth of what [ArtifactScanner] needs, already carrying its own JVM signature so + * it can be matched back to the `MethodNode` that owns the bytecode. */ +internal data class ResolvedFunction( + val kotlinName: String, + val isExtension: Boolean, + val allParameterTypes: List, // receiver (if [isExtension]) first, then declared value parameters + val returnType: KmType, + val jvmSignature: JvmMethodSignature, + /** `null` unless [isExtension]; the same type that heads [allParameterTypes] when it is not. */ + val receiverType: KmType? = null, + /** Aligned with [allParameterTypes]; the receiver slot is [RECEIVER_PARAMETER_NAME]. */ + val allParameterNames: List = emptyList(), + /** Aligned with [allParameterTypes]; the receiver slot is always `false` (a receiver cannot + * declare a default). Read, never acted on -- see `ExposedCallable.paramHasDefault`. */ + val allParameterDefaults: List = emptyList(), + /** + * Carried rather than filtered out at the source, because the two consumers of this want + * different things from it: the binder declines a `suspend` declaration outright, and the stub + * model records it as declined-because-suspend (`docs/archive/pyi-generation-pythonic-stubs.md` §3.1 -- "declined + * by both producers; must not be stubbed"). Dropping it here would make the second indistinguishable + * from a declaration that was never declared. + */ + val isSuspend: Boolean = false, + /** + * How many type arguments a call has to write, each of them `kotlin.Any?` (issue #38): the + * declaration's own type parameters, every one unbounded and non-reified, already substituted + * out of [allParameterTypes], [receiverType] and [returnType] by [resolvedFunctionOrNull]. `0` + * for a declaration that is not generic -- and for one whose type parameters cannot be + * substituted, which keeps its type parameters and is declined downstream exactly as before. + */ + val typeArgumentCount: Int = 0, + /** + * The declaration has a type parameter that cannot be read as `kotlin.Any?` -- bounded or + * reified (issue #38). Carried rather than left to fall out of an unnameable type, because a + * reified parameter need not appear in the signature at all (`typeNameOf(): String`) and such a + * declaration would otherwise look non-generic and be called with no type argument. + */ + val hasUnsubstitutableTypeParameters: Boolean = false, + /** + * One of the declaration's type parameters is `reified`. Carried apart from + * [hasUnsubstitutableTypeParameters] because `kotlinc` compiles such a function to an + * `ACC_SYNTHETIC` method (a stub that only throws -- the real body exists only inlined), which + * `ArtifactScanner.kotlinCandidates` would otherwise drop with every other synthetic before any + * model is built. This is how it is declined *visibly* instead (B-1: "a bounded or reified one + * still declined"). + */ + val hasReifiedTypeParameter: Boolean = false, +) + +/** The name given to the extension-receiver slot. Deliberately not a Python identifier: a receiver + * is positional in Kotlin too, so nothing should be able to address it by keyword. */ +internal const val RECEIVER_PARAMETER_NAME = "" + +/** + * A class's own public constructors, shaped as [ResolvedFunction] so [ArtifactScanner]'s existing + * top-level-function path binds them with no code of its own: a constructor's [ResolvedFunction + * .kotlinName] is the class's simple name and its [ResolvedFunction.returnType] is the class itself, + * so the call [candidateFromFunction] already writes for a non-extension declaration -- `$owner. + * ${'$'}{function.kotlinName}(args)` -- comes out as `androidx.compose.material3.Typography(args)`, + * which is exactly how Kotlin spells a fully-qualified constructor call. No `isConstructor` branch + * needed anywhere downstream. + * + * `` never reaches [ArtifactScanner.kotlinCandidates]: that function's own `ACC_STATIC` filter + * excludes it, a constructor never being `static`. That gap -- not a judgement about the class -- is + * why `Typography` and `Shapes` were absent rather than declined; see this object's KDoc §10. + * + * @param ownerInternalName the class's own ASM binary name (`androidx/compose/material3/Typography`), + * used both as the returned type's classifier and, split on `/`, as the class's simple name. + */ +internal fun constructorsOf(kmClass: KmClass, ownerInternalName: String): List { + val simpleName = ownerInternalName.substringAfterLast('/') + val classType = KmType().apply { classifier = KmClassifier.Class(ownerInternalName) } + return kmClass.constructors.mapNotNull { resolvedConstructorOrNull(it, simpleName, classType) } +} + +private fun resolvedConstructorOrNull(constructor: KmConstructor, simpleName: String, classType: KmType): ResolvedFunction? { + if (constructor.visibility != Visibility.PUBLIC) return null + val signature = constructor.signature ?: return null + return ResolvedFunction( + kotlinName = simpleName, + isExtension = false, + allParameterTypes = constructor.valueParameters.map { it.type }, + returnType = classType, + jvmSignature = signature, + allParameterNames = constructor.valueParameters.map { it.name }, + allParameterDefaults = constructor.valueParameters.map { it.declaresDefaultValue }, + ) +} + +/** + * The Kotlin package a facade's declarations really live in, when it is not the JVM one. + * + * `@file:JvmPackageName` moves the *class file* without moving the Kotlin declarations, and + * `kotlin-stdlib-jdk8` uses it: `MatchGroupCollection.get(String)` is declared in Kotlin's + * `kotlin.text` and compiled into `kotlin/text/jdk8/RegexExtensionsJDK8Kt`. Deriving the owner from + * the binary name therefore produced `kotlin.text.jdk8.get`, and the import it generated + * (`import kotlin.text.jdk8.get as ...`) named a package the Kotlin compiler has never heard of -- + * observed as `Unresolved reference 'get'` in a generated fragment, which is the failure mode + * `boundaryTypeOf`'s KDoc warns about in general and this is a concrete instance of. + * + * `kotlin.Metadata`'s `pn` field exists for exactly this and is `null` whenever the two agree. + */ +internal fun kotlinPackageNameOverrideOf(node: ClassNode): String? { + val annotation = node.visibleAnnotations?.firstOrNull { it.desc == KOTLIN_METADATA_DESCRIPTOR } ?: return null + val values = annotation.values ?: return null + var i = 0 + while (i < values.size - 1) { + if (values[i] == "pn") return (values[i + 1] as? String)?.replace('/', '.') + i += 2 + } + return null +} + +/** The Kotlin-source spelling of a classifier, or `null` for a type variable or a flexible type. + * Metadata already spells a nested class `Outer.Inner` and a package with `/`, so only the package + * separator changes -- which is exactly why a *metadata* name can be written into generated source + * and a JVM descriptor cannot (see [boundaryTypeOf]'s KDoc). */ +internal fun kotlinClassifierNameOf(type: KmType): String? = + (type.classifier as? KmClassifier.Class)?.name?.replace('/', '.') + +internal fun functionsOf(container: KmDeclarationContainer): List = + container.functions.mapNotNull { function -> resolvedFunctionOrNull(function) } + +private fun resolvedFunctionOrNull(function: KmFunction): ResolvedFunction? { + if (function.visibility != Visibility.PUBLIC) return null + val signature = function.signature ?: return null + val declaredReceiver = function.receiverParameterType + val declaredParams = listOfNotNull(declaredReceiver) + function.valueParameters.map { it.type } + // Issue #38: an unbounded, non-reified type parameter is read as `kotlin.Any?`, and the call + // names it (`typeArgumentCount`). A declaration that cannot be substituted keeps its type + // parameters, and `declarationModelOf` declines it as it always did -- nothing changes for it. + val substitutable = substitutableTypeParameterIds(function.typeParameters) + val substituted = substitutable?.let { ids -> + val params = declaredParams.map { it.substituteAnyFor(ids) ?: return@let null } + val returns = function.returnType.substituteAnyFor(ids) ?: return@let null + params to returns + } + val allParams = substituted?.first ?: declaredParams + val receiver = if (declaredReceiver != null) allParams.first() else null + return ResolvedFunction( + kotlinName = function.name, + isExtension = receiver != null, + allParameterTypes = allParams, + returnType = substituted?.second ?: function.returnType, + jvmSignature = signature, + receiverType = receiver, + allParameterNames = (if (receiver != null) listOf(RECEIVER_PARAMETER_NAME) else emptyList()) + + function.valueParameters.map { it.name }, + allParameterDefaults = (if (receiver != null) listOf(false) else emptyList()) + + function.valueParameters.map { it.declaresDefaultValue }, + isSuspend = function.isSuspend, + typeArgumentCount = if (substituted != null) function.typeParameters.size else 0, + hasUnsubstitutableTypeParameters = function.typeParameters.isNotEmpty() && substituted == null, + hasReifiedTypeParameter = function.typeParameters.any { it.isReified }, + ) +} + +/** + * The declared Kotlin type as `docs/design/pyi-generation-design.md` §2.2's model wants it -- qualified + * name, nullability, type arguments, and value-class identity -- or `null` when the classifier is + * something no stub can name (a type *parameter*, a flexible type). + * + * Deliberately separate from [resolveKotlinType], which answers a different question: that one says + * *how a value marshals* and declines anything the boundary cannot carry, and this one says *what the + * declaration says*. `Dp` is `TypeTag.FLOAT` there and `androidx.compose.ui.unit.Dp` wrapping + * `kotlin.Float` here, and §2.2's whole argument is that a stub needs the second. + * + * [depth] bounds the recursion rather than trusting the input: a type argument list is attacker-free + * here (it comes from a jar this build resolved) but the cost of a pathological generic signature is + * paid at build time on every build, and nothing downstream needs more nesting than this. + */ +internal fun kotlinTypeModelOf( + type: KmType, + classpath: ArtifactClasspath, + depth: Int = 0, +): python.multiplatform.gradle.model.KotlinTypeModel? { + if (depth > 8) return null + val classifier = type.classifier as? KmClassifier.Class ?: return null + val qualifiedName = classifier.name.replace('/', '.') + val arguments = type.arguments.map { argument -> + val argumentType = argument.type ?: return@map null // a star projection + kotlinTypeModelOf(argumentType, classpath, depth + 1) + } + val info = classpath.valueClassInfo(classifier.name) + val valueClass = info?.let { value -> + val underlying = kotlinTypeModelOf(value.underlyingType, classpath, depth + 1) ?: return@let null + python.multiplatform.gradle.model.ValueClassModel( + underlying = underlying, + constructorIsPublic = value.constructorIsPublic, + propertyIsPublic = value.propertyIsPublic, + ) + } + return python.multiplatform.gradle.model.KotlinTypeModel( + qualifiedName = qualifiedName, + isNullable = type.isNullable, + arguments = arguments, + valueClass = valueClass, + ) +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/PythonArtifactBindingsTask.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/PythonArtifactBindingsTask.kt new file mode 100644 index 000000000..abf9edf50 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/PythonArtifactBindingsTask.kt @@ -0,0 +1,157 @@ +package python.multiplatform.gradle.artifact + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.workers.WorkerExecutor +import javax.inject.Inject + +/** + * Walks the jars a configuration resolves and writes one `FunctionTableFragment` per artefact that + * contributed something, plus the `ArtifactTable` that lists them. + * + * `docs/design/ecosystem.md` §5b: KSP reads the consumer's own source, this reads everything the build + * resolves. The two emit the same shape into the same `UpcallTable`; see [renderArtifactTableSource] + * for why they keep separate aggregators. + * + * ### Only jars and klibs + * + * A resolved artefact that is not a `.jar` or `.klib` -- an `aar`, a project's classes directory -- is + * skipped silently. + * + * ### The klib branch runs behind a worker classloader boundary + * + * Calling [KlibScanner] directly, in this task's own classloader, throws `NoSuchMethodError` in a real + * consumer build -- a classloader-scope collision between this plugin's `kotlin-compiler-embeddable` + * and the Kotlin Gradle Plugin's `kotlin-util-klib` on the root buildscript scope, which is a *parent* + * of this classloader. See [KlibScanWorkAction]'s KDoc for the measurement and why + * [WorkerExecutor.scanKlibIsolated] fixes it: an isolated worker classloader's parent is Gradle's own + * minimal worker infrastructure, not that root buildscript scope, so nothing this plugin didn't put on + * [KlibScanWorkAction]'s explicit classpath is visible to answer for `org.jetbrains.kotlin.library.*`. + */ +abstract class PythonArtifactBindingsTask : DefaultTask() { + + @get:Inject + abstract val workerExecutor: WorkerExecutor + + /** Every file the walked configuration resolved. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.NAME_ONLY) + abstract val artifacts: ConfigurableFileCollection + + /** + * File name to the artefact's coordinates (`junit:junit:4.13.2`, or `project :lib`). + * + * Keyed by file name rather than by absolute path so that the task input does not carry a + * Gradle cache location, which differs per machine and would defeat any build cache. + */ + @get:Input + abstract val coordinatesByFileName: MapProperty + + /** Package or class names to bind; empty binds every class in every resolved jar. */ + @get:Input + abstract val includePrefixes: ListProperty + + /** + * The whole classpath of the isolated worker that runs [KlibScanner], bar this plugin's own jar: + * the klib reader plus what [ArtifactScanner] needs, resolved by the consumer's project. See + * [scanKlibIsolated] and `KlibScanWorkAction.pluginCodeSource`. + * + * `@Internal` rather than `@Classpath` on purpose. It is a lazily-resolved configuration holding one + * pinned coordinate, and marking it an input would make Gradle resolve it during this task's + * up-to-date check -- a 55 MiB download for every consumer that walks *jars only* and never reaches + * the klib branch. Nothing is lost by not tracking it: the coordinate is generated into this plugin + * (`DEFAULT_KLIB_WORKER_COORDINATES`), so it cannot change without the plugin jar changing, and the + * plugin jar is already part of the task implementation Gradle hashes. + */ + @get:Internal + abstract val klibReaderClasspath: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + /** + * Where the `.class` files go, as opposed to the `.kt` files [outputDirectory] gets. + * + * A separate output because it is a separate *kind* of thing: [outputDirectory] is registered as + * a Kotlin source directory and compiled, this is put on the compilation's classpath as if it + * were a library. Only `@Composable` bindings need one -- see `ComposableThunks.kt` for the two + * measurements that show a composable's call site cannot be Kotlin source -- so a build that + * binds none leaves this directory empty rather than absent, because a Gradle output directory + * that does not exist is a task that never ran. + */ + @get:OutputDirectory + abstract val thunkDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val destination = outputDirectory.get().asFile.resolve(ARTIFACTS_PACKAGE.replace('.', '/')) + // Rebuilt from scratch: a dependency that was removed, or an include prefix that stopped + // matching, must take its fragment file with it or the next compilation still sees it. + outputDirectory.get().asFile.deleteRecursively() + destination.mkdirs() + val thunkRoot = thunkDirectory.get().asFile + thunkRoot.deleteRecursively() + val thunkDestination = thunkRoot.resolve(THUNK_PACKAGE.replace('.', '/')) + thunkDestination.mkdirs() + + val includes = includePrefixes.get() + val coordinates = coordinatesByFileName.get() + val objectNames = mutableListOf() + + // Every resolved jar, not just the one being enumerated: a value-class parameter or an + // extension receiver declared in one artefact routinely lives in another -- + // `androidx.compose.foundation.layout`'s `Modifier.padding(Dp)` needs `Dp`, which is + // `androidx.compose.ui.unit`'s. See `ArtifactScanner.scanJar`'s `classpath` parameter. + val resolvedJars = artifacts.files.filter { it.isFile && it.name.endsWith(".jar") } + + artifacts.files + .filter { it.isFile && (it.name.endsWith(".jar") || it.name.endsWith(".klib")) } + .sortedBy { it.name } + .forEach { file -> + val entries = if (file.name.endsWith(".jar")) { + ArtifactScanner.scanJar(file, includes, classpath = resolvedJars) + } else { + // The klib path takes no classpath yet, and whether it needs one is open + // rather than overlooked: the jar path needs other artefacts because an + // erased descriptor cannot name the class it erased, and a klib records + // Kotlin types directly. Settle it before trusting a cross-artefact type here. + // + // Not a direct `KlibScanner.scanKlib` call: see this class's KDoc, "The klib + // branch runs behind a worker classloader boundary". + workerExecutor.scanKlibIsolated(file, includes, temporaryDir, klibReaderClasspath.files).callables + } + // An artefact that contributed nothing gets no fragment: a fragment with no entries + // would still take a `moduleName` in `UpcallTable`'s installed set, which is the one + // thing that set is read for. + if (entries.isEmpty()) return@forEach + val coordinate = coordinates[file.name] ?: file.name.substringBeforeLast('.') + val objectName = artifactFragmentObjectName(coordinate) + destination.resolve("$objectName.kt").writeText( + renderArtifactFragmentSource(ArtifactFragment(objectName, "artifact:$coordinate", entries)), + ) + // The `.class` half of the same fragment, and emitted from the same `entries` list + // for the same reason `docs/design/pyi-generation-design.md` §2.2 gives for the declaration + // models: two walks that can disagree are worse than one walk with two outputs. + val thunks = thunkSpecsOf(entries) + if (thunks.isNotEmpty()) { + thunkDestination.resolve("${objectName}Thunks.class") + .writeBytes(generateThunkClass(objectName, thunks)) + logger.info("python-multiplatform: emitted ${thunks.size} composable thunk(s) for $coordinate") + } + objectNames += objectName + logger.info("python-multiplatform: bound ${entries.size} declaration(s) from $coordinate") + } + + destination.resolve("ArtifactTable.kt").writeText(renderArtifactTableSource(objectNames)) + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt new file mode 100644 index 000000000..8c1db58ed --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt @@ -0,0 +1,138 @@ +package python.multiplatform.gradle.model + +import java.io.Serializable + +/** + * `docs/design/pyi-generation-design.md` §2.2's declaration model: **one representation, produced by the + * scanner, consumed by two renderers.** + * + * ### Why a third model rather than a widened existing one + * + * `python.multiplatform.ksp.CallableEntryModel` and + * `python.multiplatform.gradle.artifact.ArtifactCallable` are near-identical and both were cut down + * to *exactly* what a call site needs -- the reasoning `PythonProxySource`'s KDoc gives for + * `ExposedCallable`. A `.pyi` needs a strict superset of that, because a stub describes a signature + * and a call site only has to produce one. §2.2 rejects widening either in place: it would put + * fields on a hot path whose renderer must ignore them, and it would not fix the KSP/ASM asymmetry. + * + * §2.1 lists what every existing representation reads and then drops, one function before it would + * be used: parameter **names**, **defaults**, **nullability**, the **declared Kotlin type**, the + * **extension-receiver** flag, the **overload grouping**, and **value-class identity**. `958c0082` + * has since put flat `paramNames`/`paramTypeNames`/`returnTypeName`/`isExtension`/`receiverTypeName`/ + * `paramHasDefault` on `ExposedCallable` for the Python-side dispatcher -- so part of that loss is + * already repaired, but as strings, without nullability, without type arguments, without value-class + * identity, and only for declarations that were *bound*. Those four are exactly what a stub cannot do + * without, which is why this is a model and not six more `List`s. + * + * ### One walk + * + * §2.2 also rejects "a second scan, independent of the binder's" -- two readers of the same jars that + * can disagree. So these are built at the point the binding is built, from the same + * `ResolvedFunction`, inside the same class walk. `ArtifactScanner.scanDeclarations` is that walk + * with the other half of its result kept. + * + * ### Not implemented here + * + * The KSP producer (`python-multiplatform-ksp`) does not emit this model yet: KSP sees only the + * consumer's own source, and the artefact walker is where the third-party surface that motivates + * stubs comes from. §2.3 records that KSP would need to read *nothing new* to supply it. The klib + * producer does not exist at all (§0, §7). + * + * `Serializable`, along with [DeclaredParameter], [KotlinTypeModel] and [ValueClassModel]: this + * crosses the `WorkerExecutor` isolation boundary `KlibScanWorkAction` runs `KlibScanner` behind -- + * see that file's KDoc for why the boundary exists at all. + */ +internal data class DeclarationModel( + /** The Kotlin declaration's own name -- `padding`, not `padding__Dp` and not the FQN. */ + val simpleName: String, + /** The Kotlin package for a top-level declaration, or the qualified class name for a static. + * Which of the two it is, is [ownerIsClass]; the distinction is not recoverable from the string + * and decides which `sys.modules` entry the runtime publishes onto. */ + val owner: String, + val ownerIsClass: Boolean, + /** The extension receiver's declared type, or `null` when this is not an extension function. */ + val receiver: KotlinTypeModel?, + /** How the receiver marshals -- it is the runtime's `a0` and therefore has a boundary type of + * its own, which is not recoverable from [parameters] because it is not one of them. */ + val receiverBoundaryTag: String? = null, + /** Declared value parameters, receiver **excluded**. */ + val parameters: List, + val returnType: KotlinTypeModel, + /** + * How the return marshals (`python.multiplatform.reflection.TypeTag`'s name), or `null` when the + * declaration was declined and therefore has no boundary at all. `OBJECT` means the value reaches + * Python as a bare `HandleTable` integer, which is why the Kotlin-FQN stub product annotates it + * `int` -- see `renderKotlinFqnStubs`. + */ + val returnBoundaryTag: String? = null, + /** + * The `UpcallTable` key the binder gave this declaration, or `null` when it declined it. + * + * This is the whole of §2.2's third property: a declined declaration stays visible, with its + * [declineReason], instead of vanishing at a `return null`. It is also what makes the stub + * honest -- an entry with no table key has no runtime attribute, and stubbing it would promise a + * call that raises (§4.5's rule, generalised). + */ + val bindingName: String? = null, + val declineReason: String? = null, + /** `false` for a Java class compiled without `-parameters` (§3.2), where every [DeclaredParameter] + * name is `null` and the stub must be positional-only rather than invent a keyword. */ + val parameterNamesKnown: Boolean = true, + val isComposable: Boolean = false, + val isSuspend: Boolean = false, + /** + * `FUNCTION`, `STATIC_GETTER` (an object's constant), or `GETTER`/`SETTER` (issue #38): a + * property read off an instance, whose [receiver] is the type it is read on -- the owner of a + * member property, the receiver of an extension property -- and whose [owner] is the class or + * package that declares it. A `SETTER`'s one parameter is the value written. + */ + val kind: String = "FUNCTION", + /** + * What the declared return type **is a**, nearest first, as qualified Kotlin names -- the + * ancestry `ArtifactCallable.returnSupertypes` carries into the binding layer, kept here so a + * stub class can extend the class a value of it may fill (`BitmapPainter` fills a `Painter` + * slot). Empty for a type nothing could read an ancestry for, which is the truth rather than a + * gap to fill. + */ + val returnSupertypes: List = emptyList(), + /** + * The declaration is a class's own constructor (issue #73): [simpleName] is the class's name, + * [owner] its package and [returnType] the class itself. A stub renders it as that class's + * `__init__` rather than as a module function that would take the class's name away from it -- a + * top-level factory of the same name (`PaddingValues(...)`) is a different declaration and keeps + * the `Any` rule. + */ + val isConstructor: Boolean = false, +) : Serializable + +/** @param name `null` when the producer could not read one (§3.2); never a synthesised `arg0`. */ +internal data class DeclaredParameter( + val name: String?, + val type: KotlinTypeModel, + val declaresDefault: Boolean, + /** `python.multiplatform.reflection.TypeTag`'s name, or `null` when the type was declined. */ + val boundaryTag: String? = null, +) : Serializable + +/** + * A declared Kotlin type, as `@Metadata` spells it: qualified name, nullability, type arguments, and + * -- when the classifier is a value class -- what it wraps and whether that wrapper can be opened + * from outside its module. + * + * @param arguments a `null` element is a star projection. + */ +internal data class KotlinTypeModel( + val qualifiedName: String, + val isNullable: Boolean = false, + val arguments: List = emptyList(), + val valueClass: ValueClassModel? = null, +) : Serializable + +/** The two booleans `python.multiplatform.gradle.artifact.ValueClassInfo` already computes, kept + * rather than consumed inside a closure: §3.4's allowlist decision is not expressible without + * knowing what the wrapper wraps. */ +internal data class ValueClassModel( + val underlying: KotlinTypeModel, + val constructorIsPublic: Boolean, + val propertyIsPublic: Boolean, +) : Serializable diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt new file mode 100644 index 000000000..4cea7f641 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt @@ -0,0 +1,735 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.KotlinTypeModel +import python.multiplatform.gradle.model.ValueClassModel + +/** + * The stub product: `docs/design/pyi-generation-design.md` §5.3's Kotlin-FQN stubs, rendered from the + * declaration model. + * + * One `def` per bound declaration, in the module the runtime publishes it onto, under the Kotlin + * names. The binder never exports a Kotlin namespace under another name (AGENTS.md §12 rule 1), so + * there is no second, renamed product: a `pythonx` module is a real Python package that imports + * these modules and makes them Pythonic, and its own stubs are its own. + * + * Returns *relative path to file content*, so nothing here touches a disk and every assertion in + * `PyiRenderingTest` and `TypedStubTest` is over text. `PythonStubsTask` is the only thing that writes. + * + * ### What a stub says about a type (issue #31) + * + * The declared Kotlin type, wherever the binder serves one: + * + * | Kotlin | stub | why | + * |---|---|---| + * | `Int` `Long` `Float` `Boolean` `String` `ByteArray` `Unit` | `int` `float` `bool` `str` `bytes` `None` | what crosses | + * | any other class or interface | a stub class **in the module of its own Kotlin package** | `UpcallTrampoline` hands Python a handle and the binding layer wraps it in a proxy class named for the declared type | + * | value class the binder binds as its primitive (`Dp`) | `Dp \| float` as a parameter, `float` as a result | a result is the raw number (`_wrap` only wraps `OBJECT`); the raw module accepts the number for a parameter, and the binding layer's allowlist (`allow_raw_primitive`) decides at run time, which no stub can know | + * | value class the binder cannot open (`TextUnit`) | its own stub class | it crosses as an object handle | + * | `(A, B) -> R` | `Callable[[A, B], R]` | `kotlin.FunctionN`'s type arguments | + * | `T?` | `T \| None` | `KotlinTypeModel.isNullable` | + * + * A class whose name is also a function in its module (`PaddingValues(...)` the factory and + * `PaddingValues` the interface) cannot be both in one Python module, and the runtime resolves the + * name to the function, so such a type is annotated `Any`. A class's **own constructor** is the + * exception (issue #73): it is rendered as the class's `__init__`, so the class keeps its stub -- + * members, properties and all -- and `TextFieldState(...)` type-checks as a `TextFieldState`, which is + * what the runtime's constructor callable returns; its docstring carries the table key after + * [CONSTRUCTOR_MARKER]. Its `__`-suffixed table-key spellings stay module functions as well. A value + * class bound as its primitive is not the exception: its constructor returns the raw number. (`isinstance(x, TextFieldState)` is the one thing the stub promises that the + * runtime, whose module attribute is the callable, does not do.) + * + * A Kotlin `object` (`Alignment`, `Arrangement`) is a module of its constants and functions, and the + * types nested in it (`Alignment.Horizontal`) are classes of that same module, so + * `Alignment.End` is annotated `Horizontal` rather than `Any` (issue #53). The object's own type + * (`Alignment.Center: Alignment`) has no class to name and stays `Any`. + * + * ### Extension functions are also methods + * + * The runtime attaches an extension to the receiver's proxy class (`PythonxAdapter._attach`), reached + * as `m.padding(...)` and `Modifier.padding(...)` alike. A plain method cannot say both -- the + * second binds the argument to `self` -- so each extension is a **callable attribute** of the + * receiver's stub class whose type is a `Protocol` with `__call__` (a measured shape: archive + * `pyi-generation-pythonic-stubs.md` §4.4). Both the Kotlin name (an overload set, `@overload`ed) and + * each table-key spelling (`padding__Dp`) are attributes, because the runtime indexes both. + * The module-level `def`s stay, with the receiver as the first positional-only parameter. + * + * ### Properties (issue #38) + * + * A `GETTER`/`SETTER` key is not a module attribute: the binding layer serves it as a Python + * `property` of the proxy of the type it is read on (`receiver`). So it is stubbed there, as + * `@property` (plus `@.setter` for a `var`), and each accessor's docstring carries its table key + * after [PROPERTY_MARKER] -- the one place a test can match a key to its stub. A property whose + * receiver has no stub class (its name is taken by a function or a module of its package, so the + * type is annotated `Any`) cannot be written anywhere true; it gets a comment carrying the same + * marker in the receiver package's module, saying so, rather than vanishing. + * + * ### Overloads + * + * An overload set is `@overload`ed under its base name, **in table-key order**: the artefact table is + * sorted by name (`ArtifactScanner.scanJar`), the binding layer indexes its candidates in that order + * and `_Overloads` tries them in it, and a checker takes the first match. + */ + +private const val HEADER = "# GENERATED by python-multiplatform-gradle-plugin. Do not edit." + +/** The module alias every stub imports `typing` as: a Kotlin declaration may be called `Any`. */ +private const val TYPING = "_t" + +private const val ANY = "$TYPING.Any" + +private const val RECEIVER_NAME = "receiver" + +internal fun renderKotlinFqnStubs(declarations: List): Map = + StubRenderer(declarations.filter { it.bindingName != null && !it.isSuspend }).render() + +/** What precedes a property accessor's table key in its stub; see this file's KDoc. */ +internal const val PROPERTY_MARKER = "Kotlin property: " + +/** + * What precedes a constructor's table key in the docstring of the `__init__` it is stubbed as (issue + * #73): the key (`pkg.Class`) is a module attribute at run time but has no module-level `def` in the + * stub, so this is where a test matches the key to its stub, the same way [PROPERTY_MARKER] does for a + * property. An `@overload`ed `__init__` carries none: every member of an overload set is also stubbed + * as a module function under its `__`-suffixed key. + */ +internal const val CONSTRUCTOR_MARKER = "Kotlin constructor: " + +private val PROPERTY_KINDS = setOf("GETTER", "SETTER") + +/** Python's hard keywords (`keyword.kwlist`): none of these can be a parameter name. */ +private val PYTHON_KEYWORDS = setOf( + "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", + "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", + "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", + "try", "while", "with", "yield", +) + +private fun isIdentifier(name: String): Boolean = + name.isNotEmpty() && name !in PYTHON_KEYWORDS && + (name[0] == '_' || name[0].isLetter()) && + name.all { it == '_' || it.isLetterOrDigit() } + +private fun isWritableName(name: String?, reserved: String?): Boolean = + name != null && name != reserved && isIdentifier(name) + +/** What the docstring says: the declaration as Kotlin wrote it, which is the information the + * annotations do not always carry (a type that fell back to `Any`, a function type's receiver). */ +private fun kotlinSignatureOf(declaration: DeclarationModel): String { + val owner = declaration.receiver?.qualifiedName ?: declaration.owner + val parameters = declaration.parameters.joinToString(", ") { parameter -> + val type = parameter.type.qualifiedName + if (parameter.type.isNullable) "?" else "" + if (declaration.parameterNamesKnown && parameter.name != null) "${parameter.name}: $type" else type + } + return "$owner.${declaration.simpleName}($parameters): ${declaration.returnType.qualifiedName}" +} + +private fun builtinOf(qualifiedName: String): String? = when (qualifiedName) { + "kotlin.Boolean" -> "bool" + "kotlin.Byte", "kotlin.Short", "kotlin.Int", "kotlin.Long" -> "int" + "kotlin.Float", "kotlin.Double" -> "float" + "kotlin.String", "kotlin.Char" -> "str" + "kotlin.ByteArray" -> "bytes" + "kotlin.Unit" -> "None" + "kotlin.Any" -> ANY + "kotlin.Nothing" -> "$TYPING.NoReturn" + else -> null +} + +/** `python.multiplatform.reflection.TypeTag` to the Python type that actually crosses. */ +private fun builtinOfTag(tag: String?): String? = when (tag) { + "INT" -> "int" + "FLOAT" -> "float" + "BOOLEAN" -> "bool" + "STRING" -> "str" + "BYTES" -> "bytes" + "UNIT" -> "None" + else -> null +} + +private val FUNCTION_TYPE = Regex("""kotlin\.Function\d+""") + +/** A Kotlin class's qualified name split into its package and its (possibly nested) class path. */ +private class ClassRef(val pkg: String, val path: List) { + val top: String get() = path[0] +} + +/** + * `androidx.compose.ui.Modifier.Companion` -> package `androidx.compose.ui`, path `Modifier.Companion`. + * + * Kotlin's qualified name does not say where the package ends, so this uses the convention every + * JetBrains and AndroidX package follows: package segments are lower case, a class starts upper + * case. A name with no upper-case segment is taken to be a package plus one lower-case class. + */ +private fun classRefOf(qualifiedName: String): ClassRef? { + val segments = qualifiedName.split('.') + val firstClass = segments.indexOfFirst { it.isNotEmpty() && it[0].isUpperCase() } + val split = if (firstClass < 0) segments.size - 1 else firstClass + if (split <= 0) return null + return ClassRef(segments.take(split).joinToString("."), segments.drop(split)) +} + +private enum class Role { PARAM, RESULT } + +private class StubRenderer(declarations: List) { + + /** Everything that is a module attribute; properties are attributes of a proxy instead. */ + private val bound: List = declarations.filter { it.kind !in PROPERTY_KINDS } + + /** receiver type -> property name -> (getter, setter). A setter never stands without its getter. */ + private val properties: Map>> = run { + val accessors = declarations.filter { it.kind in PROPERTY_KINDS && it.receiver != null } + val setters = accessors.filter { it.kind == "SETTER" }.associateBy { it.bindingName!!.removeSuffix("=") } + accessors.filter { it.kind == "GETTER" } + .groupBy { it.receiver!!.qualifiedName } + .mapValues { (_, getters) -> + getters.sortedBy { it.simpleName }.associate { it.simpleName to (it to setters[it.bindingName]) } + } + } + + // ------------------------------------------------------------------------ what is taken + + private fun moduleOf(d: DeclarationModel) = d.bindingName!!.substringBeforeLast('.') + private fun leafOf(d: DeclarationModel) = d.bindingName!!.substringAfterLast('.') + + /** Mirrors `_Decl`: `base, _, suffix = leaf.partition('__')`, and the runtime indexes both. */ + private fun isSuffixed(leaf: String): Boolean { + val base = leaf.substringBefore("__") + return "__" in leaf && base.isNotEmpty() && leaf.length > base.length + 2 + } + + private fun baseOf(leaf: String): String = leaf.substringBefore("__") + + private val byModule: Map> = + bound.groupBy { moduleOf(it) }.toSortedMap() + + /** + * A class's own constructor, published in the class's own package under the class's name (issue + * #73). It does not take that name away from the class: the stub writes it as the class's + * `__init__`, so `TextFieldState(...)` type-checks as making a `TextFieldState` -- which is what the + * runtime's constructor callable returns. A factory function of the same name is not one of these. + * + * Only a constructor whose result crosses as a **handle**: a value class the boundary opens + * (`Meters`, `Dp`) is built and returned as its raw primitive, so `Meters(3.0)` gives a `float`, and + * an `__init__` would promise a `Meters`. That one stays a module function and its class `Any`, as + * before issue #73. + */ + private fun isOwnConstructor(d: DeclarationModel): Boolean = + d.isConstructor && d.kind == "FUNCTION" && d.receiver == null && d.returnBoundaryTag == "OBJECT" && + moduleOf(d) == d.owner && baseOf(leafOf(d)) == d.simpleName && + d.returnType.qualifiedName == "${d.owner}.${d.simpleName}" + + /** Every name a module defines at the top level, which a class stub must not take. A constructor's + * base name is its class's, so it takes nothing but its `__`-suffixed table-key spelling. */ + private val takenNames: Map> = byModule.mapValues { (_, entries) -> + entries.flatMap { d -> + leafOf(d).let { leaf -> + when { + isOwnConstructor(d) -> if (isSuffixed(leaf)) listOf(leaf) else emptyList() + isSuffixed(leaf) -> listOf(leaf, baseOf(leaf)) + else -> listOf(leaf) + } + } + }.toSet() + } + + /** Class qualified name -> its bound constructors, in table-key order; filled by [renderModuleDefs]. */ + private val constructorsByClass = sortedMapOf>() + + /** + * Module prefixes (every module and every directory above it) that name one directory on a + * case-insensitive filesystem together with a sibling (issue #44). In each such group the + * all-lower-case spelling keeps its path -- a Kotlin package -- and every other spelling is a + * class module that is emitted inside its parent package's `__init__.pyi` instead. + */ + private val foldedPrefixes: Set = run { + val prefixes = byModule.keys.flatMap { module -> + val parts = module.split('.') + parts.indices.map { parts.take(it + 1).joinToString(".") } + }.toSet() + prefixes.groupBy { it.lowercase() }.values + .filter { it.size > 1 } + .flatten() + .filter { '.' in it && it.substringAfterLast('.') != it.substringAfterLast('.').lowercase() } + .toSet() + } + + /** The shortest folded prefix of [module] (the module itself included), or `null` if it keeps its own path. */ + private fun foldedPrefixOf(module: String): String? { + val parts = module.split('.') + return parts.indices.map { parts.take(it + 1).joinToString(".") }.firstOrNull { it in foldedPrefixes } + } + + /** The modules that are still files: a folded class module is a class, not a module, for `classReference`. */ + private val moduleNames: Set = byModule.keys.filter { foldedPrefixOf(it) == null }.toSet() + + private val supertypes: Map> = bound + .filter { it.returnSupertypes.isNotEmpty() } + .groupBy { it.returnType.qualifiedName } + .mapValues { (_, entries) -> entries.first().returnSupertypes } + + // ----------------------------------------------------------------------- per-module output + + private class ClassNode(val name: String) { + val children = sortedMapOf() + var qualifiedName: String? = null + var bases: String? = null + var doc: String? = null + val members = mutableListOf() + } + + private inner class ModuleOut(val module: String) { + val imports = sortedSetOf() + val protocols = mutableListOf() + val classes = sortedMapOf() + val defs = mutableListOf() + + fun node(path: List): ClassNode { + var current = classes.getOrPut(path[0]) { ClassNode(path[0]) } + path.drop(1).forEach { current = current.children.getOrPut(it) { ClassNode(it) } } + return current + } + } + + private val modules = sortedMapOf() + private fun out(module: String): ModuleOut = modules.getOrPut(module) { ModuleOut(module) } + + /** Every class a signature mentioned, and the value class it wraps when it is one. */ + private val classes = sortedMapOf() + + // ------------------------------------------------------------------------------ types + + private fun annotate(type: KotlinTypeModel, tag: String?, role: Role, ctx: ModuleOut): String { + val core = core(type, tag, role, ctx) + return if (type.isNullable && core != "None") "$core | None" else core + } + + private fun argument(type: KotlinTypeModel?, role: Role, ctx: ModuleOut): String = + if (type == null) ANY else annotate(type, null, role, ctx) + + private fun core(type: KotlinTypeModel, tag: String?, role: Role, ctx: ModuleOut): String { + if (FUNCTION_TYPE.matches(type.qualifiedName)) { + if (type.arguments.isEmpty()) return "$TYPING.Callable[..., $ANY]" + val parameters = type.arguments.dropLast(1).joinToString(", ") { argument(it, Role.RESULT, ctx) } + return "$TYPING.Callable[[$parameters], ${argument(type.arguments.last(), Role.PARAM, ctx)}]" + } + val valueClass = type.valueClass + if (valueClass != null && tag != null && tag != "OBJECT") { + // Bound as its primitive: the raw number is what comes back, and what the raw module takes. + val raw = builtinOfTag(tag) ?: builtinOf(valueClass.underlying.qualifiedName) ?: ANY + if (role == Role.RESULT) return raw + val ref = classReference(type, ctx) + return if (ref == ANY) raw else "$ref | $raw" + } + builtinOf(type.qualifiedName)?.let { return it } + if (tag != null && tag != "OBJECT") builtinOfTag(tag)?.let { return it } + return classReference(type, ctx) + } + + /** The spelling of a class stub from [ctx]'s module, registering it so that it gets emitted. */ + private fun classReference(type: KotlinTypeModel, ctx: ModuleOut): String = + classReference(type.qualifiedName, type.valueClass, ctx) + + /** + * [classRefOf], except that a type nested in a Kotlin `object` whose constants and functions are + * a module of their own (issue #53) is a class **of that module**: `Alignment.Horizontal` is + * `Horizontal` in `androidx.compose.ui.Alignment`, not a class `Alignment` in `androidx.compose.ui` + * (a module and a class cannot share the name, which is why this used to fall back to `Any`). + * The object's own type (`Alignment`) has no such home and stays unresolved here. + */ + private fun resolveRef(qualifiedName: String): ClassRef? { + val ref = classRefOf(qualifiedName) ?: return null + val objectModule = "${ref.pkg}.${ref.top}" + if (ref.path.size < 2 || objectModule !in moduleNames || ref.top in takenNames[ref.pkg].orEmpty()) return ref + return ClassRef(objectModule, ref.path.drop(1)) + } + + private fun classReference(qualifiedName: String, valueClass: ValueClassModel?, ctx: ModuleOut): String { + val ref = resolveRef(qualifiedName) ?: return ANY + if (ref.top in takenNames[ref.pkg].orEmpty() || "${ref.pkg}.${ref.top}" in moduleNames) return ANY + if (qualifiedName !in classes || classes[qualifiedName] == null) classes[qualifiedName] = valueClass ?: classes[qualifiedName] + val path = ref.path.joinToString(".") + if (ref.pkg == ctx.module) return path + ctx.imports += ref.pkg + return "${ref.pkg}.$path" + } + + // --------------------------------------------------------------------------- parameters + + private fun parameters(d: DeclarationModel, ctx: ModuleOut, method: Boolean): String { + val hasReceiver = !method && d.receiver != null + val parameters = d.parameters + val reserved = when { + method -> "self" + hasReceiver -> RECEIVER_NAME + else -> null + } + val writable = parameters.map { d.parameterNamesKnown && isWritableName(it.name, reserved) } + // Everything up to and including the last unwritable name is positional-only. + val positionalOnlyEnd = writable.indexOfLast { !it } + val lastRequired = parameters.indexOfLast { !it.declaresDefault } + // `inspect.signature` on the binder's callable makes a required parameter that follows a + // defaulted one keyword-only (`KotlinSurface.kt`), and so does the stub -- except where a + // positional-only prefix is already in play, where the old rule (mark a default only when no + // required parameter follows it) keeps the prefix valid. + val keywordOnlyStart = if (positionalOnlyEnd >= 0) -1 else + parameters.indices.firstOrNull { index -> !parameters[index].declaresDefault && parameters.take(index).any { it.declaresDefault } } ?: -1 + + val rendered = mutableListOf() + if (method) rendered += "self" + if (hasReceiver) { + rendered += "$RECEIVER_NAME: " + annotate(d.receiver!!, d.receiverBoundaryTag, Role.PARAM, ctx) + if (positionalOnlyEnd < 0 && parameters.isNotEmpty()) rendered += "/" + } + parameters.forEachIndexed { index, parameter -> + if (index == keywordOnlyStart) rendered += "*" + val name = if (writable[index]) parameter.name!! else "__a$index" + val marked = if (positionalOnlyEnd >= 0) parameter.declaresDefault && index > lastRequired else parameter.declaresDefault + rendered += "$name: " + annotate(parameter.type, parameter.boundaryTag, Role.PARAM, ctx) + if (marked) " = ..." else "" + if (index == positionalOnlyEnd) rendered += "/" + } + if (parameters.isEmpty() && hasReceiver) rendered += "/" + return rendered.joinToString(", ") + } + + private fun result(d: DeclarationModel, ctx: ModuleOut): String = + annotate(d.returnType, d.returnBoundaryTag, Role.RESULT, ctx) + + // ---------------------------------------------------------------------- module level + + private fun renderDef(d: DeclarationModel, ctx: ModuleOut): String { + val leaf = leafOf(d) + // A Kotlin name that is a Python keyword (Compose's `Shadow.None`) cannot be written as an + // attribute or a def -- `None: Shadow` makes the whole file a syntax error. Leave it out and + // say how to reach it; the runtime still serves it under its Kotlin name. + if (leaf in PYTHON_KEYWORDS) { + return "# Kotlin: ${kotlinSignatureOf(d)} -- '$leaf' is a Python keyword; reach it with getattr(..., '$leaf')" + } + if (d.kind == "STATIC_GETTER") { + return buildString { + appendLine("$leaf: ${result(d, ctx)}") + append("\"\"\"Kotlin: ${kotlinSignatureOf(d)}\"\"\"") + } + } + return buildString { + appendLine("def $leaf(${parameters(d, ctx, method = false)}) -> ${result(d, ctx)}:") + appendLine(" \"\"\"Kotlin: ${kotlinSignatureOf(d)}\"\"\"") + append(" ...") + } + } + + /** + * A name that is a function (or an overload set) in its package **and** a module of its own (issue + * #78): Kotlin's `TextRange(2)` and `TextRange.Zero`. At run time the module is callable, so the + * stub says one attribute of the parent package whose type has `__call__` -- the function, its + * `@overload`s -- and the module's constants and functions as members. A bare `def` would hide + * `TextRange.Zero` from a checker; a bare module would hide `TextRange(2)`. The module's own file + * still exists for `import pkg.TextRange`. Member order is table-key order, as everywhere. + */ + private fun callableModule( + name: String, + calls: List, + members: List, + ctx: ModuleOut, + ): String { + if (name in PYTHON_KEYWORDS) return "# '$name' is a Python keyword; reach it with getattr(..., '$name')" + val protocol = "_${name}_callable_module" + val sortedMembers = members.sortedBy { it.bindingName } + ctx.protocols += buildString { + appendLine("class $protocol($TYPING.Protocol):") + appendLine(" \"\"\"Kotlin: the function $name, and the members of the module $name\"\"\"") + calls.forEachIndexed { index, d -> + if (calls.size > 1) appendLine(" @$TYPING.overload") + appendLine(" def __call__(${parameters(d, ctx, method = true)}) -> ${result(d, ctx)}: ..." + shadowed(index)) + } + val attributes = sortedMapOf>() + sortedMembers.forEach { d -> + val leaf = leafOf(d) + attributes.getOrPut(leaf) { mutableListOf() } += d + if (d.kind != "STATIC_GETTER" && isSuffixed(leaf)) attributes.getOrPut(baseOf(leaf)) { mutableListOf() } += d + } + attributes.forEach { (attribute, group) -> + if (!isIdentifier(attribute) || attribute == "__call__") { + appendLine(" # '$attribute' cannot be written as an attribute here; reach it with getattr(..., '$attribute')") + return@forEach + } + val first = group.first() + if (first.kind == "STATIC_GETTER") { + appendLine(" $attribute: ${result(first, ctx)}") + } else { + group.forEachIndexed { index, d -> + if (group.size > 1) appendLine(" @$TYPING.overload") + appendLine(" def $attribute(${parameters(d, ctx, method = true)}) -> ${result(d, ctx)}: ..." + shadowed(index)) + } + } + } + }.trimEnd() + return "$name: $protocol" + } + + private fun overloadDefs(name: String, group: List, ctx: ModuleOut): String = + if (name in PYTHON_KEYWORDS) "# '$name' is a Python keyword; reach it with getattr(..., '$name')" + else group.withIndex().joinToString("\n") { (index, d) -> + val signature = "def $name(${parameters(d, ctx, method = false)}) -> ${result(d, ctx)}: ..." + shadowed(index) + if (group.size > 1) "@$TYPING.overload\n$signature" else signature + } + + /** + * Python's one `int`/`float` and a class that fell back to `Any` cannot tell two Kotlin overloads + * apart (`lerp(Int, Int, Float)` against `lerp(Float, Float, Float)`), so a checker reports every + * later one as "will never be matched". The runtime dispatcher *does* tell them apart, and the + * explicit `name__Types` spelling reaches each one, so the diagnostic is silenced where it is a + * statement about Python's types and not about the declaration. + */ + private fun shadowed(index: Int): String = if (index > 0) " # type: ignore[overload-cannot-match]" else "" + + private fun renderModuleDefs() { + byModule.forEach { (module, entries) -> + val folded = foldedPrefixOf(module) + if (folded != null) { + renderFoldedModule(module, folded, entries) + return@forEach + } + val ctx = out(module) + // A constructor is its class's `__init__` (see [isOwnConstructor]) whenever the class has a + // stub to put it in; only its `__`-suffixed table-key spelling is also a module function, + // because the runtime serves it as one. A class with no stub keeps the old rendering. + val (constructors, sorted) = entries.sortedBy { it.bindingName } + .partition { isOwnConstructor(it) && classReference(it.returnType, ctx) != ANY } + // Issue #78: a name that is both a function here and a module of its own (`TextRange(2)` + // and `TextRange.Zero`) is one callable attribute, not a bare `def`; see [callableModule]. + val companions = sorted.filter { it.kind != "STATIC_GETTER" } + .map { baseOf(leafOf(it)) } + .filter { "$module.$it" in moduleNames } + .toSortedSet() + sorted.filter { it.kind == "STATIC_GETTER" || isSuffixed(leafOf(it)) || leafOf(it) !in companions } + .forEach { ctx.defs += renderDef(it, ctx) } + companions.forEach { base -> + val calls = sorted.filter { it.kind != "STATIC_GETTER" && baseOf(leafOf(it)) == base } + ctx.defs += callableModule(base, calls, byModule.getValue("$module.$base"), ctx) + } + constructors.forEach { d -> + if (isSuffixed(leafOf(d))) ctx.defs += renderDef(d, ctx) + constructorsByClass.getOrPut(d.returnType.qualifiedName) { mutableListOf() } += d + } + // The base name of an overload set: the binding layer serves it, so the stub says it. + val unsuffixedLeaves = sorted.filter { !isSuffixed(leafOf(it)) }.map { leafOf(it) }.toSet() + sorted.filter { it.kind != "STATIC_GETTER" && isSuffixed(leafOf(it)) } + .groupBy { baseOf(leafOf(it)) } + .toSortedMap() + .forEach { (base, group) -> + if (base !in unsuffixedLeaves && base !in companions) ctx.defs += overloadDefs(base, group, ctx) + } + } + } + + /** + * A class module that collides case-insensitively with a sibling path (issue #44): its functions + * become static members of the class of that name in the parent package's module, nested for the + * parts of [module] below [folded]. The runtime import path is unchanged; only the stub layout is. + * If the parent already defines a function of that name, the class cannot be declared beside it + * (the runtime resolves the name to the function, as for `classReference`), so nothing is emitted. + */ + private fun renderFoldedModule(module: String, folded: String, entries: List) { + val parentModule = folded.substringBeforeLast('.') + val className = folded.substringAfterLast('.') + if (className in takenNames[parentModule].orEmpty()) return + val ctx = out(parentModule) + val path = listOf(className) + module.removePrefix(folded).split('.').filter { it.isNotEmpty() } + val node = ctx.node(path) + val sorted = entries.sortedBy { it.bindingName } + val defs = sorted.map { renderDef(it, ctx) } + val unsuffixedLeaves = sorted.filter { !isSuffixed(leafOf(it)) }.map { leafOf(it) }.toSet() + val overloads = sorted.filter { it.kind != "STATIC_GETTER" && isSuffixed(leafOf(it)) } + .groupBy { baseOf(leafOf(it)) } + .toSortedMap() + .mapNotNull { (base, group) -> if (base !in unsuffixedLeaves) overloadDefs(base, group, ctx) else null } + (defs + overloads).forEach { text -> + node.members += text.lines().joinToString("\n") { if (it.startsWith("def ")) "@staticmethod\n$it" else it } + } + } + + // --------------------------------------------------------------------------- classes + + /** The class a declaration's extension receiver names, when the runtime attaches it as a method. */ + private fun methodReceiver(d: DeclarationModel): String? { + val receiver = d.receiver ?: return null + if (builtinOf(receiver.qualifiedName) != null || FUNCTION_TYPE.matches(receiver.qualifiedName)) return null + val tag = d.receiverBoundaryTag + if (tag != null && tag != "OBJECT") return null + if (tag == null && receiver.valueClass != null) return null + return receiver.qualifiedName + } + + private val extensionsByReceiver: Map> = bound + .filter { it.kind != "STATIC_GETTER" } + .mapNotNull { d -> methodReceiver(d)?.let { it to d } } + .groupBy({ it.first }, { it.second }) + + /** + * Issue #71: every Kotlin supertype that has a stub class is a base, not just the first one + * (`Arrangement.HorizontalOrVertical` is both a `Horizontal` and a `Vertical`). The ancestry is + * nearest-first and transitive, so a base is dropped when another candidate's own known ancestry + * already contains it (Python would reject `class C(A, B)` with B a subclass of A as an + * inconsistent MRO, and the repeat adds nothing); what remains keeps the ancestry's order. + * `kotlin.Any` and `java.lang.*` are `object`, which every Python class already has. + */ + private fun stubBasesOf(qualifiedName: String, ctx: ModuleOut): List { + val candidates = supertypes[qualifiedName].orEmpty() + .filter { it != qualifiedName && it != "kotlin.Any" && !it.startsWith("java.lang.") && classRefOf(it) != null } + .distinct() + val kept = candidates.filter { base -> + candidates.none { other -> other != base && base in supertypes[other].orEmpty() && other !in supertypes[base].orEmpty() } + } + return kept.map { classReference(it, null, ctx) }.filter { it != ANY }.distinct() + } + + private fun renderClass(qualifiedName: String) { + val ref = resolveRef(qualifiedName) ?: return + val ctx = out(ref.pkg) + val node = ctx.node(ref.path) + node.qualifiedName = qualifiedName + val valueClass = classes[qualifiedName] + node.doc = if (valueClass != null) { + "Kotlin value class $qualifiedName over ${valueClass.underlying.qualifiedName}. As a parameter the raw " + + "primitive is also accepted (the binding layer's allowlist decides, at run time); a result is the raw primitive." + } else { + "Kotlin: $qualifiedName" + } + stubBasesOf(qualifiedName, ctx).takeIf { it.isNotEmpty() }?.let { node.bases = it.joinToString(", ") } + + constructorsByClass[qualifiedName]?.let { group -> + group.forEachIndexed { index, d -> + node.members += if (group.size == 1) { + "def __init__(${parameters(d, ctx, method = true)}) -> None:\n" + + " \"\"\"$CONSTRUCTOR_MARKER${d.bindingName} (${kotlinSignatureOf(d)})\"\"\"\n" + + " ..." + } else { + "@$TYPING.overload\ndef __init__(${parameters(d, ctx, method = true)}) -> None: ..." + shadowed(index) + } + } + } + + val attributes = sortedMapOf>() + extensionsByReceiver[qualifiedName].orEmpty().sortedBy { it.bindingName }.forEach { d -> + val leaf = leafOf(d) + attributes.getOrPut(leaf) { mutableListOf() } += d + if (isSuffixed(leaf)) attributes.getOrPut(baseOf(leaf)) { mutableListOf() } += d + } + attributes.forEach { (attribute, group) -> + if (!isIdentifier(attribute) || attribute in node.children) return@forEach + val protocol = "_" + ref.path.joinToString("_") + "_" + attribute + ctx.protocols += buildString { + appendLine("class $protocol($TYPING.Protocol):") + group.forEachIndexed { index, d -> + if (group.size > 1) appendLine(" @$TYPING.overload") + append(" def __call__(${parameters(d, ctx, method = true)}) -> ${result(d, ctx)}: ..." + shadowed(index)) + if (index != group.lastIndex) appendLine() + } + } + node.members += "$attribute: $TYPING.ClassVar[$protocol]" + } + properties[qualifiedName].orEmpty().forEach { (name, accessors) -> + val (getter, setter) = accessors + if (!isIdentifier(name) || name in node.children || name in attributes) { + node.members += propertyNotStubbed(getter, setter, "'$name' cannot be written as an attribute here") + return@forEach + } + node.members += renderProperty(name, getter, setter, ctx) + } + } + + private fun renderProperty(name: String, getter: DeclarationModel, setter: DeclarationModel?, ctx: ModuleOut): String = buildString { + appendLine("@property") + appendLine("def $name(self) -> ${result(getter, ctx)}:") + appendLine(" \"\"\"$PROPERTY_MARKER${getter.bindingName} (${propertySignatureOf(getter)})\"\"\"") + append(" ...") + if (setter != null) { + val parameter = setter.parameters.single() + appendLine() + appendLine("@$name.setter") + appendLine("def $name(self, value: ${annotate(parameter.type, parameter.boundaryTag, Role.PARAM, ctx)}) -> None:") + appendLine(" \"\"\"$PROPERTY_MARKER${setter.bindingName}\"\"\"") + append(" ...") + } + } + + private fun propertyNotStubbed(getter: DeclarationModel, setter: DeclarationModel?, why: String): String = + listOfNotNull(getter, setter).joinToString("\n") { "# $PROPERTY_MARKER${it.bindingName} is not stubbed: $why" } + + private fun propertySignatureOf(getter: DeclarationModel): String { + val type = getter.returnType.qualifiedName + if (getter.returnType.isNullable) "?" else "" + return "${getter.receiver!!.qualifiedName}.${getter.simpleName}: $type" + } + + /** + * Every receiver that has properties gets its stub class -- even one no signature mentions, since + * a value of it can still reach Python (a `kotlin.Any?` result, a supertype's slot). One whose + * class cannot be stubbed gets the marker comment instead; see this file's KDoc. + */ + private fun registerPropertyOwners() { + properties.forEach { (receiver, byName) -> + val ref = classRefOf(receiver) + val home = ref?.pkg ?: receiver.substringBeforeLast('.', "") + val ctx = out(home) + if (ref != null && classReference(receiver, null, ctx) != ANY) return@forEach + byName.values.forEach { (getter, setter) -> + ctx.defs += propertyNotStubbed(getter, setter, "${receiver.substringAfterLast('.')} has no stub class (its name is taken in its module)") + } + } + } + + private fun renderClasses() { + val done = mutableSetOf() + while (true) { + val next = classes.keys.firstOrNull { it !in done } ?: break + done += next + renderClass(next) + } + } + + private fun renderNode(node: ClassNode, indent: String, into: StringBuilder) { + into.append(indent).append("class ").append(node.name) + node.bases?.let { into.append('(').append(it).append(')') } + into.appendLine(":") + into.append(indent).append(" \"\"\"").append(node.doc ?: "Kotlin: container of nested classes").appendLine("\"\"\"") + node.members.forEach { member -> + member.lines().forEach { into.append(indent).append(" ").appendLine(it) } + } + node.children.values.forEach { renderNode(it, "$indent ", into) } + } + + // ---------------------------------------------------------------------------- assembly + + fun render(): Map { + renderModuleDefs() + registerPropertyOwners() + renderClasses() + return modules.filterValues { it.defs.isNotEmpty() || it.classes.isNotEmpty() } + .mapValues { (_, ctx) -> + buildString { + appendLine(HEADER) + appendLine() + appendLine("import typing as $TYPING") + ctx.imports.forEach { appendLine("import $it") } + appendLine() + ctx.protocols.forEach { appendLine(it); appendLine() } + ctx.classes.values.forEach { node -> + renderNode(node, "", this) + appendLine() + } + ctx.defs.forEach { appendLine(it); appendLine() } + } + } + .mapKeys { (module, _) -> module.replace('.', '/') + "/__init__.pyi" } + } +} + +/** + * Groups of stub paths that name one file on a case-insensitive filesystem. A Kotlin package and a + * class may differ only by case (`graphics.shadow`, `graphics.Shadow`), which is legal on the JVM and + * in Python and not on macOS or Windows, where `PythonStubsTask` would otherwise overwrite one with + * the other without a word. + */ +internal fun caseCollidingPaths(paths: Collection): List> = + paths.groupBy { it.lowercase() }.values.filter { it.size > 1 }.map { it.sorted() } diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PythonStubsTask.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PythonStubsTask.kt new file mode 100644 index 000000000..fd57601ba --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PythonStubsTask.kt @@ -0,0 +1,122 @@ +package python.multiplatform.gradle.stubs + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.workers.WorkerExecutor +import python.multiplatform.gradle.artifact.ArtifactScanner +import python.multiplatform.gradle.artifact.scanKlibIsolated +import javax.inject.Inject + +/** + * `docs/design/pyi-generation-design.md`'s generator: `.pyi` stubs for the surface the bindings expose. + * + * ### Why this is a Gradle task at all + * + * The runtime binds Kotlin generically, so **nothing an editor can see is ever enumerated**. The stubs + * are what pay that back: they carry the fully enumerated surface, under the Kotlin names, while the + * runtime enumerates nothing. Making it Pythonic is `pythonx`'s job, not this plugin's. That + * generation belongs to the Gradle plugin, the way PyREPL's `createKotlinMetaPackageForPython` did + * it, is settled (`agent-rules.md` §12) and this task is it. + * + * ### What is kept from PyREPL and what is not + * + * §1.4's table. Kept: one `__init__.pyi` per package, `...` bodies, the `prepareKotlinIdeaImport` + * hook. Rejected: `def f(self, *args, **kwargs)` (parameters are the point), the JNI-ish type map + * (`jint` is not a Python type), dropping names containing `-` (that discards 52 of the 177 chainable + * `Modifier` extensions), `class StringsKt:` for a file facade, and `meta_json.lock` (Gradle's own + * up-to-date checking does this, and PyREPL's was disabled anyway). + * + * ### Where the output goes + * + * `build/generated/pythonStubs//`, not the source tree. PyREPL wrote into + * `src//generated/meta/` and got away with it because chaquopy defines a Python + * source-directory concept a plugin can add to; this repository has no equivalent, and **nothing + * compiles a `.pyi`**, so there is no `srcDir(task)` to register it with (§6.2). Registering the + * directory with an interpreter is per-environment -- a chaquopy `sourceSets.srcDirs`, a `.pth` in a + * venv, PyCharm's interpreter path list -- and **none of that was verified**. + * + * ### One walk per task, two tasks + * + * This scans the same jars `PythonArtifactBindingsTask` does, in a separate task with its own + * output. That is two walks of the same artefacts per build, and it is not the failure §2.2 rejects + * ("two readers of the same jars that can disagree"): both call `ArtifactScanner`, which produces + * the bindings and the model together and cannot answer the two differently. What is paid is time, + * and what it buys is that a consumer who wants stubs and no bindings, or the reverse, gets one task. + */ +abstract class PythonStubsTask : DefaultTask() { + + @get:Inject + abstract val workerExecutor: WorkerExecutor + + /** Every file the walked configuration resolved. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.NAME_ONLY) + abstract val artifacts: ConfigurableFileCollection + + /** Package or class names to stub; the same namespace matching the binder uses. */ + @get:Input + abstract val includePrefixes: ListProperty + + /** + * The klib reader for the isolated worker, exactly as + * [python.multiplatform.gradle.artifact.PythonArtifactBindingsTask.klibReaderClasspath] -- including + * why it is `@Internal` rather than an input. + */ + @get:Internal + abstract val klibReaderClasspath: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val destination = outputDirectory.get().asFile + // Rebuilt from scratch, exactly as `PythonArtifactBindingsTask` is: a dependency that was + // removed, or an include prefix that stopped matching, must take its stub with it. + destination.deleteRecursively() + destination.mkdirs() + + val includes = includePrefixes.get() + val jars = artifacts.files.filter { it.isFile && it.name.endsWith(".jar") } + val declarations = jars.sortedBy { it.name }.flatMap { jar -> + ArtifactScanner.scanDeclarations(jar, includes, classpath = jars) + } + artifacts.files.filter { it.isFile && it.name.endsWith(".klib") }.sortedBy { it.name }.flatMap { klib -> + // A Kotlin/Native target resolves klibs where a JVM target resolves jars, and both + // producers now build the same `DeclarationModel` -- so a stub is not a JVM-only product. + // `KlibScanner`'s KDoc lists what a klib can and cannot fill; the fields it cannot are + // exactly the ones it declines on, and a declined declaration is not stubbed. + // + // Not a direct `KlibScanner.scanKlibDeclarations` call: same classloader-scope collision + // `PythonArtifactBindingsTask`'s KDoc documents, worked around the same way. + workerExecutor.scanKlibIsolated(klib, includes, temporaryDir, klibReaderClasspath.files).declarations + } + + val files = renderKotlinFqnStubs(declarations) + caseCollidingPaths(files.keys).forEach { group -> + logger.warn( + "python-multiplatform: stub paths differ only by case and name one file on a case-insensitive " + + "filesystem (macOS, Windows); the last one written wins: $group", + ) + } + files.forEach { (path, content) -> + val file = destination.resolve(path) + file.parentFile.mkdirs() + file.writeText(content) + } + + val bound = declarations.count { it.bindingName != null } + logger.info( + "python-multiplatform: ${files.size} stub file(s) for $bound bound declaration(s); " + + "${declarations.size - bound} declined and not stubbed", + ) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/AcquiredHeaderVersionTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/AcquiredHeaderVersionTest.kt new file mode 100644 index 000000000..c0dc4a075 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/AcquiredHeaderVersionTest.kt @@ -0,0 +1,71 @@ +package python.multiplatform.gradle + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Issue #47: every acquired CPython must be the version `pythonVersion` names. iOS shipped 3.14.6 + * (BeeWare `3.14-b10`) while every other target shipped 3.14.7, and nothing noticed. + */ +class AcquiredHeaderVersionTest { + + private fun patchlevel(version: String) = """ + /* Version parsed out into numeric values */ + #define PY_MAJOR_VERSION 3 + #define PY_VERSION "$version" + #define PY_VERSION_HEX 0x030e07f0 + """.trimIndent() + + @Test + fun parsesPyVersionOutOfPatchlevelH() { + assertEquals("3.14.7", AcquiredHeaderVersion.parse(patchlevel("3.14.7"))) + assertEquals("3.15.0rc1", AcquiredHeaderVersion.parse(patchlevel("3.15.0rc1"))) + } + + @Test + fun aHeaderWithoutPyVersionIsAnError() { + assertFailsWith { AcquiredHeaderVersion.parse("#define PY_MAJOR_VERSION 3") } + } + + @Test + fun matchingTreePassesAndMismatchNamesTheTargetAndBothVersions() { + val root = kotlin.io.path.createTempDirectory("acquired").toFile() + try { + for (t in CPythonIncludeLayout.allTargets) { + val v = if (t.startsWith("ios")) "3.14.6" else "3.14.7" + root.resolve(CPythonIncludeLayout.relativePath(t, CPythonFlavour.GIL, "3.14.7")) + .also { it.mkdirs() }.resolve("patchlevel.h").writeText(patchlevel(v)) + } + val failures = AcquiredHeaderVersion.mismatches(root, "3.14.7") + assertEquals(CPythonIncludeLayout.iosTargets.toSet(), failures.keys) + assertTrue(failures.getValue("ios-arm64").contains("3.14.6")) + val e = assertFailsWith { AcquiredHeaderVersion.requireAll(root, "3.14.7") } + assertTrue(e.message!!.contains("ios-arm64") && e.message!!.contains("3.14.6") && e.message!!.contains("3.14.7")) + } finally { + root.deleteRecursively() + } + } + + /** + * The real acquired trees (`python-multiplatform/build/python-standalone/extracted//`), + * as extracted by `downloadAllPythonBuilds`. Skipped only when nothing was acquired at all; a + * target that is present must match, and iOS must be present once anything is. + */ + @Test + fun everyAcquiredTargetReportsThePinnedPythonVersion() { + val gradleProps = File("../gradle.properties").readLines() + val pinned = gradleProps.first { it.startsWith("pythonVersion=") }.substringAfter('=').trim() + val root = File("../python-multiplatform/build/python-standalone/extracted/$pinned") + if (!root.isDirectory) { + println("SKIPPED: nothing acquired under $root; run :python-multiplatform:downloadAllPythonBuilds") + return + } + val present = CPythonIncludeLayout.allTargets.filter { AcquiredHeaderVersion.patchlevelFile(root, it, pinned).isFile } + assertTrue("ios-arm64" in present, "iOS headers missing under $root") + val failures = AcquiredHeaderVersion.mismatches(root, pinned) + assertTrue(failures.isEmpty(), "acquired headers disagree with pythonVersion=$pinned: $failures") + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/ArtifactTargetsWiringTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/ArtifactTargetsWiringTest.kt new file mode 100644 index 000000000..46ea22196 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/ArtifactTargetsWiringTest.kt @@ -0,0 +1,113 @@ +package python.multiplatform.gradle + +import org.gradle.testfixtures.ProjectBuilder +import python.multiplatform.gradle.artifact.PythonArtifactBindingsTask +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * ROADMAP §16f item 1: the walker was verified against exactly one (configuration, source set) + * pair, because `artifactConfiguration`/`artifactSourceSet` are single-valued. + * + * `artifactTargets` makes the answer plural without deriving it. Deriving it is what this plugin + * cannot do -- it carries no Kotlin Gradle Plugin types (see `TEST_WORD`), so it cannot ask a + * target what its compile classpath is called, and Android's variant-aware configurations do not + * follow the `CompileClasspath` convention a derivation would have to assume. + * + * The two things that have to hold once it is plural: a build that already sets the singular pair + * keeps the task name it invokes (`generatePythonArtifactBindings`, unsuffixed), and two pairs + * produce two tasks writing to two directories rather than one task silently winning. + */ +class ArtifactTargetsWiringTest { + + private fun projectWith(sourceSets: List, configurations: List): org.gradle.api.Project { + val project = ProjectBuilder.builder().build() + val container = project.container(DummySourceSet::class.java) { name -> DummySourceSet(name, project) } + sourceSets.forEach { container.create(it) } + project.extensions.add("kotlin", DummyKotlinExt(container)) + configurations.forEach { project.configurations.create(it) } + return project + } + + private fun extensionOf(project: org.gradle.api.Project): PythonBindingsExtension = + project.extensions.create("pythonBindingsUnderTest", PythonBindingsExtension::class.java) + + @Test + fun theSingularPairKeepsItsUnsuffixedTaskNameSoAnExistingBuildStillInvokesIt() { + val project = projectWith(listOf("desktopMain"), listOf("desktopCompileClasspath")) + val extension = extensionOf(project) + extension.artifactIncludePackages.set(listOf("junit.runner")) + extension.artifactConfiguration.set("desktopCompileClasspath") + extension.artifactSourceSet.set("desktopMain") + + PythonBindingsPlugin().configureArtifactBindings(project, extension) + + assertNotNull( + project.tasks.findByName("generatePythonArtifactBindings"), + "the name an existing build already invokes: ${project.tasks.names}", + ) + assertNull( + project.tasks.findByName("generatePythonArtifactBindingsDesktopMain"), + "a single pair must not be renamed: ${project.tasks.names}", + ) + } + + @Test + fun twoPairsEachGetTheirOwnTaskAndTheirOwnOutputDirectory() { + val project = projectWith( + sourceSets = listOf("desktopMain", "androidNativeArm64Main"), + configurations = listOf("desktopCompileClasspath", "androidNativeArm64CompileClasspath"), + ) + val extension = extensionOf(project) + extension.artifactIncludePackages.set(listOf("junit.runner")) + extension.artifactTargets.set( + mapOf( + "desktopCompileClasspath" to "desktopMain", + "androidNativeArm64CompileClasspath" to "androidNativeArm64Main", + ), + ) + + PythonBindingsPlugin().configureArtifactBindings(project, extension) + + val bindings = project.tasks.names.filter { it.startsWith("generatePythonArtifactBindings") } + assertEquals(2, bindings.size, "one task per pair, got $bindings") + + // Named after the source set rather than the configuration: the source set is already what + // distinguishes the output directories, so the two names cannot disagree about which pair + // a task belongs to. + val outputs = bindings.map { name -> + (project.tasks.getByName(name) as PythonArtifactBindingsTask) + .outputDirectory.get().asFile.path + } + assertEquals(outputs.size, outputs.toSet().size, "two pairs wrote to one directory: $outputs") + assertTrue(outputs.any { it.endsWith("pythonArtifactBindings/desktopMain") }, outputs.toString()) + assertTrue( + outputs.any { it.endsWith("pythonArtifactBindings/androidNativeArm64Main") }, + outputs.toString(), + ) + } + + @Test + fun theSingularPairAndTheMapCanBeSetTogetherWithoutRegisteringTheSameTaskTwice() { + val project = projectWith( + sourceSets = listOf("desktopMain", "androidNativeArm64Main"), + configurations = listOf("desktopCompileClasspath", "androidNativeArm64CompileClasspath"), + ) + val extension = extensionOf(project) + extension.artifactIncludePackages.set(listOf("junit.runner")) + extension.artifactConfiguration.set("desktopCompileClasspath") + extension.artifactSourceSet.set("desktopMain") + extension.artifactTargets.set(mapOf("androidNativeArm64CompileClasspath" to "androidNativeArm64Main")) + + PythonBindingsPlugin().configureArtifactBindings(project, extension) + + assertEquals( + 2, + project.tasks.names.count { it.startsWith("generatePythonArtifactBindings") }, + "the singular pair folds into the map rather than duplicating it: ${project.tasks.names}", + ) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/CPythonIncludeDirectoriesTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/CPythonIncludeDirectoriesTest.kt new file mode 100644 index 000000000..971905c64 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/CPythonIncludeDirectoriesTest.kt @@ -0,0 +1,183 @@ +package python.multiplatform.gradle + +import org.gradle.api.GradleException +import org.gradle.testfixtures.ProjectBuilder +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** Issue #46: per-target CPython include directories as public providers. */ +class CPythonIncludeDirectoriesTest { + + private val version = "3.14.7" + + @Test + fun layoutMatchesTheObservedAcquisitionTree() { + val l = CPythonIncludeLayout + assertEquals("macos-aarch64/python/include/python3.14", l.relativePath("macos-aarch64", CPythonFlavour.GIL, version)) + assertEquals("linux-x86_64-freethreaded/python/include/python3.14t", l.relativePath("linux-x86_64", CPythonFlavour.FREE_THREADED, version)) + assertEquals("windows-x86_64/python/include", l.relativePath("windows-x86_64", CPythonFlavour.GIL, version)) + assertEquals("windows-x86_64-freethreaded/python/include", l.relativePath("windows-x86_64", CPythonFlavour.FREE_THREADED, version)) + assertEquals("android-aarch64/prefix/include/python3.14", l.relativePath("android-aarch64", CPythonFlavour.GIL, version)) + assertEquals("ios/Python.xcframework/ios-arm64/include/python3.14", l.relativePath("ios-arm64", CPythonFlavour.GIL, version)) + } + + @Test + fun freeThreadedIsDistinctFromDefaultOnEveryDesktopTarget() { + for (t in CPythonIncludeLayout.desktopTargets) { + assertNotEquals( + CPythonIncludeLayout.relativePath(t, CPythonFlavour.GIL, version), + CPythonIncludeLayout.relativePath(t, CPythonFlavour.FREE_THREADED, version), + ) + } + } + + @Test + fun freeThreadedDoesNotExistOffDesktop() { + assertFalse(CPythonIncludeLayout.supports("android-aarch64", CPythonFlavour.FREE_THREADED)) + assertFalse(CPythonIncludeLayout.supports("ios-arm64", CPythonFlavour.FREE_THREADED)) + assertFailsWith { + CPythonIncludeLayout.relativePath("ios-arm64", CPythonFlavour.FREE_THREADED, version) + } + } + + @Test + fun downloadTaskNamesMatchTheRootBuild() { + assertEquals("downloadPython_macos_aarch64", CPythonIncludeLayout.downloadTaskName("macos-aarch64")) + assertEquals("downloadPython_android_x86_64", CPythonIncludeLayout.downloadTaskName("android-x86_64")) + assertEquals("downloadPython_ios", CPythonIncludeLayout.downloadTaskName("ios-arm64_x86_64-simulator")) + } + + /** Registry over fake extraction tasks that really create `Python.h`; only [configured] desktop flavour is extracted. */ + private fun fixture(configured: CPythonFlavour): Pair { + val project = ProjectBuilder.builder().build() + val root = project.layout.projectDirectory.dir("extracted/$version") + val tasks = CPythonIncludeLayout.allTargets.associateWith { target -> + // Android and iOS are always GIL; desktop follows the configured flavour. + val flavour = if (target in CPythonIncludeLayout.desktopTargets) configured else CPythonFlavour.GIL + project.tasks.register("fake_${target.replace('-', '_')}") { + doLast { + root.dir(CPythonIncludeLayout.relativePath(target, flavour, version)).asFile + .also { it.mkdirs() }.resolve("Python.h").writeText("/* $target */") + } + } + } + val dirs = CPythonIncludeDirectories(root, version) { target, flavour -> + val extracted = if (target in CPythonIncludeLayout.desktopTargets) configured else CPythonFlavour.GIL + if (flavour == extracted) tasks[target] else null + } + return project to dirs + } + + @Test + fun everyTargetResolvesToADistinctDirectoryHoldingPythonH() { + val (project, dirs) = fixture(CPythonFlavour.GIL) + val seen = mutableSetOf() + for (t in dirs.targets) { + val producer = project.tasks.getByName("fake_${t.replace('-', '_')}") + val provider = dirs.includeDir(t) + // A consumer that takes the provider as an input must depend on the extraction task. + val consumer = project.tasks.register("use_${t.replace('-', '_')}") { inputs.dir(provider) }.get() + assertTrue( + producer in consumer.taskDependencies.getDependencies(consumer), + "$t: provider does not carry its extraction task", + ) + producer.actions.forEach { it.execute(producer) } + val dir = provider.get().asFile + assertTrue(File(dir, "Python.h").isFile, "$t: Python.h missing in $dir") + assertTrue(seen.add(dir), "$t: duplicate directory $dir") + } + } + + @Test + fun theOtherFlavourFailsLoudlyNamingTheProperty() { + val (_, dirs) = fixture(CPythonFlavour.GIL) + assertFalse(dirs.isAvailable("macos-aarch64", CPythonFlavour.FREE_THREADED)) + val e = assertFailsWith { dirs.includeDir("macos-aarch64", CPythonFlavour.FREE_THREADED) } + assertTrue("pythonFreeThreaded" in e.message.orEmpty()) + assertFailsWith { dirs.includeDir("ios-arm64", CPythonFlavour.FREE_THREADED) } + } + + @Test + fun configuredFreeThreadedExposesFreeThreadedKey() { + val (_, dirs) = fixture(CPythonFlavour.FREE_THREADED) + assertTrue(dirs.isAvailable("linux-x86_64", CPythonFlavour.FREE_THREADED)) + assertTrue(dirs.includeDir("linux-x86_64", CPythonFlavour.FREE_THREADED).get().asFile.path.endsWith("python3.14t")) + } + + // ---- Issue #56: link-library directory ---- + + @Test + fun linkLibraryLayoutMatchesTheObservedTree() { + val l = CPythonIncludeLayout + assertEquals("android-aarch64/prefix/lib", l.linkLibraryDirRelativePath("android-aarch64", CPythonFlavour.GIL)) + assertEquals("android-x86_64/prefix/lib", l.linkLibraryDirRelativePath("android-x86_64", CPythonFlavour.GIL)) + assertEquals("windows-x86_64/python/libs", l.linkLibraryDirRelativePath("windows-x86_64", CPythonFlavour.GIL)) + assertEquals("windows-x86_64-freethreaded/python/libs", l.linkLibraryDirRelativePath("windows-x86_64", CPythonFlavour.FREE_THREADED)) + assertEquals("libpython3.14.so", l.linkLibraryFileName("android-aarch64", CPythonFlavour.GIL, version)) + assertEquals("python314.lib", l.linkLibraryFileName("windows-x86_64", CPythonFlavour.GIL, version)) + assertEquals("python314t.lib", l.linkLibraryFileName("windows-x86_64", CPythonFlavour.FREE_THREADED, version)) + } + + @Test + fun linkLibraryIsRequiredOnlyOnAndroidAndWindows() { + val l = CPythonIncludeLayout + for (t in listOf("android-aarch64", "android-x86_64", "windows-x86_64")) assertTrue(l.isLinkRequired(t), t) + for (t in listOf("macos-aarch64", "macos-x86_64", "linux-x86_64", "ios-arm64", "ios-arm64_x86_64-simulator")) { + assertFalse(l.isLinkRequired(t), t) + } + } + + @Test + fun linkLibraryFreeThreadedOnlyOnWindows() { + val l = CPythonIncludeLayout + assertTrue(l.supportsLinkLibrary("windows-x86_64", CPythonFlavour.FREE_THREADED)) + assertFalse(l.supportsLinkLibrary("android-aarch64", CPythonFlavour.FREE_THREADED)) + assertFalse(l.supportsLinkLibrary("macos-aarch64", CPythonFlavour.GIL)) + assertFailsWith { l.linkLibraryDirRelativePath("macos-aarch64", CPythonFlavour.GIL) } + } + + @Test + fun linkLibraryProvidersCarryTheExtractionTaskAndResolve() { + val (project, dirs) = fixture(CPythonFlavour.GIL) + val root = project.layout.projectDirectory.dir("extracted/$version") + for (t in dirs.targets.filter { dirs.isLinkRequired(it) }) { + val producer = project.tasks.getByName("fake_${t.replace('-', '_')}") + val dirProvider = dirs.libraryDir(t) + val fileProvider = dirs.libraryFile(t) + val consumer = project.tasks.register("link_${t.replace('-', '_')}") { inputs.dir(dirProvider) }.get() + assertTrue(producer in consumer.taskDependencies.getDependencies(consumer), "$t: libraryDir lacks task") + val fileConsumer = project.tasks.register("linkf_${t.replace('-', '_')}") { inputs.file(fileProvider) }.get() + assertTrue(producer in fileConsumer.taskDependencies.getDependencies(fileConsumer), "$t: libraryFile lacks task") + // Simulate the extraction creating the library. + val expected = root.dir(CPythonIncludeLayout.linkLibraryDirRelativePath(t, CPythonFlavour.GIL)).asFile + expected.mkdirs() + File(expected, CPythonIncludeLayout.linkLibraryFileName(t, CPythonFlavour.GIL, version)).writeText("lib") + assertEquals(expected, dirProvider.get().asFile) + assertEquals(expected.resolve(CPythonIncludeLayout.linkLibraryFileName(t, CPythonFlavour.GIL, version)), fileProvider.get().asFile) + } + } + + @Test + fun linkLibraryFailsLoudlyWhenNotRequiredOrWrongFlavour() { + val (_, dirs) = fixture(CPythonFlavour.GIL) + assertFalse(dirs.isLinkRequired("macos-aarch64")) + assertFailsWith { dirs.libraryDir("macos-aarch64") } + assertFailsWith { dirs.libraryFile("ios-arm64") } + assertFalse(dirs.isLinkAvailable("windows-x86_64", CPythonFlavour.FREE_THREADED)) + val e = assertFailsWith { dirs.libraryDir("windows-x86_64", CPythonFlavour.FREE_THREADED) } + assertTrue("pythonFreeThreaded" in e.message.orEmpty()) + assertFailsWith { dirs.libraryDir("android-aarch64", CPythonFlavour.FREE_THREADED) } + } + + @Test + fun linkLibraryWindowsFreeThreadedWhenConfigured() { + val (_, dirs) = fixture(CPythonFlavour.FREE_THREADED) + assertTrue(dirs.isLinkAvailable("windows-x86_64", CPythonFlavour.FREE_THREADED)) + assertTrue(dirs.libraryFile("windows-x86_64", CPythonFlavour.FREE_THREADED).get().asFile.name == "python314t.lib") + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/EmbeddedPythonVersionTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/EmbeddedPythonVersionTest.kt new file mode 100644 index 000000000..09d1c5512 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/EmbeddedPythonVersionTest.kt @@ -0,0 +1,77 @@ +package python.multiplatform.gradle + +import org.gradle.api.attributes.Attribute +import org.gradle.testfixtures.ProjectBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** Issue #61: the embedded CPython version as extension, attributes and resource. */ +class EmbeddedPythonVersionTest { + + private fun attr(name: String) = Attribute.of(name, String::class.java) + + @Test + fun propertiesRoundTrip() { + val v = EmbeddedPythonVersion("3.14.7", true) + val back = EmbeddedPythonVersion.parse(v.renderProperties()) + assertEquals("3.14.7", back.pythonVersion) + assertEquals(true, back.freeThreaded) + assertEquals("pythonVersion=3.14.7\nfreeThreaded=true\n", v.renderProperties()) + } + + @Test + fun keysAreStable() { + assertEquals("org.thisisthepy.python.version", EmbeddedPythonVersion.ATTRIBUTE_VERSION) + assertEquals("org.thisisthepy.python.free-threaded", EmbeddedPythonVersion.ATTRIBUTE_FREE_THREADED) + assertEquals("META-INF/python-multiplatform/python.properties", EmbeddedPythonVersion.RESOURCE_PATH) + } + + @Test + fun parseRejectsMissingVersion() { + assertFailsWith { EmbeddedPythonVersion.parse("freeThreaded=false\n") } + } + + @Test + fun majorMinor() { + assertEquals("3.14", EmbeddedPythonVersion("3.14.7", false).majorMinor) + assertFailsWith { EmbeddedPythonVersion("3", false).majorMinor } + } + + @Test + fun attributesLandOnConsumableElementsIncludingLaterOnes() { + val project = ProjectBuilder.builder().build() + val early = project.configurations.create("apiElements") { isCanBeConsumed = true } + EmbeddedPythonVersion("3.14.7", false).register(project) + val late = project.configurations.create("desktopRuntimeElements") { isCanBeConsumed = true } + for (c in listOf(early, late)) { + assertEquals("3.14.7", c.attributes.getAttribute(attr(EmbeddedPythonVersion.ATTRIBUTE_VERSION))) + assertEquals("false", c.attributes.getAttribute(attr(EmbeddedPythonVersion.ATTRIBUTE_FREE_THREADED))) + } + } + + @Test + fun nonConsumableConfigurationsAreLeftAlone() { + val project = ProjectBuilder.builder().build() + EmbeddedPythonVersion("3.14.7", false).register(project) + // Only consumable `*Elements` configurations carry the attributes. Gradle's `create(name) {}` + // adds the configuration before running its block, so the decision is taken with the + // defaults; a configuration that is not named `*Elements` is never touched. (Every KMP/AGP + // `*Elements` configuration is consumable.) + val other = project.configurations.create("implementation") { isCanBeConsumed = true } + val resolvable = project.configurations.create("compileClasspath") { isCanBeConsumed = false } + assertNull(resolvable.attributes.getAttribute(attr(EmbeddedPythonVersion.ATTRIBUTE_VERSION))) + assertNull(other.attributes.getAttribute(attr(EmbeddedPythonVersion.ATTRIBUTE_VERSION))) + } + + @Test + fun extensionIsRegisteredUnderItsName() { + val project = ProjectBuilder.builder().build() + val v = EmbeddedPythonVersion("3.14.7", false) + v.register(project) + assertEquals("pythonMultiplatform", EmbeddedPythonVersion.EXTENSION_NAME) + assertSame(v, project.extensions.getByName("pythonMultiplatform")) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/IosPythonHomeLayoutTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/IosPythonHomeLayoutTest.kt new file mode 100644 index 000000000..7f11258e3 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/IosPythonHomeLayoutTest.kt @@ -0,0 +1,206 @@ +package python.multiplatform.gradle + +import org.gradle.api.GradleException +import org.gradle.testfixtures.ProjectBuilder +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * SPEC L-11 (issue #59): which slice an Xcode build stages, which files of `Python.xcframework` go + * into the app's prefix, and what the staging task leaves on disk. + * + * The run-time half -- the library finding `/python-multiplatform-home` and handing it + * to CPython -- is `python-multiplatform/src/iosSimulatorArm64Test/.../env/IosPythonHomeTest.kt`. + * The two share no code, so each pins the directory name as a literal. + * + * What this cannot find: whether CPython on a device really loads the result. The fake tree below + * mirrors the observed 3.14 archive's *shape*; the installed-app run in + * `docs/platforms/ios-app-bundle.md` is the check for the rest. + */ +class IosPythonHomeLayoutTest { + + private val version = "3.14.7" + // Under this module's build directory (the test task's working directory), not the system + // temporary directory: AGENTS.md rule 2. + private val scratch: File = + File(System.getProperty("user.dir"), "build/tmp/iosPythonHomeLayoutTest/${java.util.UUID.randomUUID()}") + .apply { mkdirs() } + + @AfterTest + fun cleanUp() { + scratch.deleteRecursively() + } + + // ---- names ---- + + @Test + fun theBundleDirectoryIsTheOneTheLibraryLooksFor() { + // `IosPythonHome.DIRECTORY_NAME` in python-multiplatform/src/iosMain. + assertEquals("python-multiplatform-home", IosPythonHomeLayout.BUNDLE_DIRECTORY) + } + + @Test + fun theBundleDirectoryIsNotThePayloadRoot() { + // `PythonPayload.PAYLOAD_ROOT` is "python", and the payload copy rsyncs it with --delete. + assertTrue(IosPythonHomeLayout.BUNDLE_DIRECTORY != "python") + } + + // ---- slice choice ---- + + @Test + fun xcodeSettingsSelectTheSlice() { + val l = IosPythonHomeLayout + assertEquals("iphonesimulator-arm64", l.sliceFor("-iphonesimulator", "arm64").name) + assertEquals("iphonesimulator-x86_64", l.sliceFor("-iphonesimulator", "x86_64").name) + assertEquals("iphoneos-arm64", l.sliceFor("-iphoneos", "arm64").name) + assertEquals("ios-arm64", l.sliceFor("-iphoneos", "arm64").xcframeworkSlice) + assertEquals("ios-arm64_x86_64-simulator", l.sliceFor("-iphonesimulator", "x86_64").xcframeworkSlice) + } + + @Test + fun aUniversalBuildIsRefusedWithTheFix() { + val e = assertFailsWith { IosPythonHomeLayout.sliceFor("-iphonesimulator", "arm64 x86_64") } + assertTrue("ONLY_ACTIVE_ARCH" in e.message.orEmpty(), e.message) + } + + @Test + fun outsideXcodeOrOnAnotherPlatformIsRefused() { + assertFailsWith { IosPythonHomeLayout.sliceFor(null, "arm64") } + assertFailsWith { IosPythonHomeLayout.sliceFor("-iphonesimulator", "") } + assertFailsWith { IosPythonHomeLayout.sliceFor("-appletvos", "arm64") } + assertFailsWith { IosPythonHomeLayout.sliceFor("-iphoneos", "x86_64") } + } + + @Test + fun everySliceHasItsOwnTask() { + assertEquals( + listOf( + "stageIosPythonHome_iphoneos_arm64", + "stageIosPythonHome_iphonesimulator_arm64", + "stageIosPythonHome_iphonesimulator_x86_64", + ), + IosPythonHomeLayout.slices.map { it.stageTaskName }, + ) + } + + // ---- what is copied ---- + + @Test + fun theSharedStdlibComesFirstAndTheSliceTreeSecond() { + val sim = IosPythonHomeLayout.sliceFor("-iphonesimulator", "x86_64") + assertEquals( + listOf("lib/python3.14", "ios-arm64_x86_64-simulator/lib-x86_64/python3.14"), + IosPythonHomeLayout.sourceDirectories(sim, version), + ) + } + + @Test + fun fileSelection() { + val staged = IosPythonHomeLayout::isStdlibPathStaged + assertTrue(staged("os.py")) + assertTrue(staged("encodings/__init__.py")) + assertTrue(staged("json/decoder.py")) + assertTrue(staged("lib-dynload/_json.cpython-314-iphonesimulator.so")) + assertTrue(staged("_sysconfigdata__ios_arm64-iphonesimulator.py")) + assertTrue(staged("site-packages/README.txt")) + // A package *named like* an excluded one deeper down is not excluded. + assertTrue(staged("email/test/__init__.py")) + + assertFalse(staged("test/test_os.py")) + assertFalse(staged("test")) + assertFalse(staged("tkinter/__init__.py")) + assertFalse(staged("idlelib/idle.py")) + assertFalse(staged("turtledemo/__main__.py")) + assertFalse(staged("ensurepip/_bundled/pip.whl")) + assertFalse(staged("encodings/__pycache__/aliases.cpython-314.pyc")) + assertFalse(staged("__pycache__")) + assertFalse(staged("libpython3.14.dylib")) + } + + @Test + fun aPlanMergesTheTwoTreesAndDropsTheExcludedFiles() { + val xcframework = fakeXcframework() + val slice = IosPythonHomeLayout.sliceFor("-iphonesimulator", "arm64") + val plan = IosPythonHomeLayout.plan(xcframework, slice, version) + assertEquals( + listOf( + "lib/python3.14/_sysconfigdata__ios_arm64-iphonesimulator.py", + "lib/python3.14/encodings/__init__.py", + "lib/python3.14/lib-dynload/_json.cpython-314-iphonesimulator.so", + "lib/python3.14/os.py", + ), + plan.keys.toList(), + ) + // The x86_64 tree of the same fat slice is not mixed in. + assertFalse(plan.values.any { "lib-x86_64" in it.path }) + } + + @Test + fun aMissingSliceTreeFailsInsteadOfStagingHalfAPrefix() { + val xcframework = fakeXcframework() + File(xcframework, "ios-arm64").deleteRecursively() + assertFailsWith { + IosPythonHomeLayout.plan(xcframework, IosPythonHomeLayout.sliceFor("-iphoneos", "arm64"), version) + } + } + + // ---- the task, against the fake tree ---- + + @Test + fun theTaskStagesThePrefixAndTheTemplateBesideIt() { + val xcframework = fakeXcframework() + val project = ProjectBuilder.builder() + .withProjectDir(File(scratch, "project").apply { mkdirs() }) + .withGradleUserHomeDir(File(scratch, "gradle-home")) + .build() + val out = File(scratch, "out") + // Left over from a previous run with a wider selection; must not survive. + File(out, "home/lib/python3.14/test/stale.py").apply { parentFile.mkdirs(); writeText("") } + + val task = project.tasks.create("stage", StageIosPythonHomeTask::class.java) + task.xcframework.set(xcframework) + task.sliceName.set("iphonesimulator-arm64") + task.pythonVersion.set(version) + task.destinationDir.set(out) + task.stage() + + val staged = File(out, "home").walkTopDown().filter { it.isFile } + .map { it.relativeTo(File(out, "home")).invariantSeparatorsPath }.sorted().toList() + assertEquals( + listOf( + "lib/python3.14/_sysconfigdata__ios_arm64-iphonesimulator.py", + "lib/python3.14/encodings/__init__.py", + "lib/python3.14/lib-dynload/_json.cpython-314-iphonesimulator.so", + "lib/python3.14/os.py", + ), + staged, + ) + assertTrue(File(out, StageIosPythonHomeTask.TEMPLATE_NAME).isFile) + assertFalse(File(out, "home/${StageIosPythonHomeTask.TEMPLATE_NAME}").exists()) + } + + /** The observed 3.14 archive's shape, a few files deep. */ + private fun fakeXcframework(): File { + val root = File(scratch, "Python.xcframework") + fun file(path: String) = File(root, path).apply { parentFile.mkdirs(); writeText(path) } + file("lib/python3.14/os.py") + file("lib/python3.14/encodings/__init__.py") + file("lib/python3.14/encodings/__pycache__/__init__.cpython-314.pyc") + file("lib/python3.14/test/test_os.py") + file("lib/python3.14/tkinter/__init__.py") + file("ios-arm64/lib-arm64/python3.14/lib-dynload/_json.cpython-314-iphoneos.so") + file("ios-arm64/lib-arm64/python3.14/_sysconfigdata__ios_arm64-iphoneos.py") + file("ios-arm64/lib/libpython3.14.dylib") + file("ios-arm64_x86_64-simulator/lib-arm64/python3.14/lib-dynload/_json.cpython-314-iphonesimulator.so") + file("ios-arm64_x86_64-simulator/lib-arm64/python3.14/_sysconfigdata__ios_arm64-iphonesimulator.py") + file("ios-arm64_x86_64-simulator/lib-x86_64/python3.14/lib-dynload/_json.cpython-314-iphonesimulator.so") + file("ios-arm64_x86_64-simulator/lib/libpython3.14.dylib") + file("build/iOS-dylib-Info-template.plist") + return root + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/PackagedPythonHomeTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/PackagedPythonHomeTest.kt new file mode 100644 index 000000000..833ea52d7 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/PackagedPythonHomeTest.kt @@ -0,0 +1,154 @@ +package python.multiplatform.gradle + +import org.gradle.api.tasks.Sync +import org.gradle.testfixtures.ProjectBuilder +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * SPEC L-9 (issue #60): what a packaged desktop application carries of the staged CPython prefix, + * and which tasks carry it. + * + * The run-time half -- the library finding that directory and handing it to CPython -- is + * `python-multiplatform/src/desktopTest/.../env/PackagedPythonHome*Test.kt`. Neither half can see + * the other: this file pins the directory name the library looks for as a literal for that reason. + */ +class PackagedPythonHomeTest { + + // ---- what is copied ---- + + @Test + fun theDirectoryNameIsTheOneTheLibraryLooksFor() { + // `PackagedPythonHome.DIRECTORY_NAME` in `python-multiplatform/src/jvmMain`. The two builds + // share no code, so a rename on one side only would package a prefix nothing finds -- and + // the app would still start whenever the developer's shell happened to have PYTHONHOME. + assertEquals("python-multiplatform-home", PACKAGED_HOME_DIRECTORY) + } + + @Test + fun aUnixPrefixShipsTheStdlibAndTheSharedLibraryOnly() { + val includes = packagedPrefixIncludes("3.14.7", freeThreaded = false, windows = false) + assertEquals(listOf("lib/python3.14/**", "lib/libpython3.14.*"), includes) + } + + @Test + fun aFreeThreadedPrefixUsesTheTaggedDirectoryAndLibrary() { + // A 3.14t install has `lib/python3.14t/` and `libpython3.14t.dylib` and nothing untagged, + // so the untagged patterns would package an empty tree. + val includes = packagedPrefixIncludes("3.14.7", freeThreaded = true, windows = false) + assertEquals(listOf("lib/python3.14t/**", "lib/libpython3.14t.*"), includes) + } + + @Test + fun aWindowsPrefixShipsLibDllsAndTheRootDlls() { + // python-build-standalone's Windows layout: `Lib/` (pure Python), `DLLs/` (extension + // modules), and `python3*.dll` plus the MSVC runtime at the prefix root, which is where + // `manager.libraryUnder` looks for the interpreter on that layout. + val includes = packagedPrefixIncludes("3.14.7", freeThreaded = false, windows = true) + assertEquals(listOf("Lib/**", "DLLs/**", "*.dll"), includes) + } + + @Test + fun bytecodeCachesAreNeverPackaged() { + // The staged prefix is shared by every project on the machine and accumulates + // `__pycache__` from whatever ran against it; copying that would make the app's contents + // depend on the developer's history. + assertTrue("**/__pycache__/**" in PACKAGED_PREFIX_EXCLUDES) + } + + // ---- which tasks ---- + + @Test + fun composeAppResourcesTasksAreRecognisedForEveryBuildType() { + assertTrue(isComposeAppResourcesTask("prepareAppResources")) + assertTrue(isComposeAppResourcesTask("prepareReleaseAppResources")) + assertFalse(isComposeAppResourcesTask("processResources")) + assertFalse(isComposeAppResourcesTask("desktopProcessResources")) + assertFalse(isComposeAppResourcesTask("prepareAppResourcesFoo")) + } + + // ---- wiring, against a fake staged prefix ---- + + @Test + fun theComposeResourcesTaskCopiesTheStagedPrefixSubsetAndDependsOnStaging() { + val fixture = Fixture() + val sync = fixture.syncTask() + + val dependencies = sync.taskDependencies.getDependencies(sync).map { it.name } + assertTrue("stagePythonHome" in dependencies, "prepareAppResources must run stagePythonHome first; got $dependencies") + + val copied = sync.source.files.map { it.relativeTo(fixture.prefix).invariantSeparatorsPath }.toSet() + assertTrue(fixture.stdlibFile in copied, "the stdlib must be packaged; got $copied") + assertTrue(fixture.libraryFile in copied, "the shared library must be packaged; got $copied") + assertFalse(fixture.headerFile in copied, "headers are build-time only; got $copied") + assertFalse(fixture.binaryFile in copied, "bin/ is not used by an embedding app; got $copied") + assertFalse(fixture.tclFile in copied, "Tcl/Tk is not packaged; got $copied") + assertFalse(fixture.bytecodeFile in copied, "__pycache__ is not packaged; got $copied") + } + + @Test + fun turningPackagingOffLeavesTheComposeTaskAlone() { + val fixture = Fixture() + fixture.extension.packagePythonHome.set(false) + val sync = fixture.syncTask() + + val dependencies = sync.taskDependencies.getDependencies(sync).map { it.name } + assertFalse("stagePythonHome" in dependencies, "disabled packaging must not stage; got $dependencies") + assertTrue(sync.source.files.none { it.startsWith(fixture.prefix) }, "disabled packaging must copy nothing") + } + + /** + * A project whose Gradle user home already holds a "staged" prefix, at exactly the path + * `configurePythonHomeStaging` computes, so the wiring can be read without a download. + */ + private class Fixture { + // Under this module's own build directory (the test task's working directory), not the + // system temporary directory: AGENTS.md rule 2. + val root: File = File(System.getProperty("user.dir"), "build/tmp/packagedPythonHomeTest/${java.util.UUID.randomUUID()}") + .apply { mkdirs() } + val gradleUserHome = File(root, "gradle-home") + val project = ProjectBuilder.builder() + .withProjectDir(File(root, "project").apply { mkdirs() }) + .withGradleUserHomeDir(gradleUserHome) + .build() + val extension: PythonBindingsExtension = + project.extensions.create("pythonBindings", PythonBindingsExtension::class.java) + + private val platform = assertNotNull( + hostDesktopPlatform(System.getProperty("os.name"), System.getProperty("os.arch")), + "this test needs a host python-build-standalone publishes for", + ) + private val windows = platform.startsWith("windows") + private val flavour = if (DEFAULT_PYTHON_FREE_THREADED) "-freethreaded" else "" + private val tag = DEFAULT_PYTHON_VERSION.split('.').take(2).joinToString(".") + + if (DEFAULT_PYTHON_FREE_THREADED) "t" else "" + + val prefix = File( + gradleUserHome, + "${PythonHomeStaging.CACHE_DIRECTORY}/$DEFAULT_PYTHON_VERSION+$DEFAULT_PBS_RELEASE$flavour/$platform/python", + ) + + val stdlibFile = if (windows) "Lib/os.py" else "lib/python$tag/os.py" + val libraryFile = if (windows) "python${tag.replace(".", "").removeSuffix("t")}.dll" else "lib/libpython$tag.dylib" + val headerFile = "include/python$tag/Python.h" + val binaryFile = if (windows) "python.exe" else "bin/python3" + val tclFile = if (windows) "tcl/tcl9.0/init.tcl" else "lib/libtcl9.0.dylib" + val bytecodeFile = if (windows) "Lib/__pycache__/os.cpython-314.pyc" else "lib/python$tag/__pycache__/os.cpython-314.pyc" + + init { + listOf(stdlibFile, libraryFile, headerFile, binaryFile, tclFile, bytecodeFile).forEach { + File(prefix, it).apply { parentFile.mkdirs(); writeText("x") } + } + project.tasks.register("prepareAppResources", Sync::class.java) { + into(project.layout.buildDirectory.dir("compose/tmp/prepareAppResources")) + } + PythonBindingsPlugin().configurePythonHomeStaging(project, extension) + } + + fun syncTask(): Sync = project.tasks.getByName("prepareAppResources") as Sync + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/PythonHomeStagingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/PythonHomeStagingTest.kt new file mode 100644 index 000000000..839ba3751 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/PythonHomeStagingTest.kt @@ -0,0 +1,189 @@ +package python.multiplatform.gradle + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The decisions [PythonHomeStaging] makes, as pure functions. + * + * These exist because the thing they decide is not observable from this machine. ROADMAP §15e item + * 4 is a *distribution* problem, and a distribution problem is by definition about the hosts you + * are not sitting at: three of the four platforms staged here (`linux-x86_64`, `windows-x86_64`, + * `macos-x86_64`) cannot be run in this repository's verification matrix at all. The URL, the + * asset name, the prefix layout and the stamp are therefore pinned as values rather than left to + * be discovered by a consumer on a platform nobody here can boot -- a wrong triple for Windows is + * a 404 at *their* first build, and the only way to catch it here is to assert the string. + */ +class PythonHomeStagingTest { + + // ---- host detection ---- + + @Test + fun theHostPlatformNamesMatchTheDirectoryNamesTheLibraryAlreadyUses() { + // These four names are not free: `manager.platformDirectory()` in `jvmMain` builds the + // same strings to find `lib//libpython*` inside `desktopJar`, and + // `desktopTargets` in `python-multiplatform/build.gradle.kts` keys the download tasks by + // them. A fifth spelling here would stage a prefix the loader cannot pair with a library. + assertEquals("macos-aarch64", hostDesktopPlatform("Mac OS X", "aarch64")) + assertEquals("macos-x86_64", hostDesktopPlatform("Mac OS X", "x86_64")) + assertEquals("linux-x86_64", hostDesktopPlatform("Linux", "amd64")) + assertEquals("windows-x86_64", hostDesktopPlatform("Windows 11", "amd64")) + } + + @Test + fun aarch64IsSpelledSeveralWaysBySeveralJdks() { + // `os.arch` is whatever the JDK vendor put there. Azul and Temurin report `aarch64`; + // some report `arm64`. Both have to land on the same prefix or an Apple Silicon consumer + // silently stages the x86_64 tree and pairs it with the aarch64 `libpython`. + assertEquals("macos-aarch64", hostDesktopPlatform("Mac OS X", "arm64")) + assertEquals("linux-aarch64", hostDesktopPlatform("Linux", "arm64")) + assertEquals("linux-aarch64", hostDesktopPlatform("Linux", "aarch64")) + } + + @Test + fun anUnsupportedHostIsNullRatherThanAGuess() { + // A 32-bit or exotic host must produce a message naming what it is, not a download URL + // that 404s. `Panama.POINTER_BYTES` already assumes 64-bit, so there is no silent + // fallback that could work. + assertNull(hostDesktopPlatform("Linux", "i386")) + assertNull(hostDesktopPlatform("SunOS", "sparcv9")) + assertNull(hostDesktopPlatform("Linux", "riscv64")) + } + + // ---- asset naming ---- + + @Test + fun theAssetNameIsTheOneTheLibrarysOwnBuildAlreadyDownloads() { + // Byte-for-byte the string `downloadPython_*` builds in + // `python-multiplatform/build.gradle.kts`. If these two ever disagree, a consumer stages a + // *different* CPython build than the `libpython` in `desktopJar` was taken from, and the + // two can disagree about ABI while both being version 3.14.7. + assertEquals( + "cpython-3.14.7+20260807-aarch64-apple-darwin-install_only_stripped.tar.gz", + pbsAssetName("3.14.7", "20260807", "macos-aarch64", freeThreaded = false), + ) + assertEquals( + "cpython-3.14.7+20260807-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz", + pbsAssetName("3.14.7", "20260807", "linux-x86_64", freeThreaded = false), + ) + assertEquals( + "cpython-3.14.7+20260807-x86_64-pc-windows-msvc-install_only_stripped.tar.gz", + pbsAssetName("3.14.7", "20260807", "windows-x86_64", freeThreaded = false), + ) + } + + @Test + fun aFreeThreadedBuildIsADifferentAssetNotAFlagOnTheSameOne() { + // `-PpythonFreeThreaded=true` selects a different tarball upstream, and the resulting + // prefix holds `lib/python3.14t/` rather than `lib/python3.14/`. Staging the default + // tarball for a free-threaded consumer produces a prefix `PythonHomeCheck` rejects. + assertEquals( + "cpython-3.14.7+20260807-aarch64-apple-darwin-freethreaded-install_only_stripped.tar.gz", + pbsAssetName("3.14.7", "20260807", "macos-aarch64", freeThreaded = true), + ) + } + + @Test + fun theUrlIsTheAstralReleaseTheRepoPins() { + assertEquals( + "https://github.com/astral-sh/python-build-standalone/releases/download/20260807/" + + "cpython-3.14.7+20260807-aarch64-apple-darwin-install_only_stripped.tar.gz", + pbsAssetUrl("20260807", "cpython-3.14.7+20260807-aarch64-apple-darwin-install_only_stripped.tar.gz"), + ) + } + + // ---- checksum ---- + + @Test + fun theChecksumIsReadOutOfTheReleasesOwnSha256sumsFile() { + // Upstream's format is " ", two spaces. Matching on the filename rather + // than on a line index matters: the file lists every asset of the release, hundreds of + // them, in no order this code should depend on. + val sums = """ + 1111111111111111111111111111111111111111111111111111111111111111 cpython-3.14.7+20260807-x86_64-pc-windows-msvc-install_only_stripped.tar.gz + 2222222222222222222222222222222222222222222222222222222222222222 cpython-3.14.7+20260807-aarch64-apple-darwin-install_only_stripped.tar.gz + """.trimIndent() + assertEquals( + "2222222222222222222222222222222222222222222222222222222222222222", + sha256Of(sums, "cpython-3.14.7+20260807-aarch64-apple-darwin-install_only_stripped.tar.gz"), + ) + } + + @Test + fun aFilenameThatIsOnlyASuffixOfAnotherIsNotMatched() { + // `endsWith` is what the root build uses, and it is wrong in principle here: the + // free-threaded asset name *contains* nothing of the default one, but + // `...-install_only_stripped.tar.gz` is a suffix of `...-freethreaded-install_only_stripped.tar.gz`. Asking + // for the default asset must not return the free-threaded hash, or verification fails on + // a file that is actually intact. + val sums = """ + 3333333333333333333333333333333333333333333333333333333333333333 cpython-3.14.7+20260807-aarch64-apple-darwin-freethreaded-install_only_stripped.tar.gz + 4444444444444444444444444444444444444444444444444444444444444444 cpython-3.14.7+20260807-aarch64-apple-darwin-install_only_stripped.tar.gz + """.trimIndent() + assertEquals( + "4444444444444444444444444444444444444444444444444444444444444444", + sha256Of(sums, "cpython-3.14.7+20260807-aarch64-apple-darwin-install_only_stripped.tar.gz"), + ) + } + + @Test + fun anAssetMissingFromSha256sumsIsNullSoTheCallerCanRefuseRatherThanSkipVerification() { + assertNull(sha256Of("aaaa something-else.tar.gz", "cpython-3.14.7+20260807-x.tar.gz")) + } + + // ---- the staged prefix ---- + + @Test + fun theMarkerIsTheOnePythonHomeCheckItselfLooksFor() { + // `PythonHomeCheck.diagnose` accepts a prefix when `lib/python/os.py` or `Lib/os.py` + // is readable under it. Staging asserts the *same* file rather than "the directory is not + // empty", so a prefix this task reports as staged is one `Python3.initialize()` will + // accept -- the two cannot drift into disagreeing about what a usable prefix is. + assertEquals("lib/python3.14/os.py", stdlibMarkerRelativePath("3.14.7", freeThreaded = false, windows = false)) + assertEquals("lib/python3.14t/os.py", stdlibMarkerRelativePath("3.14.7", freeThreaded = true, windows = false)) + assertEquals("Lib/os.py", stdlibMarkerRelativePath("3.14.7", freeThreaded = false, windows = true)) + } + + @Test + fun theStampNamesEveryInputThatChangesTheBytes() { + // §15f's lesson, applied to a build-time cache instead of `filesDir`: the three + // hand-written Android copies each probed one entry of the result and so accepted a tree + // whose extraction had been interrupted. A stamp written after the last byte cannot be + // true of a partial tree. It has to name the upstream release too, not just the Python + // version -- astral re-releases the same CPython version under a new date tag, and the + // cache directory is keyed by version alone. + val stamp = stagingStamp("3.14.7", "20260807", "macos-aarch64", freeThreaded = false) + assertTrue("3.14.7" in stamp, stamp) + assertTrue("20260807" in stamp, stamp) + assertTrue("macos-aarch64" in stamp, stamp) + + // Every input is load-bearing: changing any one of them must invalidate the cache. + assertTrue(stamp != stagingStamp("3.14.8", "20260807", "macos-aarch64", freeThreaded = false)) + assertTrue(stamp != stagingStamp("3.14.7", "20260808", "macos-aarch64", freeThreaded = false)) + assertTrue(stamp != stagingStamp("3.14.7", "20260807", "linux-x86_64", freeThreaded = false)) + assertTrue(stamp != stagingStamp("3.14.7", "20260807", "macos-aarch64", freeThreaded = true)) + } + + // ---- when the plugin should act at all ---- + + @Test + fun aConsumerWhoAlreadySetPythonhomeKeepsTheirs() { + // The whole point of §15c's gap is that a consumer today *has* to set `PYTHONHOME` by + // hand. Someone who has already done that -- at a system CPython, at a conda prefix, at a + // build they compiled -- must not have it silently replaced by a staged copy; that would + // turn a working setup into a differently-configured one on upgrade. + assertFalse(shouldStagePythonHome(existingPythonHome = "/opt/homebrew", stagingEnabled = true)) + assertTrue(shouldStagePythonHome(existingPythonHome = null, stagingEnabled = true)) + assertTrue(shouldStagePythonHome(existingPythonHome = " ", stagingEnabled = true)) + } + + @Test + fun stagingCanBeTurnedOffEntirely() { + // An air-gapped build, or one that gets its prefix from its own packaging step, must be + // able to opt out -- the task downloads from the network, which not every build may do. + assertFalse(shouldStagePythonHome(existingPythonHome = null, stagingEnabled = false)) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/StagedPrefixLinksTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/StagedPrefixLinksTest.kt new file mode 100644 index 000000000..b3ea0e044 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/StagedPrefixLinksTest.kt @@ -0,0 +1,89 @@ +package python.multiplatform.gradle + +import org.gradle.api.GradleException +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files +import java.util.zip.GZIPOutputStream +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Issue #74. Gradle's `tarTree` extracts the archive's `lib/libpython3.14.so -> libpython3.14.so.1.0` + * as a 0-byte file, which the staged prefix, the packaged `python-multiplatform-home` and the + * desktop jar then all carried. Expected red before the fix: the extractor did not exist; with + * `tarTree` the staged link is empty. + */ +class StagedPrefixLinksTest { + + private fun entry(out: ByteArrayOutputStream, name: String, type: Char, data: ByteArray = ByteArray(0), link: String = "") { + val h = ByteArray(512) + fun put(off: Int, s: String) = s.toByteArray().copyInto(h, off) + put(0, name) + put(100, "0000755\u0000") + put(124, String.format("%011o\u0000", data.size)) + h[156] = type.code.toByte() + put(157, link) + put(257, "ustar\u000000") + for (i in 148 until 156) h[i] = ' '.code.toByte() + put(148, String.format("%06o\u0000 ", h.sumOf { it.toInt() and 0xff })) + out.write(h) + out.write(data) + out.write(ByteArray((512 - data.size % 512) % 512)) + } + + private fun fakeArchive(dir: File, payload: ByteArray): File { + val tar = ByteArrayOutputStream() + entry(tar, "python/", '5') + entry(tar, "python/lib/", '5') + entry(tar, "python/lib/libpython3.14.so.1.0", '0', payload) + entry(tar, "python/lib/libpython3.14.so", '2', link = "libpython3.14.so.1.0") + entry(tar, "python/lib/libpython3.so", '2', link = "libpython3.14.so") // link to a link + tar.write(ByteArray(1024)) + val f = File(dir, "fake.tar.gz") + GZIPOutputStream(f.outputStream()).use { it.write(tar.toByteArray()) } + return f + } + + @Test + fun symlinksBecomeCopiesOfTheirTargets() { + val work = Files.createTempDirectory(File("../.tmp").apply { mkdirs() }.toPath(), "links").toFile() + try { + val payload = ByteArray(5000) { (it % 251).toByte() } + val root = File(work, "out") + extractTarGzMaterialisingLinks(fakeArchive(work, payload), root) + + val real = File(root, "python/lib/libpython3.14.so.1.0") + val link = File(root, "python/lib/libpython3.14.so") + val chained = File(root, "python/lib/libpython3.so") + assertContentEquals(payload, real.readBytes()) + assertTrue(link.length() > 0, "staged symlink is empty") + assertContentEquals(real.readBytes(), link.readBytes()) + assertContentEquals(real.readBytes(), chained.readBytes()) + assertTrue(!Files.isSymbolicLink(link.toPath())) + } finally { + work.deleteRecursively() + } + } + + @Test + fun theStampChangedSoOldPrefixesAreReExtracted() { + assertTrue("links-materialised" in stagingStamp("3.14.7", "20260807", "linux-x86_64", false)) + } + + @Test + fun aPackagedHomeWithAnEmptyLibraryFailsTheBuild() { + val work = Files.createTempDirectory(File("../.tmp").apply { mkdirs() }.toPath(), "packaged").toFile() + try { + val lib = File(work, "lib").apply { mkdirs() } + File(lib, "libpython3.14.so.1.0").writeBytes(ByteArray(10)) + requireNoEmptyLibraries(work) // clean tree passes + File(lib, "libpython3.14.so").writeBytes(ByteArray(0)) + assertFailsWith { requireNoEmptyLibraries(work) } + } finally { + work.deleteRecursively() + } + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/TypedPythonStubsWiringTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/TypedPythonStubsWiringTest.kt new file mode 100644 index 000000000..9cb2a8b7d --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/TypedPythonStubsWiringTest.kt @@ -0,0 +1,82 @@ +package python.multiplatform.gradle + +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * SPEC B-9 (python-multiplatform#27): the stubs task output reaches toolchain's `typedpythonStubs` + * configuration by name, with the task dependency attached, whichever side appears first. + */ +class TypedPythonStubsWiringTest { + + private val configurationName = "typedpythonStubs" + + private fun projectWith(sourceSets: List): Project { + val project = ProjectBuilder.builder().build() + val container = project.container(DummySourceSet::class.java) { name -> DummySourceSet(name, project) } + sourceSets.forEach { container.create(it) } + project.extensions.add("kotlin", DummyKotlinExt(container)) + sourceSets.forEach { project.configurations.create("${it}Classpath") } + return project + } + + private fun wire(project: Project, sourceSets: List) { + val extension = project.extensions.create("pythonBindingsUnderTest", PythonBindingsExtension::class.java) + extension.artifactIncludePackages.set(listOf("junit.runner")) + extension.artifactTargets.set(sourceSets.associate { "${it}Classpath" to it }) + PythonBindingsPlugin().configureArtifactBindings(project, extension) + } + + private fun stubsDir(project: Project, sourceSet: String) = + project.layout.buildDirectory.dir("generated/pythonStubs/$sourceSet").get().asFile + + @Test + fun aConfigurationThatExistsBeforeTheWiringReceivesEverySourceSetsStubs() { + val sets = listOf("desktopMain", "androidNativeArm64Main") + val project = projectWith(sets) + project.configurations.create(configurationName) + wire(project, sets) + + val files = project.configurations.getByName(configurationName).files + sets.forEach { assertTrue(stubsDir(project, it) in files, "$it missing from $files") } + } + + @Test + fun aConfigurationCreatedAfterTheWiringReceivesTheStubsToo() { + val sets = listOf("desktopMain", "androidNativeArm64Main") + val project = projectWith(sets) + wire(project, sets) + project.configurations.create(configurationName) + + val files = project.configurations.getByName(configurationName).files + sets.forEach { assertTrue(stubsDir(project, it) in files, "$it missing from $files") } + } + + @Test + fun resolvingTheConfigurationDependsOnTheStubsTask() { + val project = projectWith(listOf("desktopMain")) + val configuration = project.configurations.create(configurationName) + wire(project, listOf("desktopMain")) + + val deps = configuration.buildDependencies.getDependencies(null) + val names = deps.map { it.name } + assertTrue( + names.any { it.startsWith("generatePythonStubs") }, + "the task must travel with the files; dependencies were $names", + ) + } + + @Test + fun withoutTheConfigurationNothingIsCreatedAndNothingFails() { + val project = projectWith(listOf("desktopMain")) + wire(project, listOf("desktopMain")) + + assertNull(project.configurations.findByName(configurationName)) + assertFalse(project.tasks.names.isEmpty()) + } +} 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 new file mode 100644 index 000000000..30ec8f1a0 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/WasmProxyExportsRenderingTest.kt @@ -0,0 +1,149 @@ +package python.multiplatform.gradle + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The three @WasmExport trampoline lines ProxyTypeExports.kt used to hand-write. + * + * The rendered output is a pure function, so it is unit-tested here in isolation before the + * Gradle task that writes it to disk is ever wired. + * + * The package name is the only variable part. + */ +class WasmProxyExportsRenderingTest { + + @Test + fun renderedOutputStartsWithGeneratedNotice() { + val source = renderWasmProxyExportsSource("python.multiplatform.test") + assertTrue( + source.startsWith("// GENERATED"), + "Expected source to start with '// GENERATED', got: ${source.take(40)}", + ) + } + + @Test + fun renderedOutputOptsInToExperimentalWasmInterop() { + val source = renderWasmProxyExportsSource("some.pkg") + assertTrue( + source.contains("@file:OptIn(kotlin.wasm.ExperimentalWasmInterop::class)"), + "Expected @file:OptIn for ExperimentalWasmInterop", + ) + } + + @Test + fun renderedOutputUsesTheGivenPackageName() { + val source = renderWasmProxyExportsSource("my.executable.module") + assertTrue( + source.contains("package my.executable.module"), + "Expected 'package my.executable.module', got:\n$source", + ) + } + + @Test + fun renderedOutputContainsAllThreeWasmExportAnnotations() { + val source = renderWasmProxyExportsSource("test.pkg") + assertTrue(source.contains("@kotlin.wasm.WasmExport(\"pmp_tp_traverse\")"), "missing traverse export") + assertTrue(source.contains("@kotlin.wasm.WasmExport(\"pmp_tp_clear\")"), "missing clear export") + assertTrue(source.contains("@kotlin.wasm.WasmExport(\"pmp_tp_dealloc\")"), "missing dealloc export") + } + + @Test + fun traverseTrampolineDelegatesToProxyType() { + val source = renderWasmProxyExportsSource("test.pkg") + assertTrue( + source.contains("ProxyType.traverse("), + "traverse body should delegate to ProxyType.traverse", + ) + } + + @Test + fun clearTrampolineDelegatesToProxyType() { + val source = renderWasmProxyExportsSource("test.pkg") + assertTrue( + source.contains("ProxyType.clear("), + "clear body should delegate to ProxyType.clear", + ) + } + + @Test + fun deallocTrampolineDelegatesToProxyType() { + val source = renderWasmProxyExportsSource("test.pkg") + assertTrue( + source.contains("ProxyType.dealloc("), + "dealloc body should delegate to ProxyType.dealloc", + ) + } + + @Test + fun traverseHasCorrectArity() { + // 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"), + "traverse must declare three Int parameters (selfPtr, visitPtr, argPtr)", + ) + } + + @Test + fun clearHasCorrectArity() { + // tp_clear: (PyObject *self) -> int — one i32 param + val source = renderWasmProxyExportsSource("test.pkg") + val clearFun = source.substringAfter("pmp_tp_clear\"") + assertTrue( + clearFun.contains("selfPtr: Int): Int"), + "clear must have exactly (selfPtr: Int): Int signature", + ) + } + + @Test + fun deallocHasVoidReturn() { + // 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)", + ) + } + + @Test + fun renderedOutputIsStableAcrossInvocations() { + val a = renderWasmProxyExportsSource("pkg.x") + val b = renderWasmProxyExportsSource("pkg.x") + assertEquals(a, b, "Rendering must be deterministic") + } + + @Test + fun renderedOutputIncreasesWithExtraSlot() { + val extraSlot = WasmExportSlot( + exportName = "pmp_tp_call", + kotlinName = "pmpTpCall", + parameters = "selfPtr: Int, argsPtr: Int, kwdsPtr: Int", + returnType = "Int", + delegateCall = "ProxyType.call(selfPtr, argsPtr, kwdsPtr)" + ) + + val baseSlots = DEFAULT_PROXY_TYPE_SLOTS + val extraSlots = baseSlots + extraSlot + + val three = renderWasmProxyExportsSource("pkg.x", slots = baseSlots) + val four = renderWasmProxyExportsSource("pkg.x", slots = extraSlots) + + assertTrue(four.length > three.length, "Output should be longer with more slots") + + // base slots have traverse, clear, dealloc but not call + assertTrue(three.contains("pmp_tp_traverse\"")) + assertTrue(three.contains("pmp_tp_clear\"")) + assertTrue(three.contains("pmp_tp_dealloc\"")) + assertTrue(!three.contains("pmp_tp_call\"")) + + // extra slots have call as well + assertTrue(four.contains("pmp_tp_traverse\"")) + assertTrue(four.contains("pmp_tp_clear\"")) + assertTrue(four.contains("pmp_tp_dealloc\"")) + assertTrue(four.contains("pmp_tp_call\"")) + assertTrue(four.contains("ProxyType.call(selfPtr, argsPtr, kwdsPtr)")) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/WiringTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/WiringTest.kt new file mode 100644 index 000000000..2fb225019 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/WiringTest.kt @@ -0,0 +1,201 @@ +package python.multiplatform.gradle + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The three decisions the plugin makes for the user, as pure functions. + * + * The plugin's real proof is `ksp-fixtures`, which applies it and runs its generated table; these + * pin the parts that would otherwise only be observable as a missing fragment or a duplicate one. + */ +class WiringTest { + + // Observed on `:ksp-fixtures:app` (`gradlew dependencies`), Kotlin 2.4.20-Beta2 + KSP 2.3.11. + private val observedConfigurations = listOf( + "ksp", + "kspAndroidNativeArm64", + "kspAndroidNativeArm64Test", + "kspCommonMainKotlinMetadataProcessorClasspath", + "kspCommonMainMetadata", + "kspDesktop", + "kspDesktopTest", + "kspKotlinAndroidNativeArm64ProcessorClasspath", + "kspKotlinDesktopProcessorClasspath", + "kspPluginClasspath", + "kspPluginClasspathNonEmbeddable", + "kspTestKotlinAndroidNativeArm64ProcessorClasspath", + "kspTestKotlinDesktopProcessorClasspath", + ) + + @Test + fun theProcessorGoesOnEveryTargetsMainCompilationAndNowhereElse() { + assertEquals( + listOf("kspAndroidNativeArm64", "kspDesktop"), + observedConfigurations.filter { isBindingKspConfiguration(it, isMultiplatform = true) }, + ) + } + + @Test + fun theBareKspConfigurationIsForSinglePlatformProjectsOnly() { + // Adding to it from a multiplatform project is a hard failure: "The 'ksp' configuration + // is deprecated in Kotlin Multiplatform projects" -- observed against the fixtures. + assertFalse(isBindingKspConfiguration("ksp", isMultiplatform = true)) + assertTrue(isBindingKspConfiguration("ksp", isMultiplatform = false)) + } + + // Observed on `:ksp-fixtures:android` (`gradlew :ksp-fixtures:android:dependencies`), the + // fixture that carries `com.android.library`. AGP names its source sets with the build type + // *last*, so the test configurations here do not end in `Test` the way the Kotlin-target ones + // above do. Every one of the nine `...Test...` names below was receiving the processor before + // ROADMAP §13. + private val observedAndroidConfigurations = listOf( + "ksp", + "kspAndroid", + "kspAndroidAndroidTest", + "kspAndroidAndroidTestDebug", + "kspAndroidAndroidTestRelease", + "kspAndroidDebug", + "kspAndroidRelease", + "kspAndroidTest", + "kspAndroidTestDebug", + "kspAndroidTestFixtures", + "kspAndroidTestFixturesDebug", + "kspAndroidTestFixturesRelease", + "kspAndroidTestRelease", + "kspCommonMainMetadata", + "kspDebug", + "kspDebugAndroidTestKotlinAndroidProcessorClasspath", + "kspDebugKotlinAndroidProcessorClasspath", + "kspDebugUnitTestKotlinAndroidProcessorClasspath", + "kspPluginClasspath", + "kspPluginClasspathNonEmbeddable", + "kspRelease", + "kspReleaseKotlinAndroidProcessorClasspath", + "kspReleaseUnitTestKotlinAndroidProcessorClasspath", + "kspTest", + "kspTestDebug", + "kspTestFixtures", + "kspTestFixturesDebug", + "kspTestFixturesRelease", + "kspTestRelease", + ) + + @Test + fun testCompilationsAreExcludedOrTheModuleWouldEmitASecondFragmentUnderTheSameName() { + assertFalse(isBindingKspConfiguration("kspDesktopTest", isMultiplatform = true)) + assertFalse(isBindingKspConfiguration("kspAndroidNativeArm64Test", isMultiplatform = true)) + assertFalse(isBindingKspConfiguration("kspTest", isMultiplatform = false)) + } + + @Test + fun anAndroidModulesTestConfigurationsCarryTheBuildTypeAfterTheWordTest() { + // The ROADMAP §13 defect: `kspAndroidTestDebug` does not end in `Test`, so the processor + // ran over the unit-test sources and emitted a `Fragment_`/`FunctionTable` pair + // that shadowed `main`'s inside the test compilation. Observed on + // `:ksp-fixtures:android` before this filter was widened. + for (name in listOf( + "kspAndroidTestDebug", + "kspAndroidTestRelease", + "kspAndroidAndroidTestDebug", + "kspAndroidAndroidTestRelease", + "kspTestDebug", + "kspTestRelease", + "kspAndroidTestFixtures", + "kspAndroidTestFixturesDebug", + "kspTestFixturesRelease", + )) { + assertFalse(isBindingKspConfiguration(name, isMultiplatform = true), name) + } + } + + @Test + fun onlyTheAndroidMainSourceSetsConfigurationsKeepTheProcessor() { + assertEquals( + listOf("kspAndroid", "kspAndroidDebug", "kspAndroidRelease", "kspDebug", "kspRelease"), + observedAndroidConfigurations.filter { isBindingKspConfiguration(it, isMultiplatform = true) }, + ) + } + + @Test + fun aFlavourOrTargetMerelyContainingTheLettersTestKeepsTheProcessor() { + // `Test` is matched as a camel-case word, not as a substring: `testing` is an ordinary + // name a product flavour or a Kotlin target may carry, and excluding it would silently + // leave that variant without a fragment. + assertTrue(isBindingKspConfiguration("kspAndroidTestingDebug", isMultiplatform = true)) + assertTrue(isBindingKspConfiguration("kspLatestDebug", isMultiplatform = true)) + } + + @Test + fun theMetadataCompilationIsExcludedForTheSameReason() { + // commonMain is compiled again by every platform target, so processing it separately + // would produce a duplicate `Fragment_` for one module. + assertFalse(isBindingKspConfiguration("kspCommonMainMetadata", isMultiplatform = true)) + } + + @Test + fun kspsOwnInternalClasspathConfigurationsAreNotDependencyTargets() { + assertFalse(isBindingKspConfiguration("kspPluginClasspath", isMultiplatform = true)) + assertFalse(isBindingKspConfiguration("kspKotlinDesktopProcessorClasspath", isMultiplatform = true)) + assertFalse(isBindingKspConfiguration("kspTestKotlinDesktopProcessorClasspath", isMultiplatform = true)) + } + + @Test + fun moduleNameCarriesTheGroupBecauseFragmentObjectsShareOnePackageAcrossAllArtifacts() { + // Two independent libraries both called `:core` would otherwise both emit + // `python.multiplatform.generated.fragments.Fragment_core` and collide on one classpath. + assertEquals( + "io_github_thisisthepy_ksp_fixtures_app", + deriveModuleName(group = "io.github.thisisthepy", path = ":ksp-fixtures:app", name = "app"), + ) + assertEquals( + "com_example_core", + deriveModuleName(group = "com.example", path = ":core", name = "core"), + ) + } + + @Test + fun moduleNameFallsBackToTheProjectNameForARootProjectOrAGrouplessBuild() { + assertEquals("my_app", deriveModuleName(group = "", path = ":", name = "my-app")) + assertEquals("com_example_my_app", deriveModuleName(group = "com.example", path = ":", name = "my-app")) + } + + @Test + fun moduleNameIsAlwaysAValidKotlinIdentifierSuffix() { + val derived = deriveModuleName(group = "com.example-corp", path = ":some module", name = "some module") + assertTrue(derived.all { it.isLetterOrDigit() || it == '_' }, derived) + } + + @Test + fun roleIsInferredFromWhetherTheModuleProducesTheFinalBinary() { + // Only the module that links the binary may aggregate: two aggregators on one classpath + // means two `FunctionTable` objects in one package. + assertEquals("app", inferRole(hasApplicationPlugin = true, hasAndroidApplicationPlugin = false)) + assertEquals("app", inferRole(hasApplicationPlugin = false, hasAndroidApplicationPlugin = true)) + assertEquals("library", inferRole(hasApplicationPlugin = false, hasAndroidApplicationPlugin = false)) + } + + @Test + fun wasmProxyExportsTaskIsConfiguredWhenWasmJsMainSourceSetExists() { + val project = org.gradle.testfixtures.ProjectBuilder.builder().build() + val container = project.container(DummySourceSet::class.java) { name -> DummySourceSet(name, project) } + container.create("wasmJsMain") + project.extensions.add("kotlin", DummyKotlinExt(container)) + + PythonBindingsPlugin().configureWasmProxyExports(project) + + val task = project.tasks.findByName("generateWasmProxyExports") + kotlin.test.assertNotNull(task, "generateWasmProxyExports task should be registered when wasmJsMain exists") + kotlin.test.assertTrue(task is GenerateWasmProxyExportsTask) + } +} + +class DummySourceSet(private val name: String, private val project: org.gradle.api.Project) : org.gradle.api.Named { + override fun getName(): String = name + fun getKotlin(): org.gradle.api.file.SourceDirectorySet = + project.objects.sourceDirectorySet(name, "$name Kotlin sources") +} + +class DummyKotlinExt(val sourceSets: org.gradle.api.NamedDomainObjectContainer) diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactFragmentRenderingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactFragmentRenderingTest.kt new file mode 100644 index 000000000..9672e3fd1 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactFragmentRenderingTest.kt @@ -0,0 +1,208 @@ +package python.multiplatform.gradle.artifact + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The text the walker writes, pinned. + * + * The shape is `python-multiplatform-ksp`'s `renderFragmentSource` on purpose: the same + * `FunctionTableFragment`, the same `ExposedCallable`, the same one-lambda-per-entry body. The two + * producers differ in what they read, not in what they emit -- so the runtime, `UpcallTable` and + * `PythonProxySource` all stay unaware that there are two of them. See + * [ArtifactTableRenderingTest.theAggregatorAddsRatherThanReplaces] for the one place they are kept + * apart, and why. + */ +class ArtifactFragmentRenderingTest { + + private val versionId = ArtifactCallable( + name = "junit.runner.Version.id", + arity = 0, + paramTags = emptyList(), + returnTag = "STRING", + lambdaBody = "{ (junit.runner.Version.id()) }", + ) + + private val setPreference = ArtifactCallable( + name = "junit.runner.BaseTestRunner.setPreference", + arity = 2, + paramTags = listOf("STRING", "STRING"), + returnTag = "UNIT", + lambdaBody = "{ args -> (junit.runner.BaseTestRunner.setPreference((args[0] as String), (args[1] as String))) }", + paramTypeNames = listOf("kotlin.String", "kotlin.String"), + returnTypeName = "kotlin.Unit", + ) + + /** An extension, so that the rendered `isExtension`/`receiverTypeName` pair -- the fields + * `docs/archive/pythonx-adapter-design.md` §4.2 needs and §2.4 records as lost -- appears in the pinned + * text rather than only in a scanner unit test. */ + private val trimIndent = ArtifactCallable( + name = "kotlin.text.trimIndent", + arity = 1, + paramTags = listOf("STRING"), + returnTag = "STRING", + lambdaBody = "{ args -> ((args[0] as String).artifact_ext_kotlin_text_trimIndent()) }", + imports = listOf("import kotlin.text.trimIndent as artifact_ext_kotlin_text_trimIndent"), + receiverTypeName = "kotlin.String", + paramNames = listOf(""), + paramTypeNames = listOf("kotlin.String"), + returnTypeName = "kotlin.String", + paramHasDefault = listOf(false), + ) + + @Test + fun aFragmentIsTheSameShapeKspEmits() { + val source = renderArtifactFragmentSource( + ArtifactFragment( + objectName = "ArtifactFragment_junit_junit_4_13_2", + moduleName = "artifact:junit:junit:4.13.2", + entries = listOf(versionId, setPreference, trimIndent), + ), + ) + assertEquals( + """ + |// GENERATED by python-multiplatform-gradle-plugin. Do not edit. + |@file:Suppress("UNCHECKED_CAST", "DEPRECATION", "DEPRECATION_ERROR", "OPT_IN_USAGE", "OPT_IN_USAGE_ERROR") + | + |package python.multiplatform.generated.artifacts + | + |import kotlin.text.trimIndent as artifact_ext_kotlin_text_trimIndent + | + |object ArtifactFragment_junit_junit_4_13_2 : python.multiplatform.reflection.FunctionTableFragment { + | override val moduleName: String = "artifact:junit:junit:4.13.2" + | + | override fun entries(): List = listOf( + | python.multiplatform.reflection.ExposedCallable( + | name = "junit.runner.Version.id", + | arity = 0, + | paramTypes = listOf(), + | returnType = python.multiplatform.reflection.TypeTag.STRING, + | kind = python.multiplatform.reflection.CallableKind.FUNCTION, + | paramNames = listOf(), + | paramTypeNames = listOf(), + | returnTypeName = null, + | isExtension = false, + | receiverTypeName = null, + | paramHasDefault = listOf(), + | callable = { (junit.runner.Version.id()) }, + | ), + | python.multiplatform.reflection.ExposedCallable( + | name = "junit.runner.BaseTestRunner.setPreference", + | arity = 2, + | paramTypes = listOf(python.multiplatform.reflection.TypeTag.STRING, python.multiplatform.reflection.TypeTag.STRING), + | returnType = python.multiplatform.reflection.TypeTag.UNIT, + | kind = python.multiplatform.reflection.CallableKind.FUNCTION, + | paramNames = listOf(), + | paramTypeNames = listOf("kotlin.String", "kotlin.String"), + | returnTypeName = "kotlin.Unit", + | isExtension = false, + | receiverTypeName = null, + | paramHasDefault = listOf(), + | callable = { args -> (junit.runner.BaseTestRunner.setPreference((args[0] as String), (args[1] as String))) }, + | ), + | python.multiplatform.reflection.ExposedCallable( + | name = "kotlin.text.trimIndent", + | arity = 1, + | paramTypes = listOf(python.multiplatform.reflection.TypeTag.STRING), + | returnType = python.multiplatform.reflection.TypeTag.STRING, + | kind = python.multiplatform.reflection.CallableKind.FUNCTION, + | paramNames = listOf(""), + | paramTypeNames = listOf("kotlin.String"), + | returnTypeName = "kotlin.String", + | isExtension = true, + | receiverTypeName = "kotlin.String", + | paramHasDefault = listOf(false), + | callable = { args -> ((args[0] as String).artifact_ext_kotlin_text_trimIndent()) }, + | ), + | ) + |} + | + """.trimMargin(), + source, + ) + } + + /** + * A fragment with nothing in it still renders valid Kotlin. + * + * `PythonArtifactBindingsTask` does not *emit* one -- an artefact that contributed nothing gets + * no file and no line in `ArtifactTable`, because an empty fragment would still occupy a + * `moduleName` in `UpcallTable`'s installed set, which is the one thing that set is read for. + * The renderer is kept total anyway so that "which artefacts get a file" stays the task's + * decision, changeable without a second empty-list bug in the text. + */ + @Test + fun anEmptyFragmentIsStillAFragment() { + val source = renderArtifactFragmentSource( + ArtifactFragment("ArtifactFragment_x", "artifact:x", emptyList()), + ) + assertTrue(source.contains("override fun entries(): List = listOf(\n )")) + } + + /** + * A fragment too big for one JVM method is split, in order, across private helpers. + * + * `entries()` used to be one `listOf(...)`. Every entry's `paramTypes`/`paramNames`/ + * `paramTypeNames`/`paramHasDefault` lists are built inline in that method's bytecode, and a + * composable carries four lists as long as its parameter count plus three synthetic slots. Compose + * Multiplatform 1.11's `material3` (1.9.0, 326 bound entries) pushed that past the JVM's 64 KB + * method limit and the consumer's compile died with `MethodTooLargeException`. The split keeps the + * runtime contract -- one `entries()` returning every entry in walk order -- and only moves where + * the bytecode lives. + */ + @Test + fun aLargeFragmentSplitsEntriesAcrossHelpersInOrder() { + val count = FRAGMENT_ENTRIES_PER_METHOD * 2 + 1 + val entries = List(count) { versionId.copy(name = "junit.runner.Version.id$it") } + val source = renderArtifactFragmentSource(ArtifactFragment("ArtifactFragment_x", "artifact:x", entries)) + + assertTrue( + source.contains( + "override fun entries(): List =\n" + + " entries0() + entries1() + entries2()\n", + ), + source, + ) + listOf(0, 1, 2).forEach { chunk -> + assertTrue( + source.contains("private fun entries$chunk(): List = listOf("), + source, + ) + } + assertTrue(!source.contains("entries3()"), source) + // Every entry exactly once, in walk order. + val names = Regex("name = \"(junit\\.runner\\.Version\\.id\\d+)\"").findAll(source).map { it.groupValues[1] }.toList() + assertEquals(entries.map { it.name }, names) + // The chunk boundary falls where it says it does. + val secondHelper = source.indexOf("private fun entries1()") + assertTrue(source.indexOf("\"junit.runner.Version.id${FRAGMENT_ENTRIES_PER_METHOD - 1}\"") < secondHelper, source) + assertTrue(source.indexOf("\"junit.runner.Version.id$FRAGMENT_ENTRIES_PER_METHOD\"") > secondHelper, source) + } + + /** Up to one chunk's worth, the shape is exactly the single-`listOf` one pinned above. */ + @Test + fun aFragmentThatFitsOneMethodIsNotSplit() { + val entries = List(FRAGMENT_ENTRIES_PER_METHOD) { versionId.copy(name = "junit.runner.Version.id$it") } + val source = renderArtifactFragmentSource(ArtifactFragment("ArtifactFragment_x", "artifact:x", entries)) + assertTrue(source.contains("override fun entries(): List = listOf(\n"), source) + assertTrue(!source.contains("entries0()"), source) + } + + /** + * `DEPRECATION` is suppressed because a walked artefact is not the consumer's code: JUnit 4's + * own `junit.framework.Assert` is deprecated, and a consumer who asked for a binding should not + * be handed a warning about a declaration they did not write. + * + * `OPT_IN_USAGE_ERROR` is the same argument at Compose's scale: `@ExperimentalFoundationApi` and + * its siblings are `RequiresOptIn` at ERROR level, so `Modifier.combinedClickable` would fail a + * consumer's *compile* rather than warn. The marker cannot be known here -- any library may + * invent one -- so the diagnostic is suppressed rather than an `@OptIn` guessed at. + */ + @Test + fun somebodyElsesCompilerDiagnosticsAreSuppressedInGeneratedSource() { + val source = renderArtifactFragmentSource(ArtifactFragment("ArtifactFragment_x", "artifact:x", listOf(versionId))) + assertTrue(source.contains("\"DEPRECATION_ERROR\""), source) + assertTrue(source.contains("\"OPT_IN_USAGE_ERROR\""), source) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactScannerTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactScannerTest.kt new file mode 100644 index 000000000..b89dffd3f --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactScannerTest.kt @@ -0,0 +1,794 @@ +package python.multiplatform.gradle.artifact + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The artefact walker against two real jars: `junit:junit:4.13.2` (Java) and `kotlin-stdlib` + * (Kotlin). + * + * `docs/design/ecosystem.md` §5b names two producers of bindings, split by *what they look at* -- KSP sees + * the consumer's own source, the artefact walker sees everything the build resolves. This is the + * second one, and a jar the test never compiled is the only honest input for it. + * + * Neither jar is on this test's classpath. The JUnit 4 one arrives as a path in a system property + * (see this build's `walkerFixtureJar` configuration); `kotlin-stdlib` is located through its own + * `CodeSource`, since it necessarily *is* on the classpath of anything written in Kotlin. + */ +class ArtifactScannerTest { + + private val junitJar: File + get() { + val path = System.getProperty("python.multiplatform.walkerFixtureJar") + assertNotNull(path, "the walkerFixtureJar system property is not set; see build.gradle.kts") + return File(path).also { assertTrue(it.isFile, "not a file: $it") } + } + + private val kotlinStdlibJar: File + get() { + val location = Class.forName("kotlin.text.Regex").protectionDomain.codeSource.location + return File(location.toURI()).also { assertTrue(it.isFile, "not a file: $it") } + } + + /** + * `ValueClassFixtures.kt` and its `multipart` siblings, compiled as part of this module's own + * test sourceSet -- located the same way [kotlinStdlibJar] is (`protectionDomain.codeSource`), + * except a test run leaves this one a directory of `.class` files rather than a jar, which + * [ArtifactScanner.scanJar] now accepts directly. They are never compiled *by* the test, only + * looked up after the fact: the walker still meets them purely as bytecode plus `@Metadata`. + */ + private val fixtureClasses: File + get() { + val location = Class.forName("fixture.artifactvalueclass.Meters").protectionDomain.codeSource.location + return File(location.toURI()).also { assertTrue(it.isDirectory, "expected a directory of .class files: $it") } + } + + /** + * The fixed text: every declaration the walker takes out of the whole JUnit 4 jar under a name + * the jar itself uses, with no include filter at all. + * + * Seven entries out of a 380-class jar, and the smallness is the point -- [JvmDescriptorsTest] + * is the filter that produces it. `junit.runner.Version.id` is the one the end-to-end fixture + * calls from Python, because its answer (`"4.13.2"`) is the jar's own version and so cannot be + * produced by anything except the jar's own code. + * + * These seven are exactly the names that carry **no** overload. Everything else JUnit exposes is + * an overload set and now reaches the table under a suffixed name instead; see + * [overloadsAreKeptUnderNamesDerivedFromTheirParameterTypes] for the rule and + * [overloadedNamesAreNeverArbitrated] for what is still refused. Splitting the assertion this way + * rather than pinning one 90-entry list keeps the *policy* visible: a bare Kotlin name means one + * declaration, always. + */ + @Test + fun theWholeJUnitJarYieldsExactlyTheseUnsuffixedDeclarations() { + val entries = ArtifactScanner.scanJar(junitJar, includePrefixes = emptyList()) + assertEquals( + listOf( + "junit.framework.Assert.failSame", + "junit.framework.TestCase.failSame", + "junit.runner.BaseTestRunner.getFilteredTrace", + "junit.runner.BaseTestRunner.savePreferences", + "junit.runner.BaseTestRunner.setPreference", + "junit.runner.BaseTestRunner.truncate", + "junit.runner.Version.id", + ), + entries.map { it.name }.filterNot { "__" in it }, + ) + } + + @Test + fun anEntryCarriesTheTagsAndTheCallItsDeclarationImplies() { + val entries = ArtifactScanner.scanJar(junitJar, includePrefixes = listOf("junit.runner.Version")) + assertEquals(1, entries.size) + val id = entries.single() + assertEquals("junit.runner.Version.id", id.name) + assertEquals(0, id.arity) + assertEquals(emptyList(), id.paramTags) + assertEquals("STRING", id.returnTag) + assertEquals("{ (junit.runner.Version.id()) }", id.lambdaBody) + + val setPreference = ArtifactScanner + .scanJar(junitJar, includePrefixes = listOf("junit.runner.BaseTestRunner")) + .single { it.name.endsWith(".setPreference") } + assertEquals(2, setPreference.arity) + assertEquals(listOf("STRING", "STRING"), setPreference.paramTags) + assertEquals("UNIT", setPreference.returnTag) + assertEquals( + "{ args -> (junit.runner.BaseTestRunner.setPreference((args[0] as String), (args[1] as String))) }", + setPreference.lambdaBody, + ) + } + + @Test + fun anIncludePrefixMatchesAPackageOrAClassButNotAPrefixOfEither() { + val byPackage = ArtifactScanner.scanJar(junitJar, includePrefixes = listOf("junit.runner")) + assertEquals( + listOf( + "junit.runner.BaseTestRunner.getFilteredTrace", + "junit.runner.BaseTestRunner.getPreference__String", + "junit.runner.BaseTestRunner.getPreference__String_Int", + "junit.runner.BaseTestRunner.savePreferences", + "junit.runner.BaseTestRunner.setPreference", + "junit.runner.BaseTestRunner.truncate", + "junit.runner.Version.id", + ), + byPackage.map { it.name }, + ) + + // `junit.run` is a prefix of the string `junit.runner` and of nothing else: a package + // filter that matched it would be matching text rather than a namespace. + assertEquals(emptyList(), ArtifactScanner.scanJar(junitJar, includePrefixes = listOf("junit.run")).map { it.name }) + } + + /** + * The one place this deliberately parts company with `PyREPL`'s generator. + * + * PyREPL groups a class's methods by name and emits one `def name(self, *args, **kwargs): ...` + * per group, which is exactly right for a `.pyi` stub: a stub describes a name, and Python has + * no overloading to describe. A *binding* has to pick a body, and `org.junit.Assert.assertEquals` + * has eight the walker can bind. Picking one -- even deterministically -- means + * `assertEquals(3, 3)` from Python silently calls whichever the sort order happened to put + * first, which for that method is the deprecated `(double, double)` that always fails. + * + * `FragmentScanner.distinctByName` keeps the first for Kotlin *source*, and that is not the same + * decision: the collisions it resolves are duplicate views of one declaration (an instance and a + * companion property of the same name, an `expect`/`actual` pair seen twice), not eight + * different functions. + * + * Ambiguity is counted over what the walker *would bind*, not over what the class declares: + * `getFilteredTrace` has two overloads, one taking `Throwable`, and only one of them survives + * [JvmDescriptorsTest]'s type filter -- so there is nothing to disambiguate and it keeps the bare + * name. + */ + @Test + fun overloadedNamesAreNeverArbitrated() { + val names = ArtifactScanner.scanJar(junitJar, includePrefixes = listOf("org.junit")).map { it.name } + assertTrue( + names.none { "__" !in it }, + "org.junit.Assert and org.junit.Assume are overloads all the way down, so no bare name may survive: $names", + ) + // The exact case the old drop-rule was written against, and the reason nothing may be + // arbitrated: `assertEquals(double, double)` is deprecated and always fails, and it is the + // one a sort order picked. It is reachable now -- but only from a caller who spelled out + // which overload they meant. + assertTrue("org.junit.Assert.assertEquals__Long_Long" in names, names.toString()) + assertTrue("org.junit.Assert.assertEquals__Double_Double" in names, names.toString()) + assertTrue("org.junit.Assert.assertEquals" !in names, names.toString()) + + val runner = ArtifactScanner.scanJar(junitJar, includePrefixes = listOf("junit.runner.BaseTestRunner")) + .map { it.name.substringAfterLast('.') } + assertTrue("getFilteredTrace" in runner, "one bindable overload is not an ambiguity") + assertTrue("getPreference" !in runner, "two bindable overloads is") + assertTrue("getPreference__String" in runner, runner.toString()) + } + + /** + * What a Kotlin jar's `@Metadata` says, read straight off the real `kotlin-stdlib`. + * + * This is the fact that decides how far ASM alone can go, so it is pinned against the actual + * artefact rather than against a class this test synthesised. See + * [kotlinFileFacadesAreSkippedBecauseKotlinCannotNameThem]. + */ + @Test + fun kotlinMetadataKindsAreReadableWithAsmAlone() { + val kinds = ArtifactScanner.metadataKinds( + kotlinStdlibJar, + listOf("kotlin/text/StringsKt", "kotlin/text/StringsKt__IndentKt", "kotlin/text/Regex"), + ) + assertEquals(4, kinds["kotlin/text/StringsKt"], "a multi-file class facade") + assertEquals(5, kinds["kotlin/text/StringsKt__IndentKt"], "a multi-file class part") + assertEquals(1, kinds["kotlin/text/Regex"], "an ordinary class") + + val noMetadata = ArtifactScanner.metadataKinds(junitJar, listOf("junit/runner/Version")) + assertNull(noMetadata["junit/runner/Version"], "a Java class carries no kotlin.Metadata") + } + + /** + * `kotlin.text.trimIndent` used to be unreachable: it compiles to a public static on + * `kotlin/text/StringsKt__IndentKt`, which is **package-private** (generated Kotlin in another + * package cannot call it), behind the public facade `kotlin/text/StringsKt`, which declares no + * methods of its own and which Kotlin cannot name at all -- there is no `StringsKt` in the + * Kotlin namespace, only `kotlin.text.trimIndent`. Recovering that name, and knowing that its + * first JVM parameter is an extension *receiver* rather than an ordinary argument, needs + * `@Metadata`'s `d1`/`d2` payload, which `KotlinMetadata.kt` now decodes. + * + * The facade and part class names themselves must still never appear anywhere a caller could + * see them -- neither has a Kotlin spelling, so a bound entry naming one would generate source + * that cannot compile. + */ + @Test + fun aTopLevelExtensionBehindAMultiFileFacadeIsNowReachable() { + val entries = ArtifactScanner.scanJar(kotlinStdlibJar, includePrefixes = listOf("kotlin.text")) + val names = entries.map { it.name } + assertTrue(names.none { it.startsWith("kotlin.text.StringsKt") }, "nothing may be attributed to a facade or a part: $names") + + val trimIndent = entries.single { it.name == "kotlin.text.trimIndent" } + assertEquals(1, trimIndent.arity, "the receiver is the one argument this declaration takes") + assertEquals(listOf("STRING"), trimIndent.paramTags) + assertEquals("STRING", trimIndent.returnTag) + assertEquals(listOf("import kotlin.text.trimIndent as artifact_ext_kotlin_text_trimIndent"), trimIndent.imports) + assertEquals( + "{ args -> ((args[0] as String).artifact_ext_kotlin_text_trimIndent()) }", + trimIndent.lambdaBody, + "Kotlin has no fully-qualified call syntax for an extension -- the alias from imports is load-bearing", + ) + } + + /** + * `getInWholeSeconds-impl` (`kotlin.time.Duration`) was the case the old `-` filter was written + * against, and it is *still* declined -- but now because `Duration`'s constructor is `internal`, + * a fact read from `@Metadata`, not because its JVM name contains a hyphen. `Meters` is the same + * shape (`@JvmInline value class` wrapping a single primitive) with a public constructor, which + * is what makes the difference observable: replacing the name filter with a blanket "value class + * implies declined" rule would still decline this and prove nothing. + */ + @Test + fun aPublicValueClassRoundTripsThroughItsUnderlyingPrimitive() { + val entries = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")) + val sumMeters = entries.single { it.name == "fixture.artifactvalueclass.sumMeters" } + assertEquals(2, sumMeters.arity) + assertEquals(listOf("FLOAT", "FLOAT"), sumMeters.paramTags) + assertEquals("FLOAT", sumMeters.returnTag) + assertEquals(emptyList(), sumMeters.imports, "not an extension -- no import needed to call an ordinary top-level function") + assertEquals( + "{ args -> ((fixture.artifactvalueclass.sumMeters(" + + "fixture.artifactvalueclass.Meters((args[0] as Double)), " + + "fixture.artifactvalueclass.Meters((args[1] as Double)))).value) }", + sumMeters.lambdaBody, + ) + } + + /** `kotlin.time.Duration.getInWholeSeconds-impl(J)J`'s exact shape, reproduced with a + * constructor the walker *is* allowed to call: a JVM parameter (the unboxed receiver) with no + * declared Kotlin parameter behind it. Declined by the arity check in `ArtifactScanner + * .kotlinCandidates`, not by [aPublicValueClassRoundTripsThroughItsUnderlyingPrimitive]'s + * constructor-visibility check -- both have to hold for a value class to be usable at all. + * + * `Seconds` has a public constructor and a public accessor (`raw`), so its constructor is now + * bound under the name `fixture.artifactvalueclass.Seconds` -- exactly as `Meters` is. The one + * thing that stays absent is `doubled`: a value-class own member whose implicit unboxed receiver + * has no declared Kotlin parameter, declined by the arity check regardless of visibility. */ + @Test + fun aValueClasssOwnMemberIsDeclinedForItsImplicitReceiverNotItsVisibility() { + val entries = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass.Seconds")) + val names = entries.map { it.name } + // The constructor binding is present -- Seconds has a public constructor + public accessor. + assertEquals( + listOf("fixture.artifactvalueclass.Seconds"), names, + "Seconds constructor must be bound; Seconds.doubled must not (implicit receiver, arity mismatch)", + ) + val seconds = entries.single() + assertEquals(1, seconds.arity) + assertEquals(listOf("INT"), seconds.paramTags, "Seconds wraps a Long") + assertEquals("INT", seconds.returnTag, "round-trips through its underlying Long") + assertEquals( + "{ args -> ((fixture.artifactvalueclass.Seconds((args[0] as Long))).raw) }", + seconds.lambdaBody, + ) + } + + /** Same Kotlin name, unrelated JVM shape (one mangled by a value-class parameter, one not): + * grouping has to key off the *resolved* Kotlin name for this to be caught at all -- keying off + * the raw JVM/ASM name would never have seen these two as the same declaration, and would have + * left `addMeters` bound to whichever survived a different filter. */ + @Test + fun overloadsThatCollideOnlyAfterResolvingTheKotlinNameAreStillCaught() { + val names = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")).map { it.name } + assertTrue("fixture.artifactvalueclass.addMeters" !in names, "the bare name must not be arbitrated: $names") + assertTrue("fixture.artifactvalueclass.addMeters__Meters_Meters" in names, names.toString()) + } + + /** An extension whose receiver is itself the value class -- unwrap-on-the-way-in for the + * receiver, wrap-on-the-way-out for the return, in the one declaration. */ + @Test + fun anExtensionOnAValueClassUnwrapsTheReceiverAndWrapsTheReturn() { + val doubled = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")) + .single { it.name == "fixture.artifactvalueclass.doubled" } + assertEquals(1, doubled.arity) + assertEquals(listOf("FLOAT"), doubled.paramTags) + assertEquals("FLOAT", doubled.returnTag) + assertEquals( + listOf("import fixture.artifactvalueclass.doubled as artifact_ext_fixture_artifactvalueclass_doubled"), + doubled.imports, + ) + assertEquals( + "{ args -> ((fixture.artifactvalueclass.Meters((args[0] as Double))" + + ".artifact_ext_fixture_artifactvalueclass_doubled()).value) }", + doubled.lambdaBody, + ) + } + + /** `suspend` is declined explicitly, from `@Metadata`, per AGENTS.md's "제외한 것을 조용히 + * 빠뜨리지 마라" -- not merely absent as a side effect of [boundaryTypeOf] rejecting a + * `Continuation` parameter it never even has to see. */ + @Test + fun aSuspendFunctionIsExplicitlyDeclined() { + val names = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")).map { it.name } + assertTrue("fixture.artifactvalueclass.neverBound" !in names, "suspend must never reach the table: $names") + } + + /** + * `@JvmMultifileClass` split across two files, reproducing `kotlin.text.trimIndent`'s own shape + * with a fixture the test controls: a plain top-level function in one part, and -- combining the + * two hard cases at once -- an extension function in the other. + */ + @Test + fun aMultiFileFacadesPartsAreAllReachableUnderThePackageName() { + val entries = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass.multipart")) + val names = entries.map { it.name } + assertTrue(names.none { "MultiFacadeKt" in it }, "the facade's own JVM name has no Kotlin spelling: $names") + + val greeting = entries.single { it.name == "fixture.artifactvalueclass.multipart.partGreeting" } + assertEquals("{ (fixture.artifactvalueclass.multipart.partGreeting()) }", greeting.lambdaBody) + + val shout = entries.single { it.name == "fixture.artifactvalueclass.multipart.shoutViaFacade" } + assertEquals( + listOf("import fixture.artifactvalueclass.multipart.shoutViaFacade as artifact_ext_fixture_artifactvalueclass_multipart_shoutViaFacade"), + shout.imports, + ) + } + + /** `internal` is JVM-public but not a name generated Kotlin may call; filtered from `@Metadata`'s + * own visibility, not from the JVM `ACC_PUBLIC` bit every one of these methods also carries. */ + @Test + fun anInternalTopLevelFunctionIsDeclinedRegardlessOfItsJvmVisibility() { + val names = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")).map { it.name } + assertTrue("fixture.artifactvalueclass.secretlyInternal" !in names, "internal must not reach the table: $names") + } + + // ------------------------------------------------------------------ object handles (OBJECT) + + /** + * An ordinary class -- not a primitive, not a value class -- in return position. + * + * `docs/design/kotlin-extensions-in-python.md` §6 lists this as the second of the two gates that + * independently zero Compose: "Binding a parameter typed `Shape`, `Brush` or `PaddingValues` + * needs an object-handle boundary type that `boundaryTypeOf` does not have." It does not need a + * *marshaller*: `python.multiplatform.reflection.TypeTag.OBJECT` and + * `python.multiplatform.reflection.HandleTable` have carried arbitrary Kotlin objects across as + * opaque integers since the upcall trampoline was written, and KSP's own `tagFor` has emitted + * `OBJECT` for every non-primitive since it existed. What was missing is only the walker's half: + * a Kotlin *type name* to cast to, which `@Metadata` now supplies and a JVM descriptor never + * could. + * + * So the generated body is an ordinary cast, and the value never leaves Kotlin -- see + * `ArtifactScanner`'s KDoc for what owns the handle on the Python side. + */ + @Test + fun anOrdinaryClassCrossesAsAnObjectHandle() { + val entries = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")) + + val makeRope = entries.single { it.name == "fixture.artifactvalueclass.makeRope" } + assertEquals(listOf("FLOAT"), makeRope.paramTags) + assertEquals("OBJECT", makeRope.returnTag) + assertEquals( + "{ args -> (fixture.artifactvalueclass.makeRope((args[0] as Double))) }", + makeRope.lambdaBody, + ) + + val ropeLength = entries.single { it.name == "fixture.artifactvalueclass.ropeLength" } + assertEquals(listOf("OBJECT"), ropeLength.paramTags) + assertEquals("FLOAT", ropeLength.returnTag) + assertEquals( + "{ args -> (fixture.artifactvalueclass.ropeLength((args[0] as fixture.artifactvalueclass.Rope))) }", + ropeLength.lambdaBody, + ) + // The declared type survives beside the tag: `docs/archive/pythonx-adapter-design.md` §2.4 row 4. + assertEquals(listOf("fixture.artifactvalueclass.Rope"), ropeLength.paramTypeNames) + assertEquals(listOf("rope"), ropeLength.paramNames) + assertEquals(listOf(false), ropeLength.paramHasDefault) + } + + /** + * `Modifier.padding(Dp): Modifier`'s exact shape with types this module compiles: an extension + * whose receiver and return are the same ordinary class. This is the one that makes chaining + * work -- `docs/design/kotlin-extensions-in-python.md` §4.1 -- because the handle that comes back is + * the same kind of thing the next call takes. + */ + @Test + fun anExtensionOnAnOrdinaryClassChainsThroughObjectHandles() { + val lengthened = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")) + .single { it.name == "fixture.artifactvalueclass.lengthened" } + assertEquals(listOf("OBJECT", "FLOAT"), lengthened.paramTags) + assertEquals("OBJECT", lengthened.returnTag) + assertEquals("fixture.artifactvalueclass.Rope", lengthened.receiverTypeName) + assertEquals( + listOf("import fixture.artifactvalueclass.lengthened as artifact_ext_fixture_artifactvalueclass_lengthened"), + lengthened.imports, + ) + assertEquals( + "{ args -> ((args[0] as fixture.artifactvalueclass.Rope)" + + ".artifact_ext_fixture_artifactvalueclass_lengthened((args[1] as Double))) }", + lengthened.lambdaBody, + ) + } + + // ---------------------------------------------------------------------------- constructors + + /** + * A class's own public primary constructor, bound the same way a top-level function is: + * `Rope(Double): Rope`'s shape, and [Rope]'s own KDoc records why this used to be entirely + * absent -- `ArtifactScanner.kotlinCandidates`'s `ACC_STATIC` filter drops `` along with + * every instance method, and nothing else ever picked it back up. `docs/archive/pythonx-adapter-design.md` + * §10 names `Typography`/`Shapes` as the real-world casualty: not declined, simply never scanned. + */ + @Test + fun aClasssOwnPublicConstructorIsBoundUnderTheClassName() { + val rope = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")) + .single { it.name == "fixture.artifactvalueclass.Meters" } + assertEquals(1, rope.arity) + assertEquals(listOf("FLOAT"), rope.paramTags) + assertEquals("FLOAT", rope.returnTag, "Meters wraps a Double and unwraps back to one") + assertNull(rope.receiverTypeName, "a constructor is not an extension") + assertEquals( + "{ args -> ((fixture.artifactvalueclass.Meters((args[0] as Double))).value) }", + rope.lambdaBody, + "a fully-qualified constructor call is valid Kotlin, exactly like a fully-qualified function call", + ) + } + + /** A value class's own constructor round-trips through its underlying primitive on the way back + * out, the same as any other declaration returning [Meters] -- `Meters` is not special-cased, + * it goes through [ArtifactScanner.candidateFromFunction] unchanged. */ + @Test + fun aValueClasssOwnConstructorRoundTripsThroughItsUnderlyingPrimitive() { + val meters = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")) + .single { it.name == "fixture.artifactvalueclass.Meters" } + assertEquals(1, meters.arity) + assertEquals(listOf("FLOAT"), meters.paramTags) + assertEquals("FLOAT", meters.returnTag, "Meters wraps a Double and unwraps back to one") + assertEquals( + "{ args -> ((fixture.artifactvalueclass.Meters((args[0] as Double))).value) }", + meters.lambdaBody, + ) + } + + /** `` never reaches [ArtifactScanner.kotlinCandidates]'s own binder -- that path's + * `ACC_STATIC` filter excludes every constructor, so nothing about [constructorCandidates] is a + * duplicate of it. Pinned by absence: no `fixture.artifactvalueclass.Rope.`-shaped name of + * any kind reaches the table through the ordinary function path. */ + @Test + fun aConstructorIsNeverAlsoBoundAsAnOrdinaryStaticMember() { + val names = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")).map { it.name } + assertTrue(names.none { "" in it }, names.toString()) + assertEquals(1, names.count { it == "fixture.artifactvalueclass.Meters" }, "exactly one binding for Meters's one public constructor") + } + + // ---------------------------------------------------------------------------- properties + + @Test + fun objectPropertiesAreBoundAsStaticGetters() { + val candidates = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")) + val topLeft = candidates.singleOrNull { it.name == "fixture.artifactvalueclass.AbsoluteAlignment.TopLeft" } + assertNotNull(topLeft, "TopLeft not found!") + assertEquals(0, topLeft!!.arity) + assertEquals("STATIC_GETTER", topLeft.kind) + assertEquals(emptyList(), topLeft.paramTags) + assertEquals("OBJECT", topLeft.returnTag) + assertEquals( + "{ (fixture.artifactvalueclass.AbsoluteAlignment.TopLeft) }", + topLeft.lambdaBody + ) + } + + @Test + fun interfaceCompanionPropertiesAreBoundWithoutCompanionInName() { + val candidates = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")) + val center = candidates.singleOrNull { it.name == "fixture.artifactvalueclass.Alignment.Center" } + assertNotNull(center, "Center not found!") + assertEquals(0, center!!.arity) + assertEquals("STATIC_GETTER", center.kind) + assertEquals(emptyList(), center.paramTags) + assertEquals("OBJECT", center.returnTag) + assertEquals( + "{ (fixture.artifactvalueclass.Alignment.Companion.Center) }", + center.lambdaBody + ) + } + + // ------------------------------------------------------------------------------- overloads + + /** + * Overloads are **kept, under names that say which one they are** -- the rule + * `docs/design/kotlin-extensions-in-python.md` §3.1 and §6 left open. + * + * The old rule dropped a name outright when more than one binding would carry it, on the + * reasoning that a sort order picking `org.junit.Assert.assertEquals(double, double)` is a wrong + * call that never reports itself. That reasoning is about *arbitration*, and it is still right: + * nothing here picks. What changed is that `@Metadata` now supplies the Kotlin parameter types, + * so the overloads can be told apart by name instead of arbitrated between -- and the price of + * not doing so is 33 of `Modifier`'s 130 names, including `padding`, `size`, `background`, + * `border` and `clickable`. + * + * The plain name stays unbound for a group of more than one. A Python caller who writes + * `padding` gets an `AttributeError` naming a declaration that does not exist, not a silent call + * to whichever overload sorted first. + */ + @Test + fun overloadsAreKeptUnderNamesDerivedFromTheirParameterTypes() { + val names = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")).map { it.name } + + assertTrue("fixture.artifactvalueclass.addMeters" !in names, "the bare name must not be arbitrated: $names") + assertTrue("fixture.artifactvalueclass.addMeters__Int_Int" in names, names.toString()) + assertTrue("fixture.artifactvalueclass.addMeters__Meters_Meters" in names, names.toString()) + } + + /** Two overloads that differ only in their *receiver* are not separated by their value + * parameters, so the receiver joins the name -- `Int.times` and `Double.times` in + * `androidx.compose.ui.unit` are the real instance of this. Paid for only where it is needed: + * [overloadsAreKeptUnderNamesDerivedFromTheirParameterTypes]'s `addMeters` keeps the shorter + * form. */ + @Test + fun overloadsThatDifferOnlyInTheirReceiverAreSeparatedByIt() { + val names = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")).map { it.name } + assertTrue("fixture.artifactvalueclass.tagged__Rope" in names, names.toString()) + assertTrue("fixture.artifactvalueclass.tagged__Meters" in names, names.toString()) + } + + /** + * The scorecard, run against real, locally-cached Compose Multiplatform desktop jars. Skipped + * (not failed) when this machine's Gradle cache does not have them, since the path is + * local-machine state, not something a clean checkout can be expected to have -- see + * `ArtifactScannerTest`'s own KDoc for why the jars this test *does* control (junit, + * kotlin-stdlib) are found the same way rather than pinned as a path. + * + * **The baseline this replaces was zero**, and it was zero for two independent reasons + * (`docs/design/kotlin-extensions-in-python.md` §3): the metadata-kind gate, closed by `15fc5a62`, and + * the type gate, closed here. `Modifier.padding(Dp): Modifier` has `Modifier` as both its + * receiver and its return, and `Modifier` is an ordinary interface -- neither a primitive nor a + * value class -- so before an object-handle boundary type existed there was nothing for + * `resolveKotlinType` to resolve it to. + * + * The floors below are deliberately floors and not equalities: the exact number is a property of + * Compose 1.6.11's own API surface, and a Compose bump that adds a `Modifier` extension must not + * fail this. What the floors pin is that the two gates are open, which is the fact under test. + * + * `docs/design/ecosystem.md` §5b's own target (calling a composable) is out of scope here on purpose -- + * this counts declarations, and `:ksp-fixtures:artifact` is where a call is actually made. + */ + @Test + fun composeModifierExtensionsSurviveBothGates() { + val gradleCaches = listOf( + File(System.getProperty("user.home"), ".gradle/caches/modules-2/files-2.1"), + File("/Volumes/macMini/caches/.gradle/caches/modules-2/files-2.1"), + ).firstOrNull { it.isDirectory } + if (gradleCaches == null) return // no local Gradle cache found at either known location + + fun jarUnder(group: String, artifact: String): File? = gradleCaches.resolve(group).resolve(artifact) + .walkTopDown().firstOrNull { it.isFile && it.name.endsWith(".jar") && "sources" !in it.name } + + val layoutJar = jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop") ?: return + val unitJar = jarUnder("org.jetbrains.compose.ui", "ui-unit-desktop") ?: return + val walked = listOfNotNull( + layoutJar, + unitJar, + jarUnder("org.jetbrains.compose.foundation", "foundation-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-desktop"), + jarUnder("org.jetbrains.compose.material", "material-desktop"), + jarUnder("org.jetbrains.compose.material3", "material3-desktop"), + ) + val classpath = walked + listOfNotNull( + jarUnder("org.jetbrains.compose.ui", "ui-geometry-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-text-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-util-desktop"), + jarUnder("org.jetbrains.compose.runtime", "runtime-desktop"), + jarUnder("org.jetbrains.compose.runtime", "runtime-saveable-desktop"), + jarUnder("org.jetbrains.compose.animation", "animation-core-desktop"), + jarUnder("org.jetbrains.compose.collection-internal", "collection-desktop"), + jarUnder("org.jetbrains.compose.annotation-internal", "annotation-desktop"), + kotlinStdlibJar, + ) + + val all = walked.flatMap { ArtifactScanner.scanJar(it, includePrefixes = listOf("androidx.compose"), classpath = classpath) } + val modifierExtensions = all.filter { it.receiverTypeName == "androidx.compose.ui.Modifier" } + + // What did *not* survive, and why -- counted rather than asserted about, because "the rest + // take lambdas" is exactly the kind of sentence this repository has been burned by. + val declined = declinedModifierExtensions(walked, classpath, modifierExtensions.map { it.name.substringAfterLast('.').substringBefore("__") }.toSet()) + + // Printed as well as asserted: this test's whole reason to exist is the number, and a + // passing floor assertion does not report what the number actually was. + println("compose: ${all.size} declarations bound, ${modifierExtensions.size} of them Modifier extensions") + println("compose: ${declined.size} public top-level Modifier extensions declined, ${declined.count { it.second }} of them for a function-typed parameter") + + assertTrue( + modifierExtensions.size >= 100, + "expected the Modifier chain to be reachable; got ${modifierExtensions.size}: " + + modifierExtensions.map { it.name }, + ) + + // The names `docs/design/kotlin-extensions-in-python.md` §3.1 lists as the casualties of the old + // drop-ambiguous-overloads rule, spelled the way the new rule spells them. + val layoutNames = ArtifactScanner + .scanJar(layoutJar, includePrefixes = listOf("androidx.compose.foundation.layout"), classpath = classpath) + .map { it.name } + assertTrue("androidx.compose.foundation.layout.padding__Dp" in layoutNames, layoutNames.toString()) + assertTrue("androidx.compose.foundation.layout.padding__Dp_Dp" in layoutNames, layoutNames.toString()) + assertTrue("androidx.compose.foundation.layout.padding__Dp_Dp_Dp_Dp" in layoutNames, layoutNames.toString()) + assertTrue("androidx.compose.foundation.layout.padding__PaddingValues" in layoutNames, layoutNames.toString()) + assertTrue("androidx.compose.foundation.layout.size__Dp" in layoutNames, layoutNames.toString()) + + val unitEntries = ArtifactScanner.scanJar(unitJar, includePrefixes = listOf("androidx.compose.ui.unit"), classpath = classpath) + assertTrue(unitEntries.isNotEmpty(), "expected at least one Dp-only declaration in androidx.compose.ui.unit to be reachable") + } + + /** + * Every public top-level `Modifier` extension the walked jars *declare* and this walk did not + * bind, paired with whether it has a function-typed (lambda) parameter. + * + * Reads the same `@Metadata` `ArtifactScanner` does, deliberately without going through it: the + * point is to compare "declared" against "bound", so deriving the first from the second would + * make the comparison vacuous. Composables are excluded the same way the scanner excludes them + * -- a JVM parameter count that exceeds the Kotlin one means synthetic parameters + * (`$composer`/`$changed`/`$default`) that generated Kotlin source cannot supply. + * + * @return (Kotlin name, has a `kotlin.Function*` parameter). + */ + private fun declinedModifierExtensions( + walked: List, + classpath: List, + boundNames: Set, + ): List> { + val artifactClasspath = ArtifactClasspath(classpath) + val declined = mutableListOf>() + walked.forEach { jar -> + java.util.jar.JarFile(jar).use { file -> + file.entries().asSequence().filter { it.name.endsWith(".class") && '$' !in it.name }.forEach { entry -> + val node = file.getInputStream(entry).use { readClassNodeOrNull(it.readBytes()) } ?: return@forEach + val container = when (val metadata = kotlinClassMetadataOf(node)) { + is kotlin.metadata.jvm.KotlinClassMetadata.FileFacade -> listOf(metadata.kmPackage to node) + is kotlin.metadata.jvm.KotlinClassMetadata.MultiFileClassFacade -> metadata.partClassNames.mapNotNull { part -> + val partNode = artifactClasspath.classNode(part) ?: return@mapNotNull null + val partMetadata = kotlinClassMetadataOf(partNode) as? kotlin.metadata.jvm.KotlinClassMetadata.MultiFileClassPart + ?: return@mapNotNull null + partMetadata.kmPackage to partNode + } + else -> return@forEach + } + container.forEach { (kmPackage, owner) -> + val jvmArity = owner.methods.associate { it.name to splitMethodDescriptor(it.desc).first.size } + functionsOf(kmPackage).forEach { function -> + if (function.receiverType?.let { kotlinClassifierNameOf(it) } != "androidx.compose.ui.Modifier") return@forEach + if (jvmArity[function.jvmSignature.name] != function.allParameterTypes.size) return@forEach + if (function.kotlinName in boundNames) return@forEach + declined += function.kotlinName to function.allParameterTypes.any { + kotlinClassifierNameOf(it)?.startsWith("kotlin.Function") == true + } + } + } + } + } + } + return declined.distinctBy { it.first } + } + + /** + * `docs/archive/pythonx-adapter-design.md` §10's `Typography`/`Shapes` casualty, closed, and pinned + * against the structural rule that closed it rather than against the two hard-coded class names + * an earlier attempt used. + * + * `Typography` and `Shapes` are ordinary (non-`value`) classes with 15 and 5 constructor + * parameters respectively, and [ArtifactScanner.constructorCandidates] used to bind only + * `value`-class constructors. Widening it to every public class opened them both. + * + * `TextStyle` was the measured reason it could not stay unconditional: `TextStyle` also + * publishes a constant under a dotted continuation of its own name (`TextStyle.Default`, an + * `object`/companion property [ArtifactScanner.objectConstantCandidates] binds independently), + * and a constructor bound to the bare name `TextStyle` would claim it as a callable, making + * `TextStyle.Default` unreachable. `Typography` and `Shapes` publish no such constant. + * [ArtifactScanner.dropCollidingConstructors] is where that fact is now decided, in place of a + * name list that would have needed one more line per class someone next wants opened -- + * [aFocusRequesterConstructorIsDroppedBecauseItWouldShadowFocusRequesterDefault] pins that rule + * directly. `TextStyle` itself is pinned here only by outcome (no bound constructor, `Default` + * still reachable). Every public `TextStyle` constructor takes a value class, so each one is a + * JVM-`private` `` behind a synthetic `DefaultConstructorMarker` bridge; `javap -v` on + * ui-text-desktop 1.11.1 shows four of those bridges carrying `kotlin.Deprecated(level = HIDDEN)` + * and two that do not. Until issue #73 the bytecode cross-check in + * [ArtifactScanner.constructorCandidates] refused every synthetic ``, so all six were gone + * before [ArtifactScanner.dropCollidingConstructors] saw them; since #73 it refuses only the hidden + * four, and the other two reach -- and are dropped by -- the constant-collision rule. + */ + @Test + fun aTypographyOrShapesConstructorBindsButATextStyleConstructorDoesNot() { + val gradleCaches = listOf( + File(System.getProperty("user.home"), ".gradle/caches/modules-2/files-2.1"), + File("/Volumes/macMini/caches/.gradle/caches/modules-2/files-2.1"), + ).firstOrNull { it.isDirectory } + if (gradleCaches == null) return // no local Gradle cache found at either known location + + fun jarUnder(group: String, artifact: String): File? = gradleCaches.resolve(group).resolve(artifact) + .walkTopDown().firstOrNull { it.isFile && it.name.endsWith(".jar") && "sources" !in it.name } + + val material3Jar = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return + val uiTextJar = jarUnder("org.jetbrains.compose.ui", "ui-text-desktop") ?: return + val classpath = listOfNotNull( + material3Jar, + uiTextJar, + jarUnder("org.jetbrains.compose.ui", "ui-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-geometry-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-unit-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-util-desktop"), + jarUnder("org.jetbrains.compose.runtime", "runtime-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop"), + jarUnder("org.jetbrains.compose.material", "material-desktop"), + jarUnder("org.jetbrains.compose.collection-internal", "collection-desktop"), + jarUnder("org.jetbrains.compose.annotation-internal", "annotation-desktop"), + ) + + val material3Names = ArtifactScanner.scanJar(material3Jar, classpath = classpath).map { it.name } + assertTrue( + "androidx.compose.material3.Typography" in material3Names, + "Typography's own constructor should bind: ${material3Names.filter { "Typography" in it }}", + ) + assertTrue( + "androidx.compose.material3.Shapes" in material3Names, + "Shapes' own constructor should bind: ${material3Names.filter { "Shapes" in it }}", + ) + + val textStyleCallables = ArtifactScanner.scanJar(uiTextJar, classpath = classpath) + .filter { it.name.startsWith("androidx.compose.ui.text.TextStyle") } + assertTrue( + textStyleCallables.none { it.name == "androidx.compose.ui.text.TextStyle" }, + "TextStyle's own constructor must stay declined, or it would shadow TextStyle.Default: $textStyleCallables", + ) + assertTrue( + textStyleCallables.any { it.name == "androidx.compose.ui.text.TextStyle.Default" && it.kind == "STATIC_GETTER" }, + "TextStyle.Default is the constant a bound TextStyle constructor would make unreachable: $textStyleCallables", + ) + } + + /** + * [ArtifactScanner.dropCollidingConstructors]'s constant-collision rule, exercised end to end + * against a real class -- unlike `TextStyle` above, `FocusRequester` has exactly one public + * constructor (`FocusRequester()`, no parameters, not deprecated), so this is the rule actually + * doing the work rather than the bytecode cross-check pre-empting it. + */ + @Test + fun aFocusRequesterConstructorIsDroppedBecauseItWouldShadowFocusRequesterDefault() { + val gradleCaches = listOf( + File(System.getProperty("user.home"), ".gradle/caches/modules-2/files-2.1"), + File("/Volumes/macMini/caches/.gradle/caches/modules-2/files-2.1"), + ).firstOrNull { it.isDirectory } + if (gradleCaches == null) return // no local Gradle cache found at either known location + + fun jarUnder(group: String, artifact: String): File? = gradleCaches.resolve(group).resolve(artifact) + .walkTopDown().firstOrNull { it.isFile && it.name.endsWith(".jar") && "sources" !in it.name } + + val uiJar = jarUnder("org.jetbrains.compose.ui", "ui-desktop") ?: return + val classpath = listOfNotNull( + uiJar, + jarUnder("org.jetbrains.compose.ui", "ui-geometry-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-unit-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-util-desktop"), + jarUnder("org.jetbrains.compose.runtime", "runtime-desktop"), + ) + + val bound = ArtifactScanner.scanJar(uiJar, classpath = classpath) + .filter { it.name.startsWith("androidx.compose.ui.focus.FocusRequester") } + assertTrue( + bound.none { it.name == "androidx.compose.ui.focus.FocusRequester" }, + "FocusRequester's own constructor must stay declined: $bound", + ) + assertTrue( + bound.any { it.name == "androidx.compose.ui.focus.FocusRequester.Default" && it.kind == "STATIC_GETTER" }, + "FocusRequester.Default is the constant a bound constructor would shadow: $bound", + ) + + val declined = ArtifactScanner.scanDeclarations(uiJar, classpath = classpath) + .filter { it.simpleName == "FocusRequester" && it.owner == "androidx.compose.ui.focus" } + assertTrue(declined.isNotEmpty(), "expected FocusRequester's own constructor to have been scanned") + assertTrue(declined.all { it.bindingName == null }, declined.toString()) + assertTrue( + declined.all { it.declineReason == "constructor name collides with a constant this class publishes under its own name" }, + declined.map { it.declineReason }.toString(), + ) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactTableRenderingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactTableRenderingTest.kt new file mode 100644 index 000000000..3c3ac51ad --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactTableRenderingTest.kt @@ -0,0 +1,86 @@ +package python.multiplatform.gradle.artifact + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Where the walker's output joins KSP's, and why it is a second list rather than a longer first one. + * + * `docs/design/ecosystem.md` §5b: both producers run at build time under the same applied plugin, and both + * emit `FunctionTableFragment`s into the one `UpcallTable`. The question this pins is only *who + * assembles the list*. + * + * KSP's `FunctionTable` is not extended, for three reasons: + * + * 1. **`FunctionTable` already means something.** `PackageScanFragmentDiscovery` builds it from + * `Resolver.getDeclarationsFromPackage(python.multiplatform.generated.fragments)` -- "every + * module in this graph that was compiled with the processor". `ksp-fixtures/app`'s + * `CommonInstallTest` asserts that set exactly. Putting walked artefacts in it would silently + * change a shipped contract, and would make a consumer's table grow when they added an unrelated + * dependency. + * 2. **The ordering would have to be built the wrong way round.** For KSP to *see* a walked + * fragment, the walker's source would have to be generated before `kspKotlin` runs and + * land somewhere `getDeclarationsFromPackage` looks. That API's behaviour for declarations that + * exist only in the source being processed is undocumented; a fragment it silently missed would + * disappear from the table with no build failure anywhere. Emitting a list the walker owns + * outright removes the question. + * 3. **Which artefacts to bind is a consumer's decision, and should be visible at the install + * site.** Nobody wants every declaration on their compile classpath in their Python namespace. + * + * So: one `UpcallTable`, one fragment interface, one Python surface -- two aggregators, named after + * the two things they aggregate. + */ +class ArtifactTableRenderingTest { + + @Test + fun theAggregatorAddsRatherThanReplaces() { + val source = renderArtifactTableSource(listOf("ArtifactFragment_a", "ArtifactFragment_b")) + assertEquals( + """ + |// GENERATED by python-multiplatform-gradle-plugin. Do not edit. + |package python.multiplatform.generated.artifacts + | + |/** + | * Every resolved artefact this module asked to have bound. + | * + | * Not merged into `python.multiplatform.generated.FunctionTable`: that one means "every + | * module in this build graph that was compiled with the processor", and a walked + | * third-party jar is not one. Install both: + | * + | * UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + | */ + |object ArtifactTable { + | val fragments: List = listOf( + | ArtifactFragment_a, + | ArtifactFragment_b, + | ) + | + | /** Adds these to whatever is already installed, leaving it in place. */ + | fun registerInto() { + | fragments.forEach { python.multiplatform.reflection.UpcallTable.register(it) } + | } + |} + | + """.trimMargin(), + source, + ) + } + + /** + * A coordinate is not an identifier, and a fragment object's name has to be one. + * + * The same sanitisation `python-multiplatform-ksp`'s `sanitiseModuleName` does for a Gradle path, + * applied to what a resolved artefact calls itself. The *module* name keeps the original text, + * because `UpcallTable.register` deduplicates on it and two coordinates that differ only in + * punctuation must not collapse into one. + */ + @Test + fun coordinatesBecomeIdentifiers() { + assertEquals("ArtifactFragment_junit_junit_4_13_2", artifactFragmentObjectName("junit:junit:4.13.2")) + assertEquals( + "ArtifactFragment_org_jetbrains_kotlin_kotlin_stdlib_2_4_20_Beta2", + artifactFragmentObjectName("org.jetbrains.kotlin:kotlin-stdlib:2.4.20-Beta2"), + ) + assertEquals("ArtifactFragment_project__lib", artifactFragmentObjectName("project :lib")) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableBindingTest.kt new file mode 100644 index 000000000..0a0de16eb --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableBindingTest.kt @@ -0,0 +1,642 @@ +package python.multiplatform.gradle.artifact + +import org.objectweb.asm.Opcodes +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * `docs/archive/pythonx-adapter-design.md` §5, measured against real Compose Multiplatform jars. + * + * ### What this test exists to pin + * + * A `@Composable` used to be declined by [ArtifactScanner] for the one reason its KDoc gave: "the + * JVM signature carries N parameter(s) metadata does not declare". §5.2 measured what those + * parameters *are* -- `$composer`, one or more `$changed` bitmasks and (when anything is defaulted) + * one or more `$default` bitmasks -- and this walks every public `@Composable` in the jars to check + * that the shape is **derivable**, which is what makes binding one possible at all. + * + * Nothing here guesses. The trailing shape is read off the descriptor, and the arithmetic that + * predicts it (`SLOTS_PER_INT = 10`, `BITS_PER_INT = 31`) is asserted against every composable the + * jars declare rather than against the nine `javap` rows §5.2 recorded by hand. + */ +class ComposableBindingTest { + + private val caches: File? = listOf( + File(System.getProperty("user.home"), ".gradle/caches/modules-2/files-2.1"), + File("/Volumes/macMini/caches/.gradle/caches/modules-2/files-2.1"), + ).firstOrNull { it.isDirectory } + + private fun jarUnder(group: String, artifact: String): File? = caches?.resolve(group)?.resolve(artifact) + ?.walkTopDown()?.firstOrNull { it.isFile && it.name.endsWith(".jar") && "sources" !in it.name } + + private val kotlinStdlibJar: File + get() = File(Class.forName("kotlin.text.Regex").protectionDomain.codeSource.location.toURI()) + + /** Everything a Compose type might have to be resolved against. */ + private fun composeClasspath(): List = listOfNotNull( + jarUnder("org.jetbrains.compose.material3", "material3-desktop"), + jarUnder("org.jetbrains.compose.material", "material-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-unit-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-text-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-geometry-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-util-desktop"), + jarUnder("org.jetbrains.compose.runtime", "runtime-desktop"), + jarUnder("org.jetbrains.compose.runtime", "runtime-saveable-desktop"), + jarUnder("org.jetbrains.compose.animation", "animation-core-desktop"), + jarUnder("org.jetbrains.compose.collection-internal", "collection-desktop"), + jarUnder("org.jetbrains.compose.annotation-internal", "annotation-desktop"), + kotlinStdlibJar, + ) + + /** + * The measurement §5.2 did by hand for nine functions, run over every public top-level + * `@Composable` three Compose jars declare. + * + * For each one: the JVM parameter list is the Kotlin parameter list, then **one** `Composer`, + * then nothing but `int`s. The number of those ints is `changedParamCount(n) + + * defaultParamCount(n)` when the declaration defaults anything and `changedParamCount(n)` when + * it does not -- which is what makes it possible to say *which* trailing slot is the mask. + * + * Deliberately not an assertion about a fixed count: it prints how many it checked, and fails on + * the first composable whose shape the arithmetic does not predict. + */ + @Test + fun everyComposableJvmSignatureIsKotlinParamsThenComposerThenInts() { + val jars = listOfNotNull( + jarUnder("org.jetbrains.compose.material3", "material3-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop"), + jarUnder("org.jetbrains.compose.material", "material-desktop"), + ) + if (jars.isEmpty()) return // no local Gradle cache with Compose in it + val classpath = composeClasspath() + + var checked = 0 + var withDefaults = 0 + val wrong = mutableListOf() + forEachPublicComposable(jars, classpath) { owner, function, method -> + val (descriptors, _) = splitMethodDescriptor(method.desc) + val n = function.allParameterTypes.size + val shape = ComposableShape.of(n, descriptors) + checked++ + if (shape == null) { + wrong += "$owner.${function.kotlinName} ${method.desc}" + return@forEachPublicComposable + } + if (shape.defaultCount > 0) withDefaults++ + // The `$default` masks exist exactly when the declaration defaults something. + val declares = function.allParameterDefaults.any { it } + if (declares != (shape.defaultCount > 0)) { + wrong += "$owner.${function.kotlinName}: declares=$declares defaultCount=${shape.defaultCount}" + } + } + + println("composable shape: $checked public top-level composables checked, $withDefaults carry a \$default mask") + assertTrue(checked > 100, "expected the jars to declare composables; checked only $checked") + assertEquals(emptyList(), wrong, "composables whose JVM shape the arithmetic did not predict") + } + + /** + * The binding itself: `androidx.compose.material3.Text` reaches the table, with the three + * synthetic parameter groups as **ordinary slots** Python fills. + * + * Before this, the same walk produced a declined [python.multiplatform.gradle.model.DeclarationModel] + * whose reason was "the JVM signature carries 4 parameter(s) metadata does not declare". + */ + @Test + fun composablesAreBoundWithTheirSyntheticParametersExposed() { + val material3 = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return + val entries = ArtifactScanner.scanJar( + material3, + includePrefixes = listOf("androidx.compose.material3"), + classpath = composeClasspath(), + ) + val texts = entries.filter { it.name.substringAfterLast('.').substringBefore("__") == "Text" } + assertTrue(texts.isNotEmpty(), "no Text binding among ${entries.size} entries") + + val stringText = texts.firstOrNull { it.paramTypeNames.firstOrNull() == "kotlin.String" } + assertNotNull(stringText, "no String-taking Text among ${texts.map { it.name to it.arity }}") + + // The declared parameters, then Composer, two $changed masks and one $default -- the shape + // `javap -p` shows and `everyComposableJvmSignatureIsKotlinParamsThenComposerThenInts` + // proves is derivable. **How many** declared parameters is Compose's business and changes + // between versions (1.6.11's `Text` takes 16, 1.7.0's takes 17 -- `minLines`), so the + // assertion is the relation and not the number. + val declared = stringText.paramNames.indexOf("\$composer") + assertTrue(declared >= 16, "expected Text to declare at least 16 parameters, got $declared") + assertEquals(declared + 4, stringText.arity, stringText.paramNames.toString()) + assertEquals(stringText.arity, stringText.paramNames.size) + assertEquals(stringText.arity, stringText.paramTags.size) + // The synthetic slots separate nothing, so they must not reach the Python-visible name. + assertTrue("Composer" !in stringText.name, stringText.name) + assertEquals( + listOf("\$composer", "\$changed", "\$changed1", "\$default"), + stringText.paramNames.takeLast(4), + ) + assertEquals( + listOf("OBJECT", "INT", "INT", "INT"), + stringText.paramTags.takeLast(4), + ) + assertEquals("STRING", stringText.paramTags.first()) + // The declaration's own defaults survive onto the binding: the mask is what makes them + // reachable, so unlike the sentinel mechanism there is no cap and no branch to decline. + assertTrue(stringText.paramHasDefault.count { it } >= 14, stringText.paramHasDefault.toString()) + assertEquals(false, stringText.paramHasDefault.first(), "text has no default") + assertEquals( + listOf(false, false, false, false), + stringText.paramHasDefault.takeLast(4), + "a synthetic slot is never omittable: pythonx always computes it", + ) + } + + /** + * The generated Kotlin never names the composable, and it cannot: a Kotlin file facade + * (`androidx.compose.material3.TextKt`) has **no Kotlin name at all**, so + * `TextKt.\`Text-fLXpl1I\`(...)` does not compile -- "unresolved reference 'TextKt'", measured + * with `kotlinc` 2.4.20-Beta2 against the real jar. The call site is therefore emitted as + * bytecode; see [ComposableThunks]. + */ + @Test + fun aComposableBindingCallsAGeneratedThunkRatherThanNamingTheDeclaration() { + val material3 = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return + val entries = ArtifactScanner.scanJar( + material3, + includePrefixes = listOf("androidx.compose.material3"), + classpath = composeClasspath(), + ) + val text = entries.first { it.name.substringAfterLast('.').substringBefore("__") == "Text" } + val thunk = assertNotNull(text.thunk, "a composable binding must carry a thunk spec") + assertEquals("androidx/compose/material3/TextKt", thunk.ownerInternalName) + assertTrue(thunk.methodName.startsWith("Text"), thunk.methodName) + assertTrue(text.lambdaBody.contains("$THUNK_CLASS_TOKEN.t"), text.lambdaBody) + } + + /** + * The emitted thunk really links: generated with ASM against a class this test compiled itself, + * loaded, and called with a boxed `Object[]` exactly as the boundary hands one over. + * + * The target deliberately has a **mangled** JVM name, because that is the case that closes every + * other route: a hyphen is not a Java identifier and a Kotlin file facade is not a Kotlin name. + */ + @Test + fun aGeneratedThunkCallsAMangledJvmNameAndUnboxesTheBoundaryTypes() { + val targetInternal = "fixture/thunk/MangledProbe" + val target = mangledProbeClass(targetInternal) + val bytes = generateThunkClass( + "Probe", + listOf( + ThunkSpec(targetInternal, "sum-abc123", "(IJFZLjava/lang/String;)J"), + ThunkSpec(targetInternal, "nothing", "(I)V"), + ), + ) + val loader = object : ClassLoader(javaClass.classLoader) { + fun define(name: String, data: ByteArray): Class<*> = defineClass(name, data, 0, data.size) + } + loader.define(targetInternal.replace('/', '.'), target) + val cls = loader.define("$THUNK_PACKAGE.ProbeThunks", bytes) + + // INT carries a Long and FLOAT carries a Double, per `boundaryTypeOf`; the thunk narrows + // each one on the way in and widens the `long` return back to what INT promises. + val t0 = cls.getMethod(thunkMethodName(0), Array::class.java) + assertEquals(7L + 8L + 2L + 1L, t0.invoke(null, arrayOf(7L, 8L, 2.5, true, "x"))) + val t1 = cls.getMethod(thunkMethodName(1), Array::class.java) + assertEquals(Unit, t1.invoke(null, arrayOf(1L))) + } + + /** + * A class whose static method is called `sum-abc123`. Emitted rather than compiled on purpose: + * a hyphen is Kotlin's value-class mangling suffix, is not a Java identifier, and the exact + * suffix `kotlinc` would pick is not something a test may assume -- so the name is *chosen* here + * and the point (that the thunk can call a name no source file can spell) is made directly. + * + * `sum-abc123(i, j, f, z, s)` returns `i + j + f.toLong() + (if (z) 1 else 0)`; `nothing(i)` + * returns `void`. + */ + private fun mangledProbeClass(internalName: String): ByteArray { + val writer = org.objectweb.asm.ClassWriter(org.objectweb.asm.ClassWriter.COMPUTE_MAXS) + writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC or Opcodes.ACC_FINAL, internalName, null, "java/lang/Object", null) + writer.visitMethod(Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC, "sum-abc123", "(IJFZLjava/lang/String;)J", null, null).apply { + visitCode() + visitVarInsn(Opcodes.ILOAD, 0); visitInsn(Opcodes.I2L) + visitVarInsn(Opcodes.LLOAD, 1); visitInsn(Opcodes.LADD) + visitVarInsn(Opcodes.FLOAD, 3); visitInsn(Opcodes.F2L); visitInsn(Opcodes.LADD) + visitVarInsn(Opcodes.ILOAD, 4); visitInsn(Opcodes.I2L); visitInsn(Opcodes.LADD) + visitInsn(Opcodes.LRETURN) + visitMaxs(0, 0); visitEnd() + } + writer.visitMethod(Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC, "nothing", "(I)V", null, null).apply { + visitCode(); visitInsn(Opcodes.RETURN); visitMaxs(0, 0); visitEnd() + } + writer.visitEnd() + return writer.toByteArray() + } + + /** + * The rule `ArtifactScanner.functionSlotTypeName` decides a `content` slot by, measured rather + * than assumed: **a function-typed parameter of a composable is either plain or lowered by + * exactly two.** + * + * A `@Composable` function *type* gains a `Composer` and **one** `$changed` when the Compose + * plugin lowers it -- one, not the `ceil(n/10)` a lowered composable *function* carries, which is + * what makes the two cases distinguishable at all. That relation is the whole basis on which + * `pythonx` decides whether to thread a composer through an invocation, so a declaration that + * contradicted it would produce a wrapper cast to an interface it does not implement, at the + * call site, with nothing said earlier. + * + * Deliberately not a fixed count: it prints how many slots it checked and fails on the first one + * whose two arities are related in neither way. + */ + @Test + fun everyFunctionTypedSlotOfAComposableIsEitherPlainOrLoweredByTwo() { + val jars = listOfNotNull( + jarUnder("org.jetbrains.compose.material3", "material3-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop"), + jarUnder("org.jetbrains.compose.material", "material-desktop"), + ) + if (jars.isEmpty()) return + var plain = 0 + var lowered = 0 + val wrong = mutableListOf() + forEachPublicComposable(jars, composeClasspath()) { owner, function, method -> + val (descriptors, _) = splitMethodDescriptor(method.desc) + function.allParameterTypes.forEachIndexed { index, type -> + val descriptor = descriptors.getOrNull(index) ?: return@forEachIndexed + val jvmArity = ArtifactScanner.jvmFunctionArityOf(descriptor) ?: return@forEachIndexed + val declared = kotlinClassifierNameOf(type) + val declaredArity = declared?.removePrefix("kotlin.Function")?.toIntOrNull() + when { + declaredArity == null -> + wrong += "$owner.${function.kotlinName} slot $index: declared=$declared jvm=Function$jvmArity" + jvmArity == declaredArity -> plain++ + jvmArity == declaredArity + 2 -> lowered++ + else -> + wrong += "$owner.${function.kotlinName} slot $index: declared=$declaredArity jvm=$jvmArity" + } + } + } + println("function-typed composable slots: $plain plain, $lowered lowered by two") + assertTrue(plain + lowered > 100, "expected the jars to declare function-typed slots; saw ${plain + lowered}") + assertEquals(emptyList(), wrong, "function-typed slots whose declared and compiled arities are unrelated") + } + + /** + * The second half of what a slot's name has to say, measured the same way: **what the lambda is + * invoked with and what it has to give back.** + * + * The arity relation above decides which `FunctionN` to implement and whether a composer is + * threaded. It cannot decide what to do with the arguments, and until it did, everything with an + * argument was refused -- which is every interactive control (`onValueChange`, + * `onCheckedChange`) and every scoped container (`ColumnScope`, `RowScope`, `BoxScope`). + * + * Three counts come out of this walk and each one is a decision: + * + * | count | what it decides | + * |---|---| + * | slots forwarding no argument | the case that already worked | + * | slots forwarding at least one | what [ArtifactScanner.functionSlotTypeName] now has to describe | + * | slots returning something other than `kotlin.Unit` | what has to keep being refused, because `TypeTag` cannot say which boxed numeric type the caller will cast to | + * + * Printed rather than pinned to a literal, because they are facts about a Compose version. + */ + @Test + fun functionTypedSlotsCarryTheirArgumentAndReturnTypes() { + val jars = listOfNotNull( + jarUnder("org.jetbrains.compose.material3", "material3-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop"), + jarUnder("org.jetbrains.compose.material", "material-desktop"), + ) + if (jars.isEmpty()) return + var described = 0 + var undescribed = 0 + var withArguments = 0 + var nonUnitReturn = 0 + val malformed = mutableListOf() + forEachPublicComposable(jars, composeClasspath()) { owner, function, method -> + val (descriptors, _) = splitMethodDescriptor(method.desc) + function.allParameterTypes.forEachIndexed { index, type -> + val descriptor = descriptors.getOrNull(index) ?: return@forEachIndexed + val jvmArity = ArtifactScanner.jvmFunctionArityOf(descriptor) ?: return@forEachIndexed + val declared = kotlinClassifierNameOf(type) ?: return@forEachIndexed + val reported = ArtifactScanner.functionSlotTypeName(type, declared, descriptor) + if (reported == null) { + undescribed++ + return@forEachIndexed + } + described++ + // The grammar, parsed the way `pythonx._function_slot` parses it, so a name this + // walker can emit and that reader cannot understand fails here rather than at a call. + val open = reported.indexOf('(') + val close = reported.indexOf(')', open) + val arrow = reported.indexOf(ArtifactScanner.FUNCTION_RETURNS, close) + if (open < 0 || close < 0 || arrow != close + 1) { + malformed += "$owner.${function.kotlinName} slot $index: $reported" + return@forEachIndexed + } + val head = reported.substring(0, open) + val composable = head.endsWith(ArtifactScanner.COMPOSABLE_TYPE_MARK) + val arity = head.removeSuffix(ArtifactScanner.COMPOSABLE_TYPE_MARK) + .removePrefix("kotlin.Function").toIntOrNull() + val args = reported.substring(open + 1, close).split(',').filter { it.isNotEmpty() } + val returns = reported.substring(arrow + ArtifactScanner.FUNCTION_RETURNS.length) + if (arity != jvmArity) { + malformed += "$owner.${function.kotlinName} slot $index: $reported is not Function$jvmArity" + } + // A lowered lambda's two appended slots are not declared arguments, so the forwarded + // count is the compiled arity less those two -- which is what the wrapper relies on + // to know how many of the values it is invoked with belong to Python. + val forwarded = jvmArity - if (composable) 2 else 0 + if (args.size != forwarded) { + malformed += "$owner.${function.kotlinName} slot $index: $reported forwards ${args.size} of $forwarded" + } + if (args.isNotEmpty()) withArguments++ + if (returns != ArtifactScanner.UNIT_TYPE_NAME) nonUnitReturn++ + } + } + println( + "function-typed slot signatures: $described described ($withArguments forward an argument, " + + "$nonUnitReturn return something other than Unit), $undescribed not describable", + ) + assertEquals(emptyList(), malformed, "slot names pythonx's grammar cannot read back") + assertTrue(described > 100, "expected the jars to declare function-typed slots; saw $described") + assertTrue(withArguments > 50, "expected scoped containers and value callbacks; saw $withArguments") + assertTrue(nonUnitReturn > 0, "expected some slot to return a value, so the refusal is not vacuous") + } + + /** + * The four rows that decide whether an interactive UI is expressible, named individually. + * + * `Column.content` is a scope receiver, `Button.onClick` takes nothing, and + * `Slider.onValueChange` / `Checkbox.onCheckedChange` are the value callbacks -- one `Float`, one + * `Boolean` -- that were refused outright before the argument types were carried. A rule that + * described the arity but not the arguments would still pass + * [everyFunctionTypedSlotOfAComposableIsEitherPlainOrLoweredByTwo] and fail here. + */ + @Test + fun aValueCallbackSlotNamesTheTypeItsArgumentArrivesAs() { + val material3 = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return + val entries = ArtifactScanner.scanJar(material3, listOf("androidx.compose.material3"), composeClasspath()) + + fun slotOf(declaration: String, parameter: String): String? = entries + .filter { it.name.substringAfterLast('.').substringBefore("__") == declaration } + .firstNotNullOfOrNull { entry -> + entry.paramNames.indexOf(parameter).takeIf { it >= 0 }?.let { entry.paramTypeNames[it] } + } + + assertEquals("kotlin.Function1(kotlin.Float)->kotlin.Unit", slotOf("Slider", "onValueChange")) + assertEquals("kotlin.Function1(kotlin.Boolean)->kotlin.Unit", slotOf("Checkbox", "onCheckedChange")) + // The one that is an *object* rather than a value, and reached through a real composable + // rather than a scope: `Text`'s layout callback is handed a `TextLayoutResult`. + assertEquals( + "kotlin.Function1(androidx.compose.ui.text.TextLayoutResult)->kotlin.Unit", + slotOf("Text", "onTextLayout"), + ) + } + + /** + * The two rows of that distinction that decide whether a container is usable, named individually. + * + * `Column.content` is the acceptance criterion's slot and it declares **no default**, so a + * `Column` whose `content` cannot be filled is not reachable at all -- unlike `Text`, which is + * reachable with everything but its string omitted. `Button.onClick` is the plain case in the + * same walk, so a rule that marked every function type as composable would fail here rather than + * only at the call site. + */ + @Test + fun theContentSlotIsMarkedComposableAndAnOnClickIsNot() { + val layout = jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop") ?: return + val material3 = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return + val classpath = composeClasspath() + + val column = ArtifactScanner.scanJar(layout, listOf("androidx.compose.foundation.layout"), classpath) + .single { it.name == "androidx.compose.foundation.layout.Column" } + assertEquals( + listOf("modifier", "verticalArrangement", "horizontalAlignment", "content"), + column.paramNames.take(4), + ) + assertEquals( + "kotlin.Function3@Composable(androidx.compose.foundation.layout.ColumnScope)->kotlin.Unit", + column.paramTypeNames[3], + "Column.content", + ) + assertEquals(false, column.paramHasDefault[3], "content declares no default, so it must be fillable") + + val button = ArtifactScanner.scanJar(material3, listOf("androidx.compose.material3"), classpath) + .single { it.name.substringAfterLast('.') == "Button" } + assertEquals("onClick", button.paramNames.first()) + assertEquals("kotlin.Function0()->kotlin.Unit", button.paramTypeNames[0], "Button.onClick is not lowered") + assertEquals( + "kotlin.Function3@Composable(androidx.compose.foundation.layout.RowScope)->kotlin.Unit", + button.paramTypeNames[button.paramNames.indexOf("content")], + "Button.content is", + ) + } + + /** + * **Which numbering a receiver shifts**, read out of the callee rather than guessed at. + * + * `composableCandidate` declined every extension composable for exactly one reason, quoted from + * its own KDoc: *"Compose's `$changed` slots count receivers and its `$default` bits are assigned + * over value parameters, so a receiver shifts one numbering and not the other. Nothing here has + * measured which."* This is that measurement, and it is a test rather than a `javap` transcript + * so that a Compose version that changed the encoding fails here instead of in a frame. + * + * ### How the bit is attributed to a parameter + * + * Not by pattern-matching a disassembly. Every composable that defaults anything opens a + * `Composer.startDefaults()` … `endDefaults()` region containing one branch per defaulted + * parameter, of the shape + * + * ILOAD $default ; push 1 << bit ; IAND ; IFEQ past + * …the default expression… + * xSTORE + * past: + * + * so reading the guard's constant and the local the branch assigns pairs a **bit** with a + * **parameter**, with no assumption about which is which. Stores to a local at or above the first + * synthetic parameter are skipped: the same region clears bits of the `$dirty` accumulator. + * + * The two rows are asserted against each other, because either alone is consistent with the + * wrong rule: `Text` has no receiver and its bit *i* is parameter *i*; `RowScope.NavigationBarItem` + * has one and its bit *i* is parameter *i* + 1, which is value parameter *i*. + */ + @Test + fun theDefaultBitOfAnExtensionComposableNumbersValueParametersAndNotSlots() { + val material3 = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return + + // No receiver: the case the existing arithmetic was measured on. + val text = defaultBitsOf(material3, "androidx/compose/material3/TextKt") { it.startsWith("Text") } + assertTrue(text.isNotEmpty(), "no defaulted parameter found in Text's prologue") + assertEquals( + text.keys.associateWith { it }, + text, + "a composable with no receiver must number its \$default bits by parameter index", + ) + + // A receiver, and it does **not** take bit 0: `selected` does, and `modifier` -- parameter 4 + // of the JVM method and value parameter 3 -- is guarded by `& 8`. + val item = defaultBitsOf(material3, "androidx/compose/material3/NavigationBarKt") { + it == "NavigationBarItem" + } + assertTrue(item.isNotEmpty(), "no defaulted parameter found in NavigationBarItem's prologue") + assertEquals( + item.keys.associateWith { it + 1 }, + item, + "an extension composable's \$default bit i must be its value parameter i, not its slot i", + ) + assertEquals(3, item.keys.min(), "expected NavigationBarItem's first default to be `modifier`") + assertTrue(31 !in item.keys, "bit 31 is the receiver's and nothing may assign a default over it") + } + + /** + * `$default` bit -> the index in `allParameterTypes` of the parameter that bit defaults, for one + * method of [ownerInternalName] whose name [nameMatches]. + */ + private fun defaultBitsOf(jar: File, ownerInternalName: String, nameMatches: (String) -> Boolean): Map { + // Read with the code kept, unlike [readClassNodeOrNull], which skips it: the walker never + // needs an instruction and this is the one measurement that is *about* them. + val node = java.util.jar.JarFile(jar).use { file -> + val entry = file.getEntry("$ownerInternalName.class") ?: return emptyMap() + file.getInputStream(entry).use { stream -> + org.objectweb.asm.tree.ClassNode().also { + org.objectweb.asm.ClassReader(stream.readBytes()).accept(it, org.objectweb.asm.ClassReader.SKIP_DEBUG) + } + } + } + val method = node.methods.firstOrNull { + nameMatches(it.name) && ComposableShape.COMPOSER_DESCRIPTOR in it.desc && it.desc.endsWith(")V") + } ?: return emptyMap() + + val (descriptors, _) = splitMethodDescriptor(method.desc) + // A static method's locals are its parameters, `long` and `double` taking two each. The map + // is inverted so a store instruction can name the parameter it wrote. + val parameterOfLocal = HashMap() + var local = 0 + descriptors.forEachIndexed { index, descriptor -> + parameterOfLocal[local] = index + local += if (descriptor == "J" || descriptor == "D") 2 else 1 + } + // Everything from the `Composer` on is the compiler's; a store into one of those is the + // `$dirty` accumulator rather than a defaulted parameter. + val firstSynthetic = descriptors.indexOfFirst { it == ComposableShape.COMPOSER_DESCRIPTOR } + val defaultLocal = parameterOfLocal.entries.first { it.value == descriptors.size - 1 }.key + + val bits = HashMap() + val instructions = method.instructions.toArray() + var i = 0 + while (i < instructions.size - 3) { + val load = instructions[i] as? org.objectweb.asm.tree.VarInsnNode + if (load == null || load.opcode != Opcodes.ILOAD || load.`var` != defaultLocal) { i++; continue } + val constant = constantOf(instructions[i + 1]) + if (constant == null || instructions[i + 2].opcode != Opcodes.IAND || + instructions[i + 3].opcode != Opcodes.IFEQ + ) { i++; continue } + val past = (instructions[i + 3] as org.objectweb.asm.tree.JumpInsnNode).label + var j = i + 4 + while (j < instructions.size && instructions[j] !== past) { + val store = instructions[j] as? org.objectweb.asm.tree.VarInsnNode + if (store != null && store.opcode in STORE_OPCODES) { + val parameter = parameterOfLocal[store.`var`] + if (parameter != null && parameter < firstSynthetic) { + bits[Integer.numberOfTrailingZeros(constant)] = parameter + } + } + j++ + } + i++ + } + return bits + } + + private fun constantOf(instruction: org.objectweb.asm.tree.AbstractInsnNode): Int? = when { + instruction.opcode in Opcodes.ICONST_0..Opcodes.ICONST_5 -> instruction.opcode - Opcodes.ICONST_0 + instruction is org.objectweb.asm.tree.IntInsnNode -> instruction.operand + instruction is org.objectweb.asm.tree.LdcInsnNode -> instruction.cst as? Int + else -> null + } + + private companion object { + val STORE_OPCODES = setOf(Opcodes.ISTORE, Opcodes.LSTORE, Opcodes.FSTORE, Opcodes.DSTORE, Opcodes.ASTORE) + } + + private fun forEachPublicComposable( + jars: List, + classpath: List, + action: (owner: String, function: ResolvedFunction, method: org.objectweb.asm.tree.MethodNode) -> Unit, + ) { + val artifactClasspath = ArtifactClasspath(classpath) + jars.forEach { jar -> + java.util.jar.JarFile(jar).use { file -> + file.entries().asSequence().filter { it.name.endsWith(".class") && '$' !in it.name }.forEach { entry -> + val node = file.getInputStream(entry).use { readClassNodeOrNull(it.readBytes()) } ?: return@forEach + if ((node.access and Opcodes.ACC_PUBLIC) == 0) return@forEach + val containers = when (val metadata = kotlinClassMetadataOf(node)) { + is kotlin.metadata.jvm.KotlinClassMetadata.FileFacade -> listOf(metadata.kmPackage to node) + is kotlin.metadata.jvm.KotlinClassMetadata.MultiFileClassFacade -> metadata.partClassNames.mapNotNull { part -> + val partNode = artifactClasspath.classNode(part) ?: return@mapNotNull null + val partMetadata = kotlinClassMetadataOf(partNode) as? kotlin.metadata.jvm.KotlinClassMetadata.MultiFileClassPart + ?: return@mapNotNull null + partMetadata.kmPackage to partNode + } + else -> return@forEach + } + containers.forEach { (kmPackage, ownerNode) -> + val bySignature = functionsOf(kmPackage).associateBy { it.jvmSignature.name to it.jvmSignature.descriptor } + ownerNode.methods.forEach { method -> + if ((method.access and Opcodes.ACC_PUBLIC) == 0) return@forEach + if ((method.access and Opcodes.ACC_STATIC) == 0) return@forEach + // `invisibleAnnotations`, deliberately: `@Composable` is + // `AnnotationRetention.BINARY`, so it is a `RuntimeInvisibleAnnotation` + // and the visible list is empty for every composable in the jar. Read + // out of `javap -v`, and the reason `ArtifactScanner.isComposable` used + // to answer `false` for all 500 of them. + val annotations = method.visibleAnnotations.orEmpty() + method.invisibleAnnotations.orEmpty() + if (annotations.none { it.desc == "Landroidx/compose/runtime/Composable;" }) return@forEach + // By name **and descriptor**: `Text--4IGK_g` names two different + // overloads in one class, so a name-only lookup would pair a composable + // with the wrong declaration and make the arithmetic check vacuous. + // `KmFunction.jvmSignature` records the *lowered* descriptor, synthetic + // parameters included, which is why this matches at all. + val function = bySignature[method.name to method.desc] ?: return@forEach + action(ownerNode.name.replace('/', '.'), function, method) + } + } + } + } + } + } + + /** + * Issue #53: an extension composable's declared parameters carry **their own** boundary tags. + * + * The composable path tags every JVM slot, receiver first, and the declaration model lists the + * value parameters without the receiver; pairing them by index shifted every tag by one, so + * `RowScope.NavigationBarItem`'s `colors: NavigationBarItemColors` read the tag of the + * `alwaysShowLabel: Boolean` before it and the stub annotated it `bool`. + */ + @Test + fun anExtensionComposablesParametersAreTaggedByTheirOwnType() { + val material3 = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return + val items = ArtifactScanner.scanDeclarations( + material3, + includePrefixes = listOf("androidx.compose.material3"), + classpath = composeClasspath(), + ).filter { it.simpleName == "NavigationBarItem" && it.bindingName != null } + assertTrue(items.isNotEmpty(), "NavigationBarItem is bound") + items.forEach { item -> + assertEquals("androidx.compose.foundation.layout.RowScope", item.receiver?.qualifiedName) + assertEquals("OBJECT", item.receiverBoundaryTag, "the receiver's own tag") + val byName = item.parameters.associateBy { it.name } + assertEquals("BOOLEAN", byName["selected"]?.boundaryTag) + byName["alwaysShowLabel"]?.let { assertEquals("BOOLEAN", it.boundaryTag) } + val colors = byName.getValue("colors") + assertEquals("androidx.compose.material3.NavigationBarItemColors", colors.type.qualifiedName) + assertEquals("OBJECT", colors.boundaryTag, "${item.bindingName}: colors is an object, not the Boolean before it") + } + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableValueClassSlotTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableValueClassSlotTest.kt new file mode 100644 index 000000000..04dc260d3 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableValueClassSlotTest.kt @@ -0,0 +1,419 @@ +package python.multiplatform.gradle.artifact + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * **The two paths that typed the same declaration differently**, and the measurement that closes the + * gap `ebe3365f` pinned. + * + * ### What the pin said + * + * `IconRenderTest.aComposablesColorSlotRefusesAPythonBuiltColorBecauseTheTwoPathsTypeItDifferently` + * recorded that `Icon`'s `tint` and `AlertDialog`'s `containerColor` were tagged `INT` while + * `lightColorScheme`'s 36 `Color` parameters -- same type, same jar -- were tagged `OBJECT`. Not a + * mistake in either: an ordinary declaration is typed by `resolveKotlinType` from `@Metadata`, which + * knows `Color` is a `@JvmInline value class`; a `@Composable`'s parameters were typed by + * [ArtifactScanner.composableSlotTagOf] from the **JVM descriptor**, where a `Color` is the letter + * `J` and indistinguishable from a `Long`. + * + * ### Why the composable path read the descriptor in the first place + * + * Because it has to. A composable's `$composer`/`$changed`/`$default` slots exist **only** in the + * descriptor -- metadata does not declare them -- so the JVM parameter list is longer than the Kotlin + * one and the descriptor is the only thing that can say what the trailing slots are. What did not + * follow, and was the actual defect, is that the *declared* slots have to be typed from it too. + * + * ### How the two lists are paired, and what stops a wrong pairing being silent + * + * The pairing is positional and is **not** an assumption: [ComposableShape.of] has already checked + * that JVM parameter [declaredCount] is the `Composer` and that everything after it is `int`, so JVM + * slots `0 until declaredCount` are the Kotlin parameters, in order. That is the same shape of + * argument [ArtifactScanner.functionSlotTypeName] makes for a function-typed slot: compare the two + * arities, and act only on a relation that holds. + * + * On top of the arity relation, every paired slot is **cross-checked**: the Kotlin type's own + * boundary (a primitive, a value class unwrapped to one, or an object handle) must be able to arrive + * in the JVM slot the descriptor declares. A `Modifier` against a `J`, or a `Color` against a + * `Ljava/lang/String;`, is refused rather than bound -- see + * [everyComposableSlotAgreesBetweenMetadataAndDescriptorWhenPairedInOrder], which measures that the + * check is satisfied by every composable three Compose jars declare, and + * [aDeliberatelyShiftedPairingIsCaughtRatherThanBoundToTheWrongType], which shifts the pairing by one + * and counts the refusals that follow. + */ +class ComposableValueClassSlotTest { + + private val caches: File? = listOf( + File(System.getProperty("user.home"), ".gradle/caches/modules-2/files-2.1"), + File("/Volumes/macMini/caches/.gradle/caches/modules-2/files-2.1"), + ).firstOrNull { it.isDirectory } + + private fun jarUnder(group: String, artifact: String): File? = caches?.resolve(group)?.resolve(artifact) + ?.walkTopDown()?.firstOrNull { it.isFile && it.name.endsWith(".jar") && "sources" !in it.name } + + private val kotlinStdlibJar: File + get() = File(Class.forName("kotlin.text.Regex").protectionDomain.codeSource.location.toURI()) + + private fun composeClasspath(): List = listOfNotNull( + jarUnder("org.jetbrains.compose.material3", "material3-desktop"), + jarUnder("org.jetbrains.compose.material", "material-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-unit-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-text-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-geometry-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-util-desktop"), + jarUnder("org.jetbrains.compose.runtime", "runtime-desktop"), + jarUnder("org.jetbrains.compose.runtime", "runtime-saveable-desktop"), + jarUnder("org.jetbrains.compose.animation", "animation-core-desktop"), + jarUnder("org.jetbrains.compose.collection-internal", "collection-desktop"), + jarUnder("org.jetbrains.compose.annotation-internal", "annotation-desktop"), + kotlinStdlibJar, + ) + + /** + * **The claim.** `Icon`'s `tint` is a `Color` and must be tagged the way `lightColorScheme`'s + * `Color` parameters already were -- `OBJECT`, carrying the same handle -- rather than `INT`, + * which is what its erased `long` looks like. + * + * Asserted alongside a slot of the *same declaration* that must not move: `contentDescription` + * is a `String?` and stays `STRING`, and `modifier` stays `OBJECT`. A change that simply tagged + * every declared slot `OBJECT` would pass the first assertion and fail those. + */ + @Test + fun aComposablesValueClassSlotIsTypedFromMetadataAndNotFromItsErasedDescriptor() { + val material3 = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return + val entries = ArtifactScanner.scanJar( + material3, + includePrefixes = listOf("androidx.compose.material3"), + classpath = composeClasspath(), + ) + val icons = entries.filter { it.name.substringAfterLast('.').substringBefore("__") == "Icon" } + assertTrue(icons.isNotEmpty(), "no Icon binding among ${entries.size} entries") + + // Not every `Icon` overload tints with a `Color`: 1.7.0 added one taking a + // `ColorProducer?`, which is an ordinary interface and was already an object handle. The + // claim is about the `Color`-typed ones, and that there are some. + var colorTints = 0 + icons.forEach { icon -> + val tint = icon.paramNames.indexOf("tint") + assertTrue(tint >= 0, "${icon.name} has no tint slot: ${icon.paramNames}") + if (icon.paramTypeNames[tint] == "androidx.compose.ui.graphics.Color") { + colorTints++ + assertEquals( + "OBJECT", icon.paramTags[tint], + "${icon.name}'s tint is still typed from the erased descriptor", + ) + } + val description = icon.paramNames.indexOf("contentDescription") + assertEquals("STRING", icon.paramTags[description], "a String? slot must not move") + val modifier = icon.paramNames.indexOf("modifier") + assertEquals("OBJECT", icon.paramTags[modifier], "a plain object slot must not move") + // The synthetic slots keep being read off the descriptor, because nothing else declares + // them: `$composer` is an object and the masks are ints. + assertEquals(listOf("OBJECT", "INT", "INT"), icon.paramTags.takeLast(3)) + } + assertTrue(colorTints > 0, "no Icon overload declares a Color tint, so this measures nothing") + } + + /** + * **A `Dp` slot must not move**, which is the other half of "typed from metadata": `Dp` is a + * value class too, and `resolveKotlinType` *can* open it (public constructor, public accessor), + * so it stays `FLOAT` and keeps arriving as a raw number the way `pythonx.dp(16)` sends one. + * + * A rule that had said "a value class is an object" would tag this `OBJECT` and break every + * `dp(...)` argument to a composable, silently, at the call site. + */ + @Test + fun aValueClassTheWalkerCanOpenStaysAPrimitiveSlot() { + val layout = jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop") ?: return + val entries = ArtifactScanner.scanJar( + layout, + includePrefixes = listOf("androidx.compose.foundation.layout"), + classpath = composeClasspath(), + ) + val spacers = entries.filter { it.name.substringAfterLast('.').substringBefore("__") == "Spacer" } + assertTrue(spacers.isNotEmpty(), "no Spacer binding among ${entries.size} entries") + + val dpSlots = entries.flatMap { entry -> + entry.paramTypeNames.indices + .filter { entry.paramTypeNames[it] == "androidx.compose.ui.unit.Dp" } + .map { entry.name to entry.paramTags[it] } + } + assertTrue(dpSlots.isNotEmpty(), "expected some composable to take a Dp") + assertEquals( + emptyList(), dpSlots.filter { it.second != "FLOAT" }, + "a Dp slot must stay FLOAT: its constructor and its accessor are both public", + ) + } + + /** + * The thunk is the other end of the same change, and without it the tag would be a lie: the JVM + * method takes a `long`, and what the boundary now hands over for a `Color` is a **boxed** + * `kotlin.ULong` -- the carrier `resolveKotlinType`'s descent stopped at, because `ULong`'s own + * constructor is `internal`. + * + * So the thunk has to unwrap it, and the name it calls is not guessed: `unbox-impl` is read off + * the carrier's own class file, together with the descriptor it returns, and the slot is only + * upgraded when that method exists and returns exactly what the JVM slot wants. + */ + @Test + fun theThunkUnwrapsTheValueClassBoxTheBoundaryCarries() { + val material3 = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return + val entries = ArtifactScanner.scanJar( + material3, + includePrefixes = listOf("androidx.compose.material3"), + classpath = composeClasspath(), + ) + val icon = entries.first { it.name.substringAfterLast('.').substringBefore("__") == "Icon" } + val thunk = assertNotNull(icon.thunk) + val tint = icon.paramNames.indexOf("tint") + assertEquals( + "kotlin/ULong", thunk.valueClassUnboxOwners.getOrNull(tint), + "the tint slot arrives as a boxed ULong and the thunk must unbox it", + ) + assertEquals( + null, thunk.valueClassUnboxOwners.getOrNull(icon.paramNames.indexOf("modifier")), + "a plain object slot needs no unwrapping", + ) + assertEquals(icon.arity, thunk.valueClassUnboxOwners.size, "one entry per JVM slot, or none") + } + + /** + * The pairing, measured over every public top-level `@Composable` three Compose jars declare: + * paired **in order**, every declared Kotlin type's boundary can arrive in the JVM slot at the + * same index. + * + * Prints the counts rather than pinning them, because they are facts about a Compose version; + * fails on the first slot the two views disagree about. + */ + @Test + fun everyComposableSlotAgreesBetweenMetadataAndDescriptorWhenPairedInOrder() { + val jars = composeJars() + if (jars.isEmpty()) return + val counts = agreementCounts(jars, shift = 0) + println( + "composable slots paired in order: ${counts.agreed} agree " + + "(${counts.upgraded} upgraded to an object handle), ${counts.disagreed} disagree", + ) + assertTrue(counts.agreed > 500, "expected the jars to declare composable slots; saw ${counts.agreed}") + assertTrue(counts.upgraded > 0, "no slot was upgraded, so this measures nothing") + assertEquals(0, counts.disagreed, "a composable slot the two views type incompatibly") + } + + /** + * The tamper: the same walk with the JVM descriptors shifted by one against the Kotlin types. + * If the cross-check were vacuous this would produce the same zero. + */ + @Test + fun aDeliberatelyShiftedPairingIsCaughtRatherThanBoundToTheWrongType() { + val jars = composeJars() + if (jars.isEmpty()) return + val shifted = agreementCounts(jars, shift = 1) + println("composable slots paired one off: ${shifted.agreed} agree, ${shifted.disagreed} disagree") + assertTrue( + shifted.disagreed > 100, + "a one-off pairing was accepted, only ${shifted.disagreed} refusals -- the cross-check is vacuous", + ) + } + + /** + * The emitted unwrapper really links and really runs, against a hand-built class whose + * `unbox-impl` is spelled exactly the way `kotlinc` spells one -- with a hyphen, which is not a + * Java or Kotlin identifier character, so nothing but bytecode could call it. + * + * Both halves are exercised, and the second is the one that would otherwise be found at run time + * inside a composition: **`null` is an omitted argument**, not an error. A `@Composable`'s + * omitted slot is still a real JVM parameter and `pythonx._absent` can only put `None` in an + * `OBJECT` one; the callee overwrites it from the `$default` mask before reading it. + */ + @Test + fun theGeneratedUnwrapperOpensABoxAndTurnsAnOmittedSlotIntoAZero() { + val boxInternal = "fixture/thunk/ValueBoxProbe" + val targetInternal = "fixture/thunk/ValueBoxTarget" + val bytes = generateThunkClass( + "Boxed", + listOf( + ThunkSpec( + targetInternal, + "take-abc123", + "(JLjava/lang/String;)J", + valueClassUnboxOwners = listOf(boxInternal, null), + ), + ), + ) + val loader = object : ClassLoader(javaClass.classLoader) { + fun define(name: String, data: ByteArray): Class<*> = defineClass(name, data, 0, data.size) + } + loader.define(boxInternal.replace('/', '.'), valueBoxProbeClass(boxInternal)) + loader.define(targetInternal.replace('/', '.'), valueBoxTargetClass(targetInternal)) + val thunks = loader.define("$THUNK_PACKAGE.BoxedThunks", bytes) + + val boxClass = loader.loadClass(boxInternal.replace('/', '.')) + val box = boxClass.getMethod("box-impl", Long::class.javaPrimitiveType).invoke(null, 42L) + val t0 = thunks.getMethod(thunkMethodName(0), Array::class.java) + assertEquals(42L, t0.invoke(null, arrayOf(box, "x"))) + assertEquals(0L, t0.invoke(null, arrayOf(null, "x"))) + } + + /** A stand-in for a `@JvmInline value class` box: one `long` field, a static `box-impl` and the + * `unbox-impl` `kotlinc` emits. Written as bytecode because the name has a hyphen in it. */ + private fun valueBoxProbeClass(internalName: String): ByteArray { + val writer = org.objectweb.asm.ClassWriter(org.objectweb.asm.ClassWriter.COMPUTE_MAXS) + writer.visit( + org.objectweb.asm.Opcodes.V1_8, + org.objectweb.asm.Opcodes.ACC_PUBLIC or org.objectweb.asm.Opcodes.ACC_FINAL, + internalName, + null, + "java/lang/Object", + null, + ) + writer.visitField(org.objectweb.asm.Opcodes.ACC_PRIVATE or org.objectweb.asm.Opcodes.ACC_FINAL, "value", "J", null, null).visitEnd() + writer.visitMethod(org.objectweb.asm.Opcodes.ACC_PRIVATE, "", "(J)V", null, null).apply { + visitCode() + visitVarInsn(org.objectweb.asm.Opcodes.ALOAD, 0) + visitMethodInsn(org.objectweb.asm.Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false) + visitVarInsn(org.objectweb.asm.Opcodes.ALOAD, 0) + visitVarInsn(org.objectweb.asm.Opcodes.LLOAD, 1) + visitFieldInsn(org.objectweb.asm.Opcodes.PUTFIELD, internalName, "value", "J") + visitInsn(org.objectweb.asm.Opcodes.RETURN) + visitMaxs(0, 0); visitEnd() + } + writer.visitMethod( + org.objectweb.asm.Opcodes.ACC_PUBLIC or org.objectweb.asm.Opcodes.ACC_STATIC, + "box-impl", "(J)L$internalName;", null, null, + ).apply { + visitCode() + visitTypeInsn(org.objectweb.asm.Opcodes.NEW, internalName) + visitInsn(org.objectweb.asm.Opcodes.DUP) + visitVarInsn(org.objectweb.asm.Opcodes.LLOAD, 0) + visitMethodInsn(org.objectweb.asm.Opcodes.INVOKESPECIAL, internalName, "", "(J)V", false) + visitInsn(org.objectweb.asm.Opcodes.ARETURN) + visitMaxs(0, 0); visitEnd() + } + writer.visitMethod( + org.objectweb.asm.Opcodes.ACC_PUBLIC or org.objectweb.asm.Opcodes.ACC_FINAL, + VALUE_CLASS_UNBOX_METHOD, "()J", null, null, + ).apply { + visitCode() + visitVarInsn(org.objectweb.asm.Opcodes.ALOAD, 0) + visitFieldInsn(org.objectweb.asm.Opcodes.GETFIELD, internalName, "value", "J") + visitInsn(org.objectweb.asm.Opcodes.LRETURN) + visitMaxs(0, 0); visitEnd() + } + writer.visitEnd() + return writer.toByteArray() + } + + /** `take-abc123(long, String): long` -- returns the long, so the thunk's answer is the unwrapped + * value and nothing else. */ + private fun valueBoxTargetClass(internalName: String): ByteArray { + val writer = org.objectweb.asm.ClassWriter(org.objectweb.asm.ClassWriter.COMPUTE_MAXS) + writer.visit( + org.objectweb.asm.Opcodes.V1_8, + org.objectweb.asm.Opcodes.ACC_PUBLIC or org.objectweb.asm.Opcodes.ACC_FINAL, + internalName, null, "java/lang/Object", null, + ) + writer.visitMethod( + org.objectweb.asm.Opcodes.ACC_PUBLIC or org.objectweb.asm.Opcodes.ACC_STATIC, + "take-abc123", "(JLjava/lang/String;)J", null, null, + ).apply { + visitCode() + visitVarInsn(org.objectweb.asm.Opcodes.LLOAD, 0) + visitInsn(org.objectweb.asm.Opcodes.LRETURN) + visitMaxs(0, 0); visitEnd() + } + writer.visitEnd() + return writer.toByteArray() + } + + private fun composeJars(): List = listOfNotNull( + jarUnder("org.jetbrains.compose.material3", "material3-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop"), + jarUnder("org.jetbrains.compose.material", "material-desktop"), + ) + + private class Counts(var agreed: Int = 0, var upgraded: Int = 0, var disagreed: Int = 0) + + /** + * @param shift how far to slide the JVM descriptor list against the Kotlin parameter list. `0` is + * the pairing [ArtifactScanner.composableDeclaredSlot] really uses; anything else is a wrong + * pairing, and the point of being able to ask for one. + */ + private fun agreementCounts(jars: List, shift: Int): Counts { + val classpath = ArtifactClasspath(composeClasspath()) + val counts = Counts() + forEachPublicComposable(jars, classpath) { function, method -> + val (descriptors, _) = splitMethodDescriptor(method.desc) + val shape = ComposableShape.of(function.allParameterTypes.size, descriptors) ?: return@forEachPublicComposable + if (shape.totalCount != descriptors.size) return@forEachPublicComposable + function.allParameterTypes.forEachIndexed { index, type -> + val descriptor = descriptors.getOrNull(index + shift) ?: return@forEachIndexed + // `char` has no boundary type on either view, so the scanner declines the whole + // declaration by design (`composableSlotTagOf`) -- a decline, not a disagreement. + // Compose 1.6 declared no composable with a `Char` slot; material/material3 under + // CMP 1.11 do (`SecureTextField`'s `obfuscationCharacter`), so it is skipped here + // rather than counted as a mis-pairing. + if (descriptor == "C") return@forEachIndexed + when (val slot = ArtifactScanner.composableDeclaredSlot(type, descriptor, classpath)) { + null -> counts.disagreed++ + else -> { + counts.agreed++ + if (slot.upgraded) counts.upgraded++ + } + } + } + } + return counts + } + + /** Every public top-level `@Composable` in [jars], paired with its own `MethodNode`. A local + * copy of `ComposableBindingTest`'s walk, deliberately: this file measures the *pairing* and + * must not be able to inherit an assumption about it from the file it is checking. */ + private fun forEachPublicComposable( + jars: List, + classpath: ArtifactClasspath, + action: (function: ResolvedFunction, method: org.objectweb.asm.tree.MethodNode) -> Unit, + ) { + val public = org.objectweb.asm.Opcodes.ACC_PUBLIC + val static = org.objectweb.asm.Opcodes.ACC_STATIC + jars.forEach { jar -> + java.util.jar.JarFile(jar).use { file -> + file.entries().asSequence().filter { it.name.endsWith(".class") && '$' !in it.name }.forEach { entry -> + val node = file.getInputStream(entry).use { readClassNodeOrNull(it.readBytes()) } ?: return@forEach + if ((node.access and public) == 0) return@forEach + val containers = when (val metadata = kotlinClassMetadataOf(node)) { + is kotlin.metadata.jvm.KotlinClassMetadata.FileFacade -> listOf(metadata.kmPackage to node) + is kotlin.metadata.jvm.KotlinClassMetadata.MultiFileClassFacade -> + metadata.partClassNames.mapNotNull { part -> + val partNode = classpath.classNode(part) ?: return@mapNotNull null + val partMetadata = kotlinClassMetadataOf(partNode) + as? kotlin.metadata.jvm.KotlinClassMetadata.MultiFileClassPart + ?: return@mapNotNull null + partMetadata.kmPackage to partNode + } + else -> return@forEach + } + containers.forEach { (kmPackage, ownerNode) -> + val bySignature = functionsOf(kmPackage) + .associateBy { it.jvmSignature.name to it.jvmSignature.descriptor } + ownerNode.methods.forEach { method -> + if ((method.access and public) == 0 || (method.access and static) == 0) return@forEach + val annotations = method.visibleAnnotations.orEmpty() + method.invisibleAnnotations.orEmpty() + if (annotations.none { it.desc == ComposableShape.COMPOSABLE_ANNOTATION_DESCRIPTOR }) { + return@forEach + } + val function = bySignature[method.name to method.desc] ?: return@forEach + action(function, method) + } + } + } + } + } + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/DeclarationModelTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/DeclarationModelTest.kt new file mode 100644 index 000000000..893f9b5de --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/DeclarationModelTest.kt @@ -0,0 +1,177 @@ +package python.multiplatform.gradle.artifact + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `docs/design/pyi-generation-design.md` §2.2's model, taken out of the same walk that produces the + * bindings. + * + * ### Why this is a third representation and not a widened `ArtifactCallable` + * + * §2.2 rejects widening in place: `ArtifactCallable` is consumed by `renderArtifactFragmentSource`, + * where every field is load-bearing, and a stub needs a strict superset of what a *call site* needs. + * §2.1 lists what both existing models read and then drop -- parameter names, defaults, nullability, + * the declared Kotlin type, the extension-receiver flag, and the overload grouping. + * + * `958c0082` had already put `paramNames`, `paramTypeNames`, `returnTypeName`, `isExtension`, + * `receiverTypeName` and `paramHasDefault` on `ExposedCallable` for the *dispatcher*, so some of that + * loss is already repaired -- but as flat `List`, with no nullability, no type arguments and + * no value-class identity, and only for declarations that were bound. Those three are exactly what a + * stub cannot do without. + * + * ### One walk, not two + * + * §2.2 also rejects "a second scan, independent of the binder's" -- two readers of the same jars that + * can disagree. So the model is built at the same point the binding is + * (`ArtifactScanner.buildCallableFromFunction`), from the same `ResolvedFunction`, and + * [ArtifactScanner.scanDeclarations] is the same walk with the other half of its result kept. + */ +class DeclarationModelTest { + + private val junitJar: File + get() { + val path = System.getProperty("python.multiplatform.walkerFixtureJar") + assertNotNull(path, "the walkerFixtureJar system property is not set; see build.gradle.kts") + return File(path) + } + + private val kotlinStdlibJar: File + get() = File(Class.forName("kotlin.text.Regex").protectionDomain.codeSource.location.toURI()) + + private val fixtureClasses: File + get() = File(Class.forName("fixture.artifactvalueclass.Meters").protectionDomain.codeSource.location.toURI()) + + private fun fixtureDeclarations() = + ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")) + + /** Parameter names are what `FragmentScanner` and `ArtifactScanner` both read and drop + * (§2.1). They are the difference between `def padding(all: Dp)` and `def padding(a0: float)`. */ + @Test + fun aKotlinDeclarationKeepsItsParameterNames() { + val sumMeters = fixtureDeclarations().single { it.simpleName == "sumMeters" } + assertEquals(listOf("a", "b"), sumMeters.parameters.map { it.name }) + assertTrue(sumMeters.parameterNamesKnown) + assertEquals("fixture.artifactvalueclass", sumMeters.owner) + assertNull(sumMeters.receiver) + assertEquals("fixture.artifactvalueclass.sumMeters", sumMeters.bindingName) + } + + /** The declared Kotlin type, not the tag it marshals as: `Meters` is `TypeTag.FLOAT` and the + * two are not the same fact. The value class's own identity -- underlying type and the two + * visibility booleans `ValueClassInfo` already computes -- comes with it, which is what §3.4's + * allowlist decision needs to be *expressible*. */ + @Test + fun aValueClassParameterCarriesItsIdentityAndNotOnlyItsTag() { + val sumMeters = fixtureDeclarations().single { it.simpleName == "sumMeters" } + val first = sumMeters.parameters.first() + assertEquals("fixture.artifactvalueclass.Meters", first.type.qualifiedName) + assertEquals("FLOAT", first.boundaryTag, "the tag is still there -- the point is that it is not the only thing") + val valueClass = assertNotNull(first.type.valueClass) + assertEquals("kotlin.Double", valueClass.underlying.qualifiedName) + assertTrue(valueClass.constructorIsPublic) + assertTrue(valueClass.propertyIsPublic) + } + + /** An extension receiver is `KmFunction.receiverParameterType` and is spent on choosing an + * alias one line later (§2.1). Here it stays a receiver, which is what §4.1 needs to decide the + * declaration is a *method on `Modifier`* rather than a module-level function. */ + @Test + fun anExtensionReceiverStaysAReceiverAndIsNotAParameter() { + val trimIndent = ArtifactScanner + .scanDeclarations(kotlinStdlibJar, includePrefixes = listOf("kotlin.text")) + .single { it.simpleName == "trimIndent" && it.parameters.isEmpty() } + assertEquals("kotlin.String", trimIndent.receiver?.qualifiedName) + assertEquals("kotlin.text", trimIndent.owner) + assertEquals("kotlin.String", trimIndent.returnType.qualifiedName) + } + + /** §3.2: a Java class file carries parameter names only when it was compiled with `-parameters`, + * and JUnit 4 was not. The model says so rather than inventing `arg0`, because a wrong keyword + * name type-checks at the call site and fails at run time. */ + @Test + fun aJavaDeclarationReportsThatItHasNoParameterNames() { + val setPreference = ArtifactScanner + .scanDeclarations(junitJar, includePrefixes = listOf("junit.runner.BaseTestRunner")) + .single { it.simpleName == "setPreference" } + assertTrue(!setPreference.parameterNamesKnown) + assertEquals(listOf(null, null), setPreference.parameters.map { it.name }) + assertEquals("junit.runner.BaseTestRunner", setPreference.owner) + assertTrue(setPreference.ownerIsClass) + } + + /** + * §2.2 property 2: the binder drops an ambiguous name because it cannot dispatch, and a stub can + * state all of them. Today those are the same line of code; here the model keeps every member of + * the overload set, each carrying the table key the binder actually gave it. + */ + @Test + fun everyMemberOfAnOverloadSetSurvivesWithItsOwnTableKey() { + val addMeters = fixtureDeclarations().filter { it.simpleName == "addMeters" } + assertEquals(2, addMeters.size, addMeters.map { it.bindingName }.toString()) + assertEquals( + setOf( + "fixture.artifactvalueclass.addMeters__Int_Int", + "fixture.artifactvalueclass.addMeters__Meters_Meters", + ), + addMeters.mapNotNull { it.bindingName }.toSet(), + ) + } + + /** + * §2.2 property 3: "a model entry can be marked *not bound, reason X* and still be stubbed -- or + * deliberately not stubbed... Today a declined declaration returns `null` and vanishes." + * + * `withSuspendCallback` is the fixture for the function-typed shape that is **still** declined. + * `withCallback` used to be -- the shape 44 of the 46 unbound `Modifier` extensions had -- and it + * binds now (`FunctionSlotBindingTest`), so the two are asserted together: what moved and what + * did not. + */ + @Test + fun aDeclinedDeclarationStaysVisibleWithItsReason() { + val declined = fixtureDeclarations().single { it.simpleName == "withSuspendCallback" } + assertNull(declined.bindingName, "the binder declined it, and that is the fact being kept") + val reason = assertNotNull(declined.declineReason) + assertTrue("suspend" in reason, reason) + assertEquals("kotlin.Function1", declined.parameters.single().type.qualifiedName) + + // The half that moved. A plain `() -> Unit` binds now, and the *model* still spells its + // parameter with the declared classifier: the signature grammar is the binding's answer + // (`ArtifactCallable.paramTypeNames`), not the stub's. + val bound = fixtureDeclarations().single { it.simpleName == "withCallback" } + assertEquals("fixture.artifactvalueclass.withCallback", bound.bindingName) + assertNull(bound.declineReason) + assertEquals("kotlin.Function0", bound.parameters.single().type.qualifiedName) + } + + /** `suspend` is declined by both producers and §3.1 says it must not be stubbed. It is still in + * the model, flagged, so that "not stubbed" is a decision the renderer makes rather than an + * absence nobody can account for. */ + @Test + fun aSuspendDeclarationIsFlaggedRatherThanSilentlyAbsent() { + val neverBound = fixtureDeclarations().singleOrNull { it.simpleName == "neverBound" } + assertNotNull(neverBound, "suspend declarations must reach the model") + assertTrue(neverBound.isSuspend) + assertNull(neverBound.bindingName) + } + + /** `internal` is not a name anybody outside the module may say, so it is not in the model at + * all -- unlike a declined *public* declaration, there is nothing a consumer could be told. */ + @Test + fun anInternalDeclarationIsNotInTheModelAtAll() { + assertTrue(fixtureDeclarations().none { it.simpleName == "secretlyInternal" }) + } + + /** The two halves of one walk cannot disagree, because they are produced together: every table + * key the binder emitted is a `bindingName` in the model, and nothing else is. */ + @Test + fun theModelAndTheBindingsAgreeBecauseTheyComeFromTheSameWalk() { + val bound = ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactvalueclass")).map { it.name } + val modelled = fixtureDeclarations().mapNotNull { it.bindingName } + assertEquals(bound.sorted(), modelled.sorted()) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/DefaultOmissionTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/DefaultOmissionTest.kt new file mode 100644 index 000000000..514c92ec3 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/DefaultOmissionTest.kt @@ -0,0 +1,280 @@ +package python.multiplatform.gradle.artifact + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * `docs/archive/pythonx-adapter-design.md` §4.5 closed for everything the walker actually binds: a Kotlin + * default value is reached by **not writing the argument**, in generated Kotlin *source*. + * + * ### What the problem was + * + * §4.5 measures it: `material3.Text` declares 16--18 parameters of which **one** is required, + * `Button` 10 of which two are, and across the `Modifier` surface **254 of 435 parameters (58%) + * declare a default**. Every generated body passed every argument, so a Python caller had to supply + * all of them -- and the values it would have had to supply (`TextStyle.Default`, `Alignment.Center`) + * are objects the type gate does not bind, so it could not have supplied them at all. "Pass + * everything" is not merely inconvenient; for most of the bound surface it is impossible. + * + * ### Why the answer is in the generated Kotlin and not in the table + * + * §4.5 lists four candidates. Three are closed: + * + * | candidate | what closes it | + * |---|---| + * | one entry per **arity prefix** | cannot express "skip the middle one", which is how Compose is written -- and it multiplies table keys, each of which `PythonProxySource` publishes by leaf name | + * | the compiler's **`$default` synthetic** | `ACC_SYNTHETIC`, so Kotlin source cannot name it; and `javap -p` over `ButtonKt` shows no `Button$default` exists at all | + * | a **generated wrapper restating the defaults** | metadata carries the *flag*, never the value | + * + * The fourth -- **presence branching in the generated lambda** -- is the one that survives, and its + * stated objection ("2^15 branches for `Text`") is an objection to an *unbounded* version of it. + * `Text` is a `@Composable` and no composable is in the table at all (the arity check in + * [ArtifactScanner] declines every one of them for its synthetic `$composer`/`$changed` + * parameters). Measured over everything the walker does bind today -- all of + * `foundation-layout-desktop-1.6.11`, `kotlin-stdlib`'s `kotlin.text` and JUnit 4 -- + * **the widest declaration declares four defaults**, so the exponent is 4 and the branch count is + * 16. [MAX_OMITTABLE_PARAMETERS] is what keeps it that way for an artefact nobody has measured yet. + * + * ### The sentinel, and why one exists at all + * + * A branch tests `args[i] == null`. That works because the boundary already carries `null` for every + * tag: `UpcallTrampoline.toKotlin` opens with *"`None` is how Python spells a null argument, whatever + * the tag says it should have been"*, before it ever looks at the tag. So no new marshalling + * category, no change to `ExposedCallable`, and no change to any of the five bootstraps. + * + * It also cannot collide with a real value in the primitive cases, which is the thing worth pinning: + * `resolveKotlinType` **declines a nullable primitive and a nullable value class outright** (a `null` + * arriving for an `Int?` would throw inside `(args[0] as Long)`), so a `FLOAT`/`INT`/`BOOLEAN` slot + * can never legitimately be `null`. The one case where the sentinel is not free is a **nullable + * object** parameter that declares a default: `None` there means "omitted" and can no longer mean + * "null". That is stated in [ArtifactScanner]'s KDoc rather than worked around. + */ +class DefaultOmissionTest { + + /** `DefaultFixtures.kt`, compiled into this module's own test classes and then met as bytecode. + * The same lookup [ArtifactScannerTest] uses for its value-class fixture. */ + private val fixtureClasses: File + get() { + val location = Class.forName("fixture.artifactdefaults.Bag").protectionDomain.codeSource.location + return File(location.toURI()).also { assertTrue(it.isDirectory, "expected a directory of .class files: $it") } + } + + private fun walk(): List = + ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactdefaults")) + + private fun entry(name: String): ArtifactCallable = + walk().single { it.name == "fixture.artifactdefaults.$name" } + + /** One space, no line breaks: the assertions are about the *calls* generated, not about how the + * renderer lays them out. */ + private fun String.flattened(): String = replace(Regex("\\s+"), " ").trim() + + /** + * The judgement, at the level the generator can be asked about it: a body that can be called + * with the required argument alone. + * + * `greet(name, punctuation = "!")` produces two call expressions -- one passing both, one + * passing only `name` -- and the second is the one a Python `greet("hi")` reaches. Note the + * *named* argument: an omission that is not a trailing one could not be positional, so every + * partial branch names what it passes and only the all-present branch stays positional. + */ + @Test + fun aDefaultedParameterGetsACallThatDoesNotPassIt() { + val body = entry("greet").lambdaBody.flattened() + assertTrue( + body.contains("fixture.artifactdefaults.greet((args[0] as String), (args[1] as String))"), + "the all-present call must survive unchanged: $body", + ) + assertTrue( + body.contains("fixture.artifactdefaults.greet(name = (args[0] as String))"), + "there must be a call that omits the defaulted parameter: $body", + ) + assertTrue(body.contains("args[1] == null"), "the branch must key off the null sentinel: $body") + } + + /** + * The property an arity-prefix scheme cannot have (§4.5, first row): the *middle* parameter is + * omitted while a later one is passed. + * + * `label(bag, prefix = , suffix = )` has two defaulted parameters, so there are four subsets and + * therefore four call expressions -- including `label(bag = , suffix = )`, which skips `prefix`. + */ + @Test + fun everySubsetOfTheDefaultedParametersGetsItsOwnCall() { + val body = entry("label").lambdaBody.flattened() + val bag = "(args[0] as fixture.artifactdefaults.Bag)" + val prefix = "(args[1] as String)" + val suffix = "(args[2] as String)" + assertTrue(body.contains("fixture.artifactdefaults.label($bag, $prefix, $suffix)"), body) + assertTrue(body.contains("fixture.artifactdefaults.label(bag = $bag, suffix = $suffix)"), body) + assertTrue(body.contains("fixture.artifactdefaults.label(bag = $bag, prefix = $prefix)"), body) + assertTrue(body.contains("fixture.artifactdefaults.label(bag = $bag)"), body) + assertEquals( + 4, + Regex("fixture\\.artifactdefaults\\.label\\(").findAll(body).count(), + "two defaulted parameters means exactly four call expressions: $body", + ) + } + + /** + * An extension, which is where Compose lives: the receiver is never omittable (it is positional + * in Kotlin and `` is deliberately not a Python identifier), and the call still goes + * through the `import ... as ...` alias because Kotlin has no fully-qualified extension call. + */ + @Test + fun anExtensionOmitsItsValueParametersAndNeverItsReceiver() { + val padded = entry("padded") + val body = padded.lambdaBody.flattened() + val alias = "artifact_ext_fixture_artifactdefaults_padded" + assertTrue(body.contains("(args[0] as fixture.artifactdefaults.Bag).$alias()"), body) + assertTrue(body.contains(".$alias(horizontal = (args[1] as Double))"), body) + assertTrue(body.contains(".$alias(vertical = (args[2] as Double))"), body) + assertEquals(listOf(false, true, true), padded.paramHasDefault) + } + + /** + * `paramHasDefault` stops being carried-and-unused and becomes the *contract*: it marks exactly + * the slots this body will accept `null` in. + * + * That is a slightly narrower claim than "the Kotlin declaration declares a default", and the + * narrower one is the honest one -- it is what `pythonx._bind` reads to decide whether a missing + * argument may be filled with the sentinel, and [aDeclarationWiderThanTheCapKeepsEveryParameterRequired] + * is the case where the two differ. + */ + @Test + fun paramHasDefaultMarksExactlyTheSlotsTheBodyCanOmit() { + assertEquals(listOf(false, true), entry("greet").paramHasDefault) + assertEquals(listOf(false, true, true), entry("label").paramHasDefault) + assertEquals(listOf(false, false), entry("plain").paramHasDefault) + } + + /** A declaration with no default at all must generate exactly what it generated before this + * existed -- one call, no `when`, no sentinel test. 43 of `foundation-layout`'s 70 entries are + * in this class and none of them should change. */ + @Test + fun aDeclarationWithNoDefaultsIsUntouched() { + val body = entry("plain").lambdaBody.flattened() + assertEquals( + "{ args -> (fixture.artifactdefaults.plain((args[0] as Long).toInt(), (args[1] as Long).toInt())).toLong() }", + body, + ) + } + + /** + * The cap firing, which is the only place the binding is deliberately narrower than the + * declaration. + * + * Seven defaulted parameters would be 128 generated call expressions. Rather than pay that, the + * walker keeps the pre-existing all-present body **and says so in the table**: `paramHasDefault` + * is all `false`, so `pythonx` requires every argument and the `.pyi` writes no `= ...`. The + * declaration is still bound and still callable; what it loses is omission. + */ + /** + * The stub's side of the same rule: the declaration model says `declaresDefault` for exactly the + * slots the **binding** lets Python leave out, so a stub never promises an omission the runtime + * refuses. Before this was pinned the model kept Kotlin's own view for a declaration the cap (or a + * name the walker cannot write) left all-required, and `combinedClickable`'s stub offered nine + * defaults that `inspect.signature` at run time requires (`StubSignatureAgreesWithRuntimeTest`). + */ + @Test + fun theDeclarationModelMarksADefaultOnlyWhereTheBindingAllowsOmission() { + val declarations = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactdefaults")) + fun defaults(name: String) = declarations.single { it.bindingName == "fixture.artifactdefaults.$name" }.parameters.map { it.declaresDefault } + assertEquals(List(7) { false }, defaults("wide")) + assertEquals(List(6) { true }, defaults("sixWide")) + assertEquals(listOf(false, true), defaults("greet")) + // And for every bound declaration, receiver slot included, the two views are one view. + val bound = walk().associateBy { it.name } + declarations.filter { it.bindingName != null }.forEach { declaration -> + val callable = bound.getValue(declaration.bindingName!!) + val offset = if (callable.receiverTypeName != null) 1 else 0 + assertEquals( + declaration.parameters.indices.map { index -> callable.paramHasDefault.getOrElse(index + offset) { false } }, + declaration.parameters.map { it.declaresDefault }, + declaration.bindingName, + ) + } + } + + @Test + fun aDeclarationWiderThanTheCapKeepsEveryParameterRequired() { + val wide = entry("wide") + assertEquals(List(7) { false }, wide.paramHasDefault) + assertFalse("== null" in wide.lambdaBody, "no sentinel branching past the cap: ${wide.lambdaBody}") + // And the parameter that is one narrower does branch, so the boundary is pinned from both + // sides rather than only from the far one. + val six = entry("sixWide") + assertEquals(List(6) { true }, six.paramHasDefault) + assertEquals( + 64, + Regex("fixture\\.artifactdefaults\\.sixWide\\(").findAll(six.lambdaBody).count(), + "six defaulted parameters is 2^6 calls, which is the most this generator will emit", + ) + } + + /** + * The limit the Kotlin compiler imposed, and the only reason the omission sets are decided over + * the whole walk instead of where the call is built. + * + * Emitting every subset unconditionally did not compile: `foundation-layout-desktop-1.6.11` + * failed in six places, every one of them the branch that writes **no** argument, every one of + * them *"Overload resolution ambiguity between candidates"* -- `WindowInsets(Dp×4)` against + * `WindowInsets(Int×4)`, `paddingFromBaseline(Dp,Dp)` against its `TextUnit` twin, + * `paddingFrom(AlignmentLine,Dp,Dp)` against its. `insets` is that shape in types this fixture + * owns. + * + * What survives is everything else: writing *one* argument settles the overload by its type, so + * only the empty branch is refused, and it is refused by throwing rather than by being absent -- + * `pythonx` reads `paramHasDefault` per slot and cannot express "these two but not both at once". + */ + @Test + fun theOmissionThatWouldBeAmbiguousIsRefusedAndTheRestSurvive() { + val doubles = entry("insets__Double_Double") + val ints = entry("insets__Int_Int") + for (candidate in listOf(doubles, ints)) { + val body = candidate.lambdaBody.flattened() + assertEquals( + 1, + Regex("is ambiguous with another overload").findAll(body).count(), + "only the write-nothing branch is ambiguous: $body", + ) + assertTrue("leaving out left and right is ambiguous" in body, body) + // Both parameters stay omittable: each appears in an omission set that *is* generated. + assertEquals(listOf(true, true), candidate.paramHasDefault) + } + assertTrue( + "insets(left = (args[0] as Double))" in doubles.lambdaBody.flattened(), + "one written argument settles the overload by its type: ${doubles.lambdaBody}", + ) + assertTrue( + "insets(right = (args[1] as Long).toInt())" in ints.lambdaBody.flattened(), + ints.lambdaBody, + ) + } + + /** The other side of it: a defaulted name with no sibling keeps every subset, the empty one + * included. `sixWide` is 64 calls and no refusal. */ + @Test + fun aNameWithNoOverloadSetKeepsEveryOmissionIncludingTheEmptyOne() { + val body = entry("sixWide").lambdaBody + assertFalse("ambiguous" in body, "nothing to be ambiguous with") + assertTrue("fixture.artifactdefaults.sixWide()" in body.flattened(), "the empty call is missing") + } + + /** + * A named argument is source, so a parameter whose name is a Kotlin hard keyword has to be + * written back with the backticks metadata does not carry. Without this the generated fragment + * fails to *compile*, in a consumer's build, over a declaration they only asked to have bound. + */ + @Test + fun aParameterNamedWithAKeywordIsEscapedInTheNamedCall() { + val body = entry("keyworded").lambdaBody.flattened() + assertTrue( + body.contains("fixture.artifactdefaults.keyworded(`object` = (args[0] as String))"), + "a hard keyword has to be written back with its backticks: $body", + ) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/FunctionSlotBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/FunctionSlotBindingTest.kt new file mode 100644 index 000000000..d6f04774d --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/FunctionSlotBindingTest.kt @@ -0,0 +1,325 @@ +package python.multiplatform.gradle.artifact + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The layer `a179b747` left closed: a function-typed parameter of a declaration that is **not** a + * `@Composable`. + * + * ### What was in the way + * + * That commit taught the slot *name* to carry a whole signature, and taught `pythonx` to build a + * `FunctionN` from a Python callable out of it. Both of those are shared by every producer. What was + * not shared is how the two producers reach a parameter's type at all: + * + * | | how the slot's type is turned into a call | + * |---|---| + * | `@Composable` | [ComposableThunks] emits bytecode; a `CHECKCAST` to `kotlin/jvm/functions/FunctionN` needs no Kotlin name | + * | everything else | generated **Kotlin source** casts `args[i]` to the declared type, so the type has to be *spellable* | + * + * `resolveKotlinType` -> `objectBoundaryTypeOrNull` answers "not spellable" for `kotlin.Function0`, + * and it is right to: it looks the classifier up as a public class file, and Kotlin's built-in + * function types have none anywhere -- they live in `.kotlin_builtins` metadata. So the parameter + * was declined one layer before the slot grammar was ever consulted, and + * `ArtifactScannerTest.composeModifierExtensionsSurviveBothGates` counted the cost: **44 of the 46 + * declined public top-level `Modifier` extensions declined for exactly that**, `clickable` and + * `combinedClickable` among them. + * + * ### What opens it + * + * [ArtifactScanner.candidateFromFunction] now consults the same slot grammar + * [ArtifactScanner.functionSlotTypeName] the composable path uses, and -- when it can also *write* + * the Kotlin type -- supplies its own `OBJECT` boundary type rather than asking + * `resolveKotlinType` for one. `objectBoundaryTypeOrNull` is untouched, so nothing else that has no + * class file became bindable; the walker opted this one case in at the point it knows the JVM + * descriptor, which is the fact the grammar needs and the type resolver never had. + * + * ### What still declines, and why it is not an omission + * + * | shape | example | why | + * |---|---|---| + * | `suspend` lambda | `Modifier.pointerInput(block:)` | its compiled type takes a `Continuation` and answers `COROUTINE_SUSPENDED`; a Python callable cannot be one, and Kotlin refuses to assign a `Function2` to a `suspend` function type at all | + * | `@Composable` lambda inside a non-composable | `Modifier.composed(factory:)` | the generated fragment is compiled **without** the Compose plugin, so `@Composable () -> Unit` there is a `Function0` while the callee wants the lowered `Function3` | + * | a type argument with no name | `Modifier.swipeable`'s `thresholds` | a type *parameter* has no spelling, which is the same reason the composable path returns `null` for it | + * + * A slot returning something other than `kotlin.Unit` is **not** in that table: it binds, carries its + * return type in the name, and `pythonx._coerce` refuses to fill it with a message. That is + * `a179b747`'s judgement and it is unchanged here -- and it is the same judgement for a + * non-composable, for the same reason: `TypeTag` carries one `INT` for `Byte` through `Long`, so + * neither side can say which boxed type the callee will cast the erased answer to. What differs is + * only that the declaration around it is reachable, so a caller who wanted `onKeyEvent`'s *other* + * arguments gets a refusal naming the slot instead of an `AttributeError` naming nothing. + */ +class FunctionSlotBindingTest { + + private val caches: File? = listOf( + File(System.getProperty("user.home"), ".gradle/caches/modules-2/files-2.1"), + File("/Volumes/macMini/caches/.gradle/caches/modules-2/files-2.1"), + ).firstOrNull { it.isDirectory } + + private fun jarUnder(group: String, artifact: String): File? = caches?.resolve(group)?.resolve(artifact) + ?.walkTopDown()?.firstOrNull { it.isFile && it.name.endsWith(".jar") && "sources" !in it.name } + + private val kotlinStdlibJar: File + get() = File(Class.forName("kotlin.text.Regex").protectionDomain.codeSource.location.toURI()) + + private fun composeClasspath(): List = listOfNotNull( + jarUnder("org.jetbrains.compose.material3", "material3-desktop"), + jarUnder("org.jetbrains.compose.material", "material-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-unit-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-text-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-geometry-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-util-desktop"), + jarUnder("org.jetbrains.compose.runtime", "runtime-desktop"), + jarUnder("org.jetbrains.compose.runtime", "runtime-saveable-desktop"), + jarUnder("org.jetbrains.compose.animation", "animation-core-desktop"), + jarUnder("org.jetbrains.compose.collection-internal", "collection-desktop"), + jarUnder("org.jetbrains.compose.annotation-internal", "annotation-desktop"), + kotlinStdlibJar, + ) + + /** + * The acceptance criterion's declaration, out of the real jar: `Modifier.clickable`. + * + * Three separate facts, and each fails on its own: + * + * 1. it is **bound at all** -- before this it was declined with "no boundary type for parameter + * kotlin.Function0"; + * 2. its `onClick` slot carries the signature grammar `pythonx._function_slot` parses, not the + * bare classifier `kotlin.Function0` an ordinary object slot would carry; + * 3. the generated body **casts** to a Kotlin function type, which is the part the composable + * path never needed and the part that decides whether the fragment compiles. + */ + @Test + fun clickableIsBoundAndItsOnClickCarriesTheSlotGrammar() { + val foundation = jarUnder("org.jetbrains.compose.foundation", "foundation-desktop") ?: return + val entries = ArtifactScanner.scanJar(foundation, listOf("androidx.compose.foundation"), composeClasspath()) + + val clickable = entries.filter { it.name.substringAfterLast('.').substringBefore("__") == "clickable" } + assertTrue(clickable.isNotEmpty(), "Modifier.clickable is not bound: ${entries.map { it.name }}") + + // Compose 1.6's `clickable(enabled, onClickLabel, role, onClick)` is binary-only (hidden) by + // CMP 1.11; its source-visible successor adds a defaulted `interactionSource` before `onClick`. + val simple = clickable.single { + it.paramNames == listOf("", "enabled", "onClickLabel", "role", "interactionSource", "onClick") + } + assertEquals("androidx.compose.ui.Modifier", simple.receiverTypeName) + assertEquals("kotlin.Function0()->kotlin.Unit", simple.paramTypeNames.last(), "clickable.onClick") + assertEquals("OBJECT", simple.paramTags.last(), "a callable crosses as a handle") + // The slot has no default, so `pythonx` must be able to fill it -- exactly Column.content's + // situation one producer over. + assertEquals(false, simple.paramHasDefault.last(), "onClick declares no default") + assertTrue( + "as kotlin.Function0" in simple.lambdaBody, + "the generated body must cast the handle to a Kotlin function type: ${simple.lambdaBody}", + ) + // And no thunk: this is ordinary Kotlin source, unlike every composable binding. + assertNull(simple.thunk, "a non-composable needs no bytecode thunk") + } + + /** + * A slot that forwards an argument, and one whose forwarded argument is **nullable**. + * + * The second is the one that says the cast is rendered from the declared `KmType` rather than + * re-parsed out of the slot name. `Modifier.onFocusedBoundsChanged` takes + * `(LayoutCoordinates?) -> Unit`, and the name drops nullability by design (it is a key, and + * `pythonx` marshals `null` as `None` whatever the slot said). A cast rebuilt from that name + * would be `Function1`, which is **not** a subtype of + * `Function1` -- `Function1` is contravariant in its argument -- and + * would fail to compile in the generated fragment, one build after this walk. + */ + @Test + fun aForwardedArgumentKeepsItsDeclaredNullabilityInTheCast() { + val ui = jarUnder("org.jetbrains.compose.ui", "ui-desktop") ?: return + val entries = ArtifactScanner.scanJar(ui, listOf("androidx.compose.ui"), composeClasspath()) + + val positioned = entries.single { it.name == "androidx.compose.ui.layout.onGloballyPositioned" } + assertEquals( + "kotlin.Function1(androidx.compose.ui.layout.LayoutCoordinates)->kotlin.Unit", + positioned.paramTypeNames.last(), + ) + assertTrue( + "as kotlin.Function1" in positioned.lambdaBody, + positioned.lambdaBody, + ) + + val foundation = jarUnder("org.jetbrains.compose.foundation", "foundation-desktop") ?: return + val focused = ArtifactScanner + .scanJar(foundation, listOf("androidx.compose.foundation"), composeClasspath()) + .singleOrNull { it.name == "androidx.compose.foundation.onFocusedBoundsChanged" } + assertNotNull(focused, "androidx.compose.foundation.onFocusedBoundsChanged is not bound") + assertTrue( + "as kotlin.Function1" in focused.lambdaBody, + "the cast must keep the argument's `?`: ${focused.lambdaBody}", + ) + } + + /** + * The three shapes that stay declined, each with the reason spelled out rather than absent. + * + * `pointerInput` is the one `a179b747`'s open note named beside `clickable`, and the honest + * finding is that it is a *different* limit: its `block` is `suspend PointerInputScope.() -> Unit`, + * whose compiled type is `Function2, Any?>`. Kotlin will + * not assign that to a `suspend` function type from source at all, and a Python callable has no + * way to answer `COROUTINE_SUSPENDED`, so it is refused with a reason rather than bound into an + * entry that cannot work. + */ + @Test + fun suspendComposableAndUnnameableFunctionSlotsAreDeclinedWithAReason() { + val ui = jarUnder("org.jetbrains.compose.ui", "ui-desktop") ?: return + val material = jarUnder("org.jetbrains.compose.material", "material-desktop") ?: return + val classpath = composeClasspath() + + val uiDeclarations = ArtifactScanner.scanDeclarations(ui, listOf("androidx.compose.ui"), classpath) + val pointerInput = uiDeclarations.filter { it.simpleName == "pointerInput" } + assertTrue(pointerInput.isNotEmpty(), "pointerInput was not walked at all") + // Through Compose 1.6 every overload's `block` was a suspend lambda. From Compose 1.8 the keyed + // overloads take a `PointerInputEventHandler` (a fun interface) instead; only the key-less + // overload still carries the suspend lambda. Until issue #38 the keyed ones also declined, on + // their `Any?` keys -- an incidental reason, not this test's subject: #38 (SPEC B-1/U-10) makes + // `kotlin.Any?` cross as an object handle, so a keyed overload, whose `block` is an + // interface-typed object slot and not a function slot at all, now binds. What this test is + // about still holds without exception: no overload with a function-typed slot binds, and + // every overload that does not bind says why. + assertTrue( + pointerInput.all { declaration -> + if (declaration.bindingName == null) { + declaration.declineReason != null + } else { + declaration.parameters.none { it.type.qualifiedName.startsWith("kotlin.Function") } + } + }, + "a pointerInput overload with a function-typed slot bound, or one declined silently: " + + "${pointerInput.map { it.bindingName to it.declineReason }}", + ) + assertTrue( + pointerInput.none { it.bindingName == null && it.declineReason == null }, + "every declined pointerInput overload must say why: ${pointerInput.map { it.declineReason }}", + ) + assertTrue( + pointerInput.any { it.declineReason?.contains("suspend") == true }, + "pointerInput's suspend-lambda overload must decline for it: ${pointerInput.map { it.declineReason }}", + ) + + val composed = uiDeclarations.filter { it.simpleName == "composed" && it.receiver != null } + assertTrue( + composed.isNotEmpty() && composed.all { it.declineReason?.contains("@Composable") == true }, + "Modifier.composed's factory is a composable lambda: ${composed.map { it.declineReason }}", + ) + + val swipeable = ArtifactScanner.scanDeclarations(material, listOf("androidx.compose.material"), classpath) + .filter { it.simpleName == "swipeable" } + assertTrue( + swipeable.isNotEmpty() && swipeable.all { it.bindingName == null }, + "a slot over a type parameter has no spelling and must stay declined", + ) + } + + /** + * The composable path is **not** re-routed through the new one. + * + * Both now consult [ArtifactScanner.functionSlotTypeName], so the failure to guard against is a + * composable slot losing its `@Composable` mark (which is what tells `pythonx` to thread a + * composer) or gaining a source-level cast (which would be a `Function3` handed to a `Function0` + * parameter). `Column` carries both risks at once: its `content` is lowered *and* its binding is + * a thunk. + */ + @Test + fun theComposablePathStillBindsThroughAThunkAndKeepsItsMark() { + val layout = jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop") ?: return + val column = ArtifactScanner + .scanJar(layout, listOf("androidx.compose.foundation.layout"), composeClasspath()) + .single { it.name == "androidx.compose.foundation.layout.Column" } + + assertEquals( + "kotlin.Function3@Composable(androidx.compose.foundation.layout.ColumnScope)->kotlin.Unit", + column.paramTypeNames[column.paramNames.indexOf("content")], + ) + assertNotNull(column.thunk, "a composable is still bound by bytecode, not by a source cast") + assertTrue("as kotlin.Function" !in column.lambdaBody, "a thunk body casts nothing: ${column.lambdaBody}") + } + + /** + * `kotlin.system.measureTimeMillis`, which is the shape `:ksp-fixtures:artifact` calls from real + * Python: the same `() -> Unit` slot `clickable.onClick` has, on a declaration that **invokes** + * it rather than storing it for a composition to invoke later. + * + * Out of `kotlin-stdlib` rather than out of Compose on purpose. Every Compose callback is stored + * -- `clickable` is `composed { }`, `drawBehind` is a node, `semantics` is an element -- so + * nothing in those jars invokes a `() -> Unit` at the moment it is called, and a test that wants + * to see the callback *run* outside a composition has to find a declaration that runs it. + */ + @Test + fun aStdlibDeclarationThatInvokesItsLambdaIsBound() { + val entries = ArtifactScanner.scanJar(kotlinStdlibJar, listOf("kotlin.system"), listOf(kotlinStdlibJar)) + val measure = entries.single { it.name == "kotlin.system.measureTimeMillis" } + assertEquals(listOf("block"), measure.paramNames) + assertEquals("kotlin.Function0()->kotlin.Unit", measure.paramTypeNames.single()) + assertEquals("INT", measure.returnTag, "a Long return crosses as INT") + } + + /** + * The scorecard this change is measured by, in the same shape + * `ArtifactScannerTest.composeModifierExtensionsSurviveBothGates` prints it. + * + * The number that matters is not "how many bind" but **how many of the ones that do not, do not + * for a function-typed parameter** -- 44 of 46 before, 6 of 8 after, with 157 `Modifier` + * extensions bound where there were 107. Counted per *declaration* here rather than per name, so + * the numbers this test prints are the overload-level ones (50 bound, 15 declined) and the + * name-level ones stay in `ArtifactScannerTest`. + * + * The assertion is a floor rather than an equality for that test's stated reason (a Compose bump + * must not fail it), and the named declarations below are what a floor alone would not pin. + */ + @Test + fun mostFunctionTypedModifierExtensionsAreNoLongerDeclinedForBeingFunctionTyped() { + val walked = listOfNotNull( + jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-unit-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-desktop"), + jarUnder("org.jetbrains.compose.material", "material-desktop"), + jarUnder("org.jetbrains.compose.material3", "material3-desktop"), + ) + if (walked.size < 6) return + val classpath = composeClasspath() + + val declarations = walked.flatMap { + ArtifactScanner.scanDeclarations(it, listOf("androidx.compose"), classpath) + } + val modifierExtensions = declarations + .filter { it.receiver?.qualifiedName == "androidx.compose.ui.Modifier" && !it.isComposable } + .distinctBy { it.owner + "." + it.simpleName + it.parameters.map { p -> p.type.qualifiedName } } + val functionTyped = modifierExtensions.filter { declaration -> + declaration.parameters.any { it.type.qualifiedName.startsWith("kotlin.Function") } + } + val bound = functionTyped.filter { it.bindingName != null } + val stillDeclined = functionTyped.filter { it.bindingName == null } + + println("function-typed Modifier extensions: ${bound.size} bound, ${stillDeclined.size} declined") + stillDeclined.groupBy { it.declineReason ?: "(no reason recorded)" }.forEach { (reason, group) -> + println(" ${group.map { it.simpleName }.distinct()}: $reason") + } + assertTrue( + bound.size >= 25, + "expected the function-typed Modifier surface to open; got ${bound.map { it.simpleName }}", + ) + listOf("clickable", "combinedClickable", "semantics", "drawBehind", "onKeyEvent", "toggleable") + .forEach { name -> + assertTrue( + bound.any { it.simpleName == name }, + "$name is still declined: " + stillDeclined.filter { it.simpleName == name } + .map { it.declineReason }, + ) + } + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/JvmDescriptorsTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/JvmDescriptorsTest.kt new file mode 100644 index 000000000..20cf17a97 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/JvmDescriptorsTest.kt @@ -0,0 +1,84 @@ +package python.multiplatform.gradle.artifact + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What the boundary can and cannot carry, stated as data rather than discovered at the point a + * generated fragment fails to compile. + * + * The table this pins is the whole of the walker's admission policy: a declaration is bound if and + * only if every one of its parameter types and its return type appears here. That is a much + * stronger filter than `PyREPL`'s (which is name-based, because it emits `.pyi` stubs that never + * have to compile), and it is what keeps the generated Kotlin buildable without any knowledge of + * how a jar's types map back onto Kotlin's own. + */ +class JvmDescriptorsTest { + + @Test + fun primitivesWidenToWhatTheirTagPromises() { + // `TypeTag.INT` means the boundary carries a Long. A jar's `int` therefore has to narrow on + // the way in and widen on the way out, exactly as `FragmentScanner` does for Kotlin source. + val int = boundaryTypeOf("I")!! + assertEquals("INT", int.tag) + assertEquals("(args[0] as Long).toInt()", int.read("args[0]")) + assertEquals("(junit.X.f()).toLong()", int.wrapReturn("junit.X.f()")) + + val long = boundaryTypeOf("J")!! + assertEquals("INT", long.tag) + assertEquals("(args[0] as Long)", long.read("args[0]")) + + val float = boundaryTypeOf("F")!! + assertEquals("FLOAT", float.tag) + assertEquals("(args[1] as Double).toFloat()", float.read("args[1]")) + + assertEquals("BOOLEAN", boundaryTypeOf("Z")!!.tag) + assertEquals("FLOAT", boundaryTypeOf("D")!!.tag) + assertEquals("INT", boundaryTypeOf("B")!!.tag) + assertEquals("INT", boundaryTypeOf("S")!!.tag) + } + + @Test + fun stringAndByteArrayAndVoidAreCarriedDirectly() { + assertEquals("STRING", boundaryTypeOf("Ljava/lang/String;")!!.tag) + assertEquals("BYTES", boundaryTypeOf("[B")!!.tag) + assertEquals("UNIT", boundaryTypeOf("V")!!.tag) + } + + @Test + fun everythingElseIsUnbindable() { + // Not "bound as OBJECT". A `TypeTag.OBJECT` parameter needs a Kotlin type name to cast to, + // and a jar gives only an erased JVM one: `Ljava/util/List;` has no Kotlin spelling at all + // (Kotlin maps it to `kotlin.collections.(Mutable)List`), and `Ljava/lang/Object;` would + // erase away the very type the cast exists to check. So the walker declines rather than + // emitting source that may not compile. + assertNull(boundaryTypeOf("Ljava/util/List;")) + assertNull(boundaryTypeOf("Ljava/lang/Object;")) + assertNull(boundaryTypeOf("[Ljava/lang/String;")) + assertNull(boundaryTypeOf("[I")) + // `char` has no Python counterpart and no obvious tag; declined rather than guessed at. + assertNull(boundaryTypeOf("C")) + } + + @Test + fun descriptorsSplitIntoParametersAndAReturn() { + assertEquals( + listOf("Ljava/lang/String;", "Z") to "V", + splitMethodDescriptor("(Ljava/lang/String;Z)V"), + ) + assertEquals(emptyList() to "Ljava/lang/String;", splitMethodDescriptor("()Ljava/lang/String;")) + assertEquals(listOf("[B", "[B") to "V", splitMethodDescriptor("([B[B)V")) + assertEquals(listOf("J", "J") to "V", splitMethodDescriptor("(JJ)V")) + assertEquals(listOf("[Ljava/lang/String;") to "V", splitMethodDescriptor("([Ljava/lang/String;)V")) + } + + @Test + fun aVoidParameterIsNotAThing() { + // `V` is a return-position-only descriptor. It is admitted by `boundaryTypeOf` because the + // return position needs it; a parameter carrying it would be a malformed class file, and + // the walker must not turn that into generated source that reads plausibly. + assertTrue(boundaryTypeOf("V")!!.isReturnOnly) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/KlibScannerTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/KlibScannerTest.kt new file mode 100644 index 000000000..5a5e77f7d --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/KlibScannerTest.kt @@ -0,0 +1,462 @@ +package python.multiplatform.gradle.artifact + +import org.jetbrains.kotlin.library.abi.AbiClassifierReference +import org.jetbrains.kotlin.library.abi.AbiCompoundName +import org.jetbrains.kotlin.library.abi.AbiFunction +import org.jetbrains.kotlin.library.abi.AbiModality +import org.jetbrains.kotlin.library.abi.AbiQualifiedName +import org.jetbrains.kotlin.library.abi.AbiSignatureVersion +import org.jetbrains.kotlin.library.abi.AbiSignatures +import org.jetbrains.kotlin.library.abi.AbiType +import org.jetbrains.kotlin.library.abi.AbiTypeArgument +import org.jetbrains.kotlin.library.abi.AbiTypeNullability +import org.jetbrains.kotlin.library.abi.AbiTypeParameter +import org.jetbrains.kotlin.library.abi.AbiValueParameter +import org.jetbrains.kotlin.library.abi.ExperimentalLibraryAbiReader +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The klib counterpart of `ArtifactScannerTest`, against two real klibs: `kotlin-stdlib` (bundled + * with the Kotlin/Native distribution this build's `.konan` cache already stages, so no extra + * download) and `kotlinx-coroutines-core-androidnativearm64:1.8.1` (a real download, the klib + * counterpart of `ArtifactScannerTest`'s `junit:junit:4.13.2`). + * + * ROADMAP §16e: reading a klib is the *easier* half of the two producers, not the harder one -- + * `LibraryAbiReader` gives exact Kotlin types directly, with no facade problem to work around. What + * still needs proving is that this walker matches on the right thing and disambiguates the same way + * the JVM walker does; both real klibs below are chosen to exercise exactly that. + */ +class KlibScannerTest { + + /** + * `kotlin-stdlib`'s own klib, staged by the Kotlin/Native distribution this build already pins + * (`kotlin-compiler-embeddable:2.0.20` here; the `.konan` prebuilt toolchain is the matching + * version). `~/.konan` is this workspace's own symlink onto external storage -- AGENTS.md's + * "캐시 ... 홈에서 심볼릭 링크" -- so this path is host-relative, not hardcoded to a volume. + * + * Guarded rather than asserted: a machine that has never run a Kotlin/Native build has no + * `.konan` cache yet, and that is a setup gap this test should report plainly rather than fail + * opaquely on. See each test's own early return. + */ + private val konanStdlibKlib: File + get() = File( + System.getProperty("user.home") + + "/.konan/kotlin-native-prebuilt-macos-aarch64-2.0.20/klib/common/stdlib", + ) + + private val coroutinesKlib: File + get() { + val path = System.getProperty("python.multiplatform.walkerFixtureKlib") + assertNotNull(path, "the walkerFixtureKlib system property is not set; see build.gradle.kts") + return File(path).also { assertTrue(it.isFile, "not a file: $it") } + } + + /** + * The bug this pins: `AbiQualifiedName.toString()` joins package and relative name with `/` + * (`kotlin.math/abs`), not `.`. An earlier version of [KlibScanner.matchesInclude] matched the + * *full* qualified name against `"kotlin.math."` (the JVM producer's dotted-class rule, reused + * unchanged) -- which a declaration directly inside the `kotlin.math` package can never satisfy, + * because the character after the prefix is `/`. Every one of `kotlin.math`'s bindable + * declarations is directly in that package (there is no `kotlin.math.trig` sub-package), so the + * old rule bound **zero** of them; matching on `packageName` alone (this test) is what makes the + * 68 below reachable at all. + */ + @Test + fun aPackageAnIncludePrefixNamesExactlyIsMatchedNotOnlyItsSubPackages() { + if (!konanStdlibKlib.isDirectory) { + println("skipped: no .konan cache at $konanStdlibKlib") + return + } + val entries = KlibScanner.scanKlib(konanStdlibKlib, includePrefixes = listOf("kotlin.math")) + assertTrue(entries.isNotEmpty(), "kotlin.math yielded nothing; the package-prefix match regressed") + assertEquals(68, entries.size) + } + + /** + * `kotlin.math.max`/`min` are each declared four times (`Int`, `Long`, `Float`, `Double`), and a + * klib's ABI carries the exact Kotlin type for each -- the same fact `@Metadata` gives the JVM + * producer, so [ArtifactScanner.disambiguateOverloads]'s `name__` rule applies unchanged + * (this test calls the *shared* function, not a klib-local copy). An earlier version of + * [KlibScanner] grouped by bare name and kept only groups of size one, silently dropping `max`, + * `min` and every other overloaded `kotlin.math` name; suffixing instead of dropping is what + * [ArtifactScannerTest.theWholeJUnitJarYieldsExactlyTheseUnsuffixedDeclarations]'s KDoc calls "a + * third option between arbitrate and drop", applied here to a klib for the first time. + */ + @Test + fun overloadedNamesAreSuffixedNotDropped() { + if (!konanStdlibKlib.isDirectory) { + println("skipped: no .konan cache at $konanStdlibKlib") + return + } + val names = KlibScanner.scanKlib(konanStdlibKlib, includePrefixes = listOf("kotlin.math")).map { it.name } + assertEquals( + listOf("kotlin.math.max__Double_Double", "kotlin.math.max__Float_Float", "kotlin.math.max__Int_Int", "kotlin.math.max__Long_Long"), + names.filter { it.startsWith("kotlin.math.max__") }.sorted(), + ) + // The bare name must be absent -- exactly one binding may ever answer to it, and here there + // are four, none of which this walker may pick for the others. + assertTrue("kotlin.math.max" !in names) + assertTrue("kotlin.math.min" !in names) + } + + /** One entry, its tags and its call, pinned the same way + * [ArtifactScannerTest.anEntryCarriesTheTagsAndTheCallItsDeclarationImplies] pins a JVM one. */ + @Test + fun anEntryCarriesTheTagsItsDeclarationImplies() { + if (!konanStdlibKlib.isDirectory) { + println("skipped: no .konan cache at $konanStdlibKlib") + return + } + val entries = KlibScanner.scanKlib(konanStdlibKlib, includePrefixes = listOf("kotlin.math")) + val absInt = entries.single { it.name == "kotlin.math.abs__Int" } + assertEquals(1, absInt.arity) + assertEquals(listOf("INT"), absInt.paramTags) + assertEquals("INT", absInt.returnTag) + assertEquals(listOf("kotlin.Int"), absInt.paramTypeNames) + } + + /** + * A real, separately-published klib -- the klib counterpart of `ArtifactScannerTest`'s JUnit jar + * -- scanned with no include filter, over all ~400 of its top-level declarations. Proves the + * walker does not crash on a large, diverse artefact: `suspend`, extension functions, value + * classes and `@PublishedApi internal` declarations are all present in `kotlinx-coroutines-core` + * and must all be *declined*, not mishandled. + * + * **Zero bindable, and that is the correct number, not a regression.** An earlier version of this + * test pinned one -- `checkIndexOverflow`, `kotlinx.coroutines.flow.internal`'s bounds check -- as + * bound. It was wrong: `checkIndexOverflow` is `@PublishedApi internal`, binary-visible so an + * inline call site elsewhere in the *same* library can resolve it, but not source-visible outside + * that module. `KlibScanWorkAction`'s KDoc records how this was found -- `:ksp-fixtures + * :klib-artifact:compileKotlinAndroidNativeArm64` failing to compile the generated fragment with + * "it is internal in file", the first time this walker's output was ever actually compiled rather + * than only unit-tested. `KlibScanner`'s `PUBLISHED_API` constant has the rest. Every other candidate in + * this klib's ~400 top-level declarations is an extension function (declined for the reason + * [aDeclinedDeclarationIsRecordedWithItsReasonRatherThanVanishing] pins) or `suspend` -- coroutines' + * entire public surface operates on `CoroutineScope`/`Job`/`Flow` through extensions, and none of + * those are bindable boundary types today. `kotlin.math` above is the richer case; this one is the + * "does it survive a real, messy artefact, and decline what it must decline" case. + */ + @Test + fun aRealThirdPartyKlibIsScannedWithoutCrashingAndDeclinesEveryDeclaration() { + val entries = KlibScanner.scanKlib(coroutinesKlib, includePrefixes = emptyList()) + assertEquals(emptyList(), entries.map { it.name }) + } + + /** The specific bug [aRealThirdPartyKlibIsScannedWithoutCrashingAndDeclinesEveryDeclaration] pins + * the fix for, isolated to one declaration and its reason. */ + @Test + fun aPublishedApiInternalDeclarationIsDeclinedWithItsOwnReason() { + val declarations = KlibScanner.scanKlibDeclarations(coroutinesKlib, includePrefixes = emptyList()) + val checkIndexOverflow = declarations.single { it.simpleName == "checkIndexOverflow" } + assertEquals(null, checkIndexOverflow.bindingName) + assertTrue( + checkIndexOverflow.declineReason?.contains("PublishedApi") == true, + "expected a @PublishedApi decline reason, got ${checkIndexOverflow.declineReason}", + ) + } + + /** + * A namespace include prefix means "this package or a sub-package of it", not "a text prefix of + * the package string". `kotlin.collections`, `kotlin.concurrent` and `kotlin.comparisons` are all + * real `kotlin-stdlib` packages that literally start with the seven characters `kotlin.co` -- + * proof that [KlibScanner.matchesInclude] checks a `.`-bounded namespace and not + * `String.startsWith` on its own, using packages that actually exist rather than an invented + * negative case. + */ + @Test + fun anIncludePrefixIsANamespaceNotATextPrefix() { + if (!konanStdlibKlib.isDirectory) { + println("skipped: no .konan cache at $konanStdlibKlib") + return + } + val entries = KlibScanner.scanKlib(konanStdlibKlib, includePrefixes = listOf("kotlin.co")) + assertEquals(emptyList(), entries, "\"kotlin.co\" is a text prefix of real packages but not their namespace") + } + + // ------------------------------------------------------------------- the declaration model half + + /** + * `docs/design/pyi-generation-design.md` §2.2's "what is declined stays visible", for the klib producer. + * + * Before this, [KlibScanner] dropped an extension, a `suspend` or an unbindable-typed declaration + * at a `filter`, so nothing downstream could say it had ever been seen. `kotlin.math` is the case + * that makes this concrete: `Double.pow`, `Double.withSign` and the rest of its extension surface + * are the *majority* of the package, and a stub product that simply omitted them would read as if + * `kotlin.math` were 68 functions wide. + */ + @Test + fun aDeclinedDeclarationIsRecordedWithItsReasonRatherThanVanishing() { + if (!konanStdlibKlib.isDirectory) { + println("skipped: no .konan cache at $konanStdlibKlib") + return + } + val declarations = KlibScanner.scanKlibDeclarations(konanStdlibKlib, includePrefixes = listOf("kotlin.math")) + val bound = declarations.filter { it.bindingName != null } + val declined = declarations.filter { it.bindingName == null } + + assertEquals(68, bound.size, "the bound half must still be exactly what scanKlib returns") + assertTrue(declined.isNotEmpty(), "kotlin.math's extensions must be recorded, not dropped") + assertTrue( + declined.all { it.declineReason != null }, + "a declined declaration with no reason is the silent drop this replaces", + ) + // The one klib cannot answer and the JVM producer can: the receiver's own type. + val pow = declined.filter { it.simpleName == "pow" } + assertTrue(pow.isNotEmpty(), "kotlin.math.pow is an extension on Double and must appear declined") + assertTrue( + pow.all { it.declineReason!!.contains("extension receiver") }, + "expected an extension-receiver reason, got ${pow.map { it.declineReason }}", + ) + assertTrue(pow.all { it.receiver == null }, "klib cannot name the receiver, so it must not claim one") + } + + /** The two halves come out of one walk, exactly as `DeclarationModelTest + * .theModelAndTheBindingsAgreeBecauseTheyComeFromTheSameWalk` pins for the JVM producer. */ + @Test + fun theModelAndTheBindingsAgreeBecauseTheyComeFromTheSameWalk() { + if (!konanStdlibKlib.isDirectory) { + println("skipped: no .konan cache at $konanStdlibKlib") + return + } + val bound = KlibScanner.scanKlib(konanStdlibKlib, includePrefixes = listOf("kotlin.math")).map { it.name } + val modelled = KlibScanner.scanKlibDeclarations(konanStdlibKlib, includePrefixes = listOf("kotlin.math")) + .mapNotNull { it.bindingName } + assertEquals(bound.sorted(), modelled.sorted()) + } + + /** + * The overload rule is *one* implementation shared with the JVM producer, and the model sees its + * result: `kotlin.math.max`'s four members reach the table under `max__Int_Int` and friends, and + * the [python.multiplatform.gradle.model.DeclarationModel] for each carries that suffixed key as + * its `bindingName` while keeping `max` as its `simpleName`. A stub generator needs both -- the + * key to name the attribute, the simple name to say what Kotlin called it. + */ + @Test + fun theSharedOverloadRuleReachesTheModelsBindingName() { + if (!konanStdlibKlib.isDirectory) { + println("skipped: no .konan cache at $konanStdlibKlib") + return + } + val max = KlibScanner.scanKlibDeclarations(konanStdlibKlib, includePrefixes = listOf("kotlin.math")) + .filter { it.simpleName == "max" && it.bindingName != null } + assertEquals( + listOf( + "kotlin.math.max__Double_Double", + "kotlin.math.max__Float_Float", + "kotlin.math.max__Int_Int", + "kotlin.math.max__Long_Long", + ), + max.map { it.bindingName!! }.sorted(), + ) + assertTrue(max.all { it.simpleName == "max" }, "the Kotlin name must survive the table key") + } + + /** + * §3.2's rule, reached for a third reason. A klib's `AbiValueParameter` has a `type`, a + * `hasDefaultArg` and no **name** -- `kotlin-compiler-embeddable` 2.0.20's interface declares + * exactly five members and none of them is one (checked with `javap`, not assumed). So every klib + * declaration is positional-only, the same as a Java jar compiled without `-parameters`, and + * `parameterNamesKnown` is what says so rather than a synthesised `arg0`. + */ + @Test + fun aKlibSuppliesTypesAndDefaultsButNoParameterNames() { + if (!konanStdlibKlib.isDirectory) { + println("skipped: no .konan cache at $konanStdlibKlib") + return + } + val absInt = KlibScanner.scanKlibDeclarations(konanStdlibKlib, includePrefixes = listOf("kotlin.math")) + .single { it.bindingName == "kotlin.math.abs__Int" } + assertEquals(false, absInt.parameterNamesKnown) + assertEquals(listOf(null), absInt.parameters.map { it.name }) + assertEquals(listOf("kotlin.Int"), absInt.parameters.map { it.type.qualifiedName }) + assertEquals(listOf("INT"), absInt.parameters.map { it.boundaryTag }) + assertEquals("kotlin.Int", absInt.returnType.qualifiedName) + assertEquals("INT", absInt.returnBoundaryTag) + assertEquals("kotlin.math", absInt.owner) + assertEquals(false, absInt.ownerIsClass) + } + + /** + * A nullable primitive is declined, the same rule `resolveKotlinType` applies to the JVM + * producer's `Int?` -- and for the same reason: `TypeTag.INT` carries a `Long` and the generated + * read is `(args[0] as Long).toInt()`, so a Python `None` would fail inside the cast rather than + * reach the declaration. + * + * This is not a rule the klib walker used to have. `AbiType.Simple.nullability` exists on 2.0.20 + * and the first version of this file never read it, so `String?` mapped to `STRING` and would have + * generated `(args[0] as String)` for a parameter that accepts null. + */ + @Test + fun aNullablePrimitiveIsDeclinedTheWayTheJvmProducerDeclinesOne() { + if (!konanStdlibKlib.isDirectory) { + println("skipped: no .konan cache at $konanStdlibKlib") + return + } + val declarations = KlibScanner.scanKlibDeclarations(konanStdlibKlib, includePrefixes = NULLABLE_PROBE_PACKAGES) + val nullablePrimitiveTyped = declarations.filter { declaration -> + declaration.parameters.any { it.type.isNullable && it.type.qualifiedName in KLIB_PRIMITIVE_NAMES } + } + assertTrue( + nullablePrimitiveTyped.isNotEmpty(), + "no declaration in $NULLABLE_PROBE_PACKAGES has a nullable primitive parameter; " + + "this test's fixture assumption is wrong, not the rule", + ) + assertTrue( + nullablePrimitiveTyped.all { it.bindingName == null }, + "bound with a nullable primitive: ${nullablePrimitiveTyped.filter { it.bindingName != null }.map { it.bindingName }}", + ) + } + + /** + * What confirms `declaresDefault`'s bug is real rather than theoretical, and what rules out + * "reproduce it end-to-end with a real klib" as the way to pin it: neither real klib this + * module's tests can reach (`kotlin-stdlib`, unscoped -- every include prefix matches) binds a + * single declaration that also declares a default. `kotlin-stdlib` has 183 declarations with at + * least one defaulted parameter; every one of them is declined, overwhelmingly (141 of 183) for + * "an extension receiver whose type ... does not expose" -- unrelated to the default itself, but + * enough on its own to keep this combination from ever reaching [KlibScanner]'s bound path here. + * + * That absence is why `declaresDefaultIsFalseForABoundDeclarationEvenWhenTheAbiDeclaresADefault` + * below has to construct its own [AbiFunction]: this test is the record of a real one not being + * available, not a substitute for the synthetic one. It stays as a regression guard against that + * going stale silently -- a future compiler bump that lets extension receivers bind (this file's + * own KDoc names `2.2.20` as the version that would) would make a real klib exercise the bug this + * test currently cannot observe, and this assertion would be the one to fail and say so. + */ + @Test + fun boundDeclarationWithADefaultDoesNotOccurInAvailableRealKlibs() { + if (!konanStdlibKlib.isDirectory) { + println("skipped: no .konan cache at $konanStdlibKlib") + return + } + val declarations = KlibScanner.scanKlibDeclarations(konanStdlibKlib, includePrefixes = emptyList()) + val boundWithDefault = declarations.filter { it.bindingName != null && it.parameters.any { p -> p.declaresDefault } } + assertTrue( + boundWithDefault.isEmpty(), + "expected no bound declaration to declare a default in the available real klibs, found: " + + boundWithDefault.map { it.bindingName }, + ) + } + + /** + * The bug this pins: `DeclarationModel.parameters[].declaresDefault` used to copy + * `AbiValueParameter.hasDefaultArg` straight through for a *bound* declaration too -- the Kotlin + * declaration's own truth, not the generated binding's. `KlibScanner.candidateOrNull`'s own KDoc + * on [ArtifactCallable.paramHasDefault] (the "Deliberately all `false`" comment) already settles + * what the binding's truth is: a klib walk has no `ArtifactScanner.applyDefaultOmission` + * counterpart, so every generated call passes every argument, always. Before the fix in this + * commit, a bound klib parameter with `hasDefaultArg == true` still reported + * `declaresDefault == true`, so `PyiRendering`'s `if (parameter.declaresDefault) " = ..."` would + * write a default the runtime binding cannot honour -- a parameter the `.pyi` says is optional + * but that `pythonx._bind` never receives a `null` sentinel for, because [candidateOrNull]'s + * `lambdaBody` has no branch that omits it. + * + * Since [boundDeclarationWithADefaultDoesNotOccurInAvailableRealKlibs] just established that no + * real klib available here exercises this combination, this test constructs the minimal + * [AbiFunction] the bug needs by hand: a top-level, non-extension, non-suspend, two-`Int`- + * parameter function whose second parameter declares a default. [KlibScanner.candidateOrNull] is + * `internal` rather than `private` for exactly this call -- `scanKlib`/`scanKlibDeclarations` have + * no seam to inject a declaration that was not read from a real file on disk. + */ + @Test + @OptIn(ExperimentalLibraryAbiReader::class) + fun declaresDefaultIsFalseForABoundDeclarationEvenWhenTheAbiDeclaresADefault() { + val function = FakeTopLevelFunction( + simpleName = "topLevelWithDefault", + valueParameters = listOf( + FakeValueParameter(FakePrimitiveType("Int"), hasDefaultArg = false), + FakeValueParameter(FakePrimitiveType("Int"), hasDefaultArg = true), + ), + returnType = FakePrimitiveType("Int"), + ) + + val candidate = KlibScanner.candidateOrNull(function) + + assertNotNull(candidate, "expected the fake declaration to produce a candidate at all") + assertNotNull( + candidate.declaration.bindingName, + "expected the fake declaration to bind, not decline: ${candidate.declaration.declineReason}", + ) + assertNotNull(candidate.callable, "a bound declaration must carry a callable") + assertEquals( + listOf(false, false), + candidate.callable.paramHasDefault, + "the binding's own contract: a klib walk never omits an argument", + ) + assertEquals( + listOf(false, false), + candidate.declaration.parameters.map { it.declaresDefault }, + "a bound klib parameter must never advertise a default the generated call cannot omit " + + "-- see this test's KDoc for what regresses when this reads [false, true] instead", + ) + } + + private companion object { + /** Packages searched for a real nullable-primitive parameter; see the test that uses them. */ + val NULLABLE_PROBE_PACKAGES = listOf("kotlin.text", "kotlin.collections", "kotlin.io", "kotlin") + + val KLIB_PRIMITIVE_NAMES = setOf( + "kotlin.Boolean", "kotlin.Byte", "kotlin.Short", "kotlin.Int", + "kotlin.Long", "kotlin.Float", "kotlin.Double", "kotlin.String", "kotlin.ByteArray", + ) + } +} + +/** + * A `kotlin.` primitive type, minimally -- exactly the shape + * [KlibScanner]'s private `typeModelOf`/`klibBoundaryTypeOf` need to accept it as a boundary type: + * an [AbiClassifierReference.ClassReference] classifier, no type arguments, unmarked nullability. + */ +@OptIn(ExperimentalLibraryAbiReader::class) +private class FakePrimitiveType(name: String) : AbiType.Simple { + override val classifierReference: AbiClassifierReference = object : AbiClassifierReference.ClassReference { + override val className: AbiQualifiedName = AbiQualifiedName(AbiCompoundName("kotlin"), AbiCompoundName(name)) + } + override val arguments: List = emptyList() + override val nullability: AbiTypeNullability = AbiTypeNullability.NOT_SPECIFIED +} + +/** A plain value parameter: not vararg, not `noinline`/`crossinline`, with [hasDefaultArg] the one + * property [declaresDefaultIsFalseForABoundDeclarationEvenWhenTheAbiDeclaresADefault] varies. */ +@OptIn(ExperimentalLibraryAbiReader::class) +private class FakeValueParameter( + override val type: AbiType, + override val hasDefaultArg: Boolean, +) : AbiValueParameter { + override val isVararg: Boolean = false + override val isNoinline: Boolean = false + override val isCrossinline: Boolean = false +} + +/** + * The minimal [AbiFunction] `KlibScanner.candidateOrNull` needs to bind: top-level (package + * `fixture.klib`), no extension receiver, no context receivers, not `suspend`, not a constructor, no + * annotations (so never declined as `@PublishedApi internal`) -- every one of + * `candidateOrNull`/`declarationModelOf`'s early `return declined(...)` checks reads a property this + * class answers "no" to, so the only thing left deciding bound-vs-declined is [valueParameters] and + * [returnType], which the test sets up to be bindable primitives. + */ +@OptIn(ExperimentalLibraryAbiReader::class) +private class FakeTopLevelFunction( + simpleName: String, + override val valueParameters: List, + override val returnType: AbiType, +) : AbiFunction { + override val qualifiedName: AbiQualifiedName = + AbiQualifiedName(AbiCompoundName("fixture.klib"), AbiCompoundName(simpleName)) + override val signatures: AbiSignatures = object : AbiSignatures { + override fun get(version: AbiSignatureVersion): String? = null + } + override fun hasAnnotation(name: AbiQualifiedName): Boolean = false + override val modality: AbiModality = AbiModality.FINAL + override val typeParameters: List = emptyList() + override val isConstructor: Boolean = false + override val isInline: Boolean = false + override val isSuspend: Boolean = false + override val hasExtensionReceiverParameter: Boolean = false + override val contextReceiverParametersCount: Int = 0 +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ObjectMemberBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ObjectMemberBindingTest.kt new file mode 100644 index 000000000..be587db14 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ObjectMemberBindingTest.kt @@ -0,0 +1,72 @@ +package python.multiplatform.gradle.artifact + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #53: the functions of a Kotlin `object` are bound, not only its constants. + * + * `Arrangement.spacedBy` is an instance method of the `Arrangement` singleton -- `INSTANCE.spacedBy` + * in bytecode -- so a walk that looked only at `ACC_STATIC` methods bound `Arrangement.End` and never + * `Arrangement.spacedBy`, and the stub (which renders what is bound) could not carry it. Generated + * Kotlin source calls it as `Arrangement.spacedBy(x)`, which the compiler lowers and mangles; nothing + * here looks a JVM method up by name. + */ +class ObjectMemberBindingTest { + + private val fixtureClasses: File + get() = File(Class.forName("fixture.artifactobject.Gap").protectionDomain.codeSource.location.toURI()) + + private fun declarations() = + ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactobject")) + + private val spacing = "fixture.artifactobject.Spacing" + + @Test + fun anObjectFunctionIsBoundUnderTheObjectsName() { + val describe = declarations().single { it.simpleName == "describe" } + assertEquals("$spacing.describe", describe.bindingName) + assertEquals(spacing, describe.owner) + assertTrue(describe.ownerIsClass) + assertEquals("FUNCTION", describe.kind) + assertEquals("fixture.artifactobject.Gap", describe.parameters.single().type.qualifiedName) + assertEquals("kotlin.String", describe.returnType.qualifiedName) + } + + @Test + fun theOverloadsOfAnObjectFunctionAreToldApart() { + val bound = declarations().filter { it.simpleName == "spacedBy" }.mapNotNull { it.bindingName }.sorted() + assertEquals(listOf("$spacing.spacedBy__Int", "$spacing.spacedBy__Int_String"), bound) + } + + @Test + fun theObjectsConstantIsStillBound() { + val tight = declarations().single { it.simpleName == "Tight" } + assertEquals("STATIC_GETTER", tight.kind) + assertEquals("$spacing.Tight", tight.bindingName) + } + + @Test + fun anObjectsToStringOverrideIsNotBound() { + assertFalse(declarations().any { it.simpleName == "toString" && it.bindingName != null }) + } + + @Test + fun aJvmStaticObjectMemberIsBoundOnce() { + val twice = declarations().filter { it.simpleName == "twice" } + assertEquals(listOf("fixture.artifactobject.StaticSpacing.twice"), twice.mapNotNull { it.bindingName }) + } + + @Test + fun anOrdinaryClassInstanceMethodIsStillNotBound() { + assertTrue(declarations().none { it.simpleName == "instanceMethod" && it.bindingName != null }) + } + + @Test + fun anInternalObjectsMembersAreNotBoundOrDeclared() { + assertTrue(declarations().none { it.simpleName == "hiddenFunction" }) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/PropertyBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/PropertyBindingTest.kt new file mode 100644 index 000000000..c7aadb5f3 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/PropertyBindingTest.kt @@ -0,0 +1,261 @@ +package python.multiplatform.gradle.artifact + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Issue #38 over `PropertyFixtures.kt`: unbounded generic functions bind with their type parameters + * read as `kotlin.Any?`, member properties of public classes bind as `GETTER`/`SETTER` entries, and + * top-level extension properties bind as `GETTER`s. + * + * ### Red before the implementation + * + * Every positive assertion here fails against a walker that reads no `kmClass.properties` and no + * `kmPackage.properties` (no `GETTER` entry exists at all -- `single { ... }` throws "Collection + * contains no element matching") and that declines every type parameter (`identity` and `emptyBox` + * absent). The negative assertions (`largest`, `typeNameOf`, `squared`, the private and internal + * properties, `Registry`, `Grams`) already hold before it, and are what must *keep* holding. + */ +class PropertyBindingTest { + + private val fixtureClasses: File + get() { + val location = Class.forName("fixture.artifactproperty.Counter").protectionDomain.codeSource.location + return File(location.toURI()).also { assertTrue(it.isDirectory, "expected a directory of .class files: $it") } + } + + private val entries by lazy { + ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + } + + private fun entry(name: String): ArtifactCallable = + assertNotNull(entries.singleOrNull { it.name == name }, "$name is not bound; bound: ${entries.map { it.name }}") + + // ------------------------------------------------------------------------------ generics + + @Test + fun anUnboundedTypeParameterIsReadAsNullableAnyAndWrittenOutAtTheCall() { + val identity = entry("fixture.artifactproperty.identity") + assertEquals(listOf("OBJECT"), identity.paramTags) + assertEquals(listOf("kotlin.Any"), identity.paramTypeNames) + assertEquals("kotlin.Any", identity.returnTypeName) + assertEquals("OBJECT", identity.returnTag) + assertEquals( + "{ args -> (fixture.artifactproperty.identity((args[0] as kotlin.Any?))) }", + identity.lambdaBody, + ) + } + + /** `structuralEqualityPolicy()`'s shape: nothing to infer `T` from, so the type argument is the only way it compiles. */ + @Test + fun aTypeParameterNothingCanInferIsStillWrittenOut() { + val emptyBox = entry("fixture.artifactproperty.emptyBox") + assertEquals(0, emptyBox.arity) + assertEquals("fixture.artifactproperty.Box", emptyBox.returnTypeName) + assertEquals("{ (fixture.artifactproperty.emptyBox()) }", emptyBox.lambdaBody) + } + + @Test + fun aBoundedOrReifiedTypeParameterIsStillDeclined() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.largest" !in names, names.toString()) + assertTrue("fixture.artifactproperty.typeNameOf" !in names, names.toString()) + val declarations = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + val reified = declarations.single { it.simpleName == "typeNameOf" } + assertEquals(null, reified.bindingName) + assertTrue(reified.declineReason!!.contains("reified"), reified.declineReason) + } + + // ---------------------------------------------------------------------- member properties + + @Test + fun aMemberValAndVarBindAsGetterAndSetter() { + val count = entry("fixture.artifactproperty.Counter.count") + assertEquals("GETTER", count.kind) + assertEquals(0, count.arity) + assertEquals("fixture.artifactproperty.Counter", count.receiverTypeName) + assertEquals("INT", count.returnTag) + assertEquals("{ args -> ((args[0] as fixture.artifactproperty.Counter).count).toLong() }", count.lambdaBody) + + val setCount = entry("fixture.artifactproperty.Counter.count=") + assertEquals("SETTER", setCount.kind) + assertEquals(1, setCount.arity) + assertEquals(listOf("INT"), setCount.paramTags) + assertEquals(listOf("kotlin.Int"), setCount.paramTypeNames) + assertEquals("UNIT", setCount.returnTag) + assertEquals( + "{ args -> (args[0] as fixture.artifactproperty.Counter).count = (args[1] as Long).toInt(); Unit }", + setCount.lambdaBody, + ) + + assertEquals("GETTER", entry("fixture.artifactproperty.Counter.name").kind) + assertTrue(entries.none { it.name == "fixture.artifactproperty.Counter.name=" }, "a val has no setter") + assertEquals(listOf("STRING"), entry("fixture.artifactproperty.Counter.note=").paramTags, "a nullable String var") + } + + @Test + fun aPropertyNobodyOutsideMayWriteHasNoSetterAndOneNobodyMayReadIsAbsent() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.Counter.guarded" in names, names.toString()) + assertTrue("fixture.artifactproperty.Counter.guarded=" !in names, "a private setter was bound") + assertTrue(names.none { it.endsWith(".hidden") || it.endsWith(".internalProp") }, names.toString()) + } + + /** A property is not a constant the class publishes under its name: the constructor survives it. */ + @Test + fun aPropertyDoesNotCostItsClassItsConstructor() { + assertEquals(2, entry("fixture.artifactproperty.Counter").arity) + } + + /** `MutableState.value`: read through `Box<*>`, written through `Box`. */ + @Test + fun aGenericVarIsReadThroughAStarAndWrittenThroughAny() { + val content = entry("fixture.artifactproperty.Box.content") + assertEquals("kotlin.Any", content.returnTypeName) + assertEquals("{ args -> ((args[0] as fixture.artifactproperty.Box<*>).content) }", content.lambdaBody) + assertEquals( + "{ args -> (args[0] as fixture.artifactproperty.Box).content = (args[1] as kotlin.Any?); Unit }", + entry("fixture.artifactproperty.Box.content=").lambdaBody, + ) + assertEquals("GETTER", entry("fixture.artifactproperty.Box.size").kind) + assertEquals("GETTER", entry("fixture.artifactproperty.Holder.held").kind, "an interface's abstract val") + } + + @Test + fun aBoundedClassTypeParameterDeclinesWhatMentionsItAndEverySetter() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.Bounded.item" !in names, names.toString()) + assertTrue("fixture.artifactproperty.Bounded.plain" in names, names.toString()) + assertTrue("fixture.artifactproperty.Bounded.plain=" !in names, names.toString()) + } + + @Test + fun anObjectsAndAnOpenedValueClasssPropertiesAreNotGetters() { + assertEquals("STATIC_GETTER", entry("fixture.artifactproperty.Registry.size").kind) + assertTrue(entries.none { it.name.startsWith("fixture.artifactproperty.Grams.") }, entries.map { it.name }.toString()) + } + + // ------------------------------------------------------------------- extension properties + + /** `Icons.Filled.Add`'s shape. */ + @Test + fun anExtensionPropertyBindsAsAGetterOnItsReceiver() { + val doubled = entry("fixture.artifactproperty.doubled") + assertEquals("GETTER", doubled.kind) + assertEquals(0, doubled.arity) + assertEquals("fixture.artifactproperty.Counter", doubled.receiverTypeName) + assertEquals( + listOf("import fixture.artifactproperty.doubled as artifact_ext_fixture_artifactproperty_doubled"), + doubled.imports, + ) + assertEquals( + "{ args -> ((args[0] as fixture.artifactproperty.Counter).artifact_ext_fixture_artifactproperty_doubled).toLong() }", + doubled.lambdaBody, + ) + assertEquals("GETTER", entry("fixture.artifactproperty.mirrored").kind) + assertTrue(entries.none { it.name == "fixture.artifactproperty.mirrored=" }, "an extension var's setter was bound") + } + + @Test + fun anExtensionPropertyOnAPrimitiveOrGenericIsDeclined() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.squared" !in names, names.toString()) + assertTrue("fixture.artifactproperty.contentOrNull" !in names, names.toString()) + val squared = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + .single { it.simpleName == "squared" } + assertTrue(squared.declineReason!!.contains("receiver"), squared.declineReason) + } + + // ----------------------------------------------------- a receiver the classpath cannot see + + /** + * The `:ksp-fixtures:compose` compile failure on ui-desktop 1.11's + * `DefaultArchitectureComponentsOwner`: its supertypes live in lifecycle jars that the consumer's + * compile classpath does not carry, so `(args[0] as Owner).lifecycle` does not compile. Declined + * with a reason naming the missing supertype -- for a member property and an extension property + * alike -- and bound once the classpath carries it, so the decline is about the classpath and not + * the class. + * + * Red before the fix: `Detached.level` and `reach` are bound (the first assertion fails). + */ + @Test + fun aReceiverWithASupertypeOffTheClasspathHasItsPropertiesDeclined() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.Detached.level" !in names, names.toString()) + assertTrue("fixture.artifactproperty.reach" !in names, names.toString()) + + val declarations = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + val level = declarations.single { it.owner == "fixture.artifactproperty.Detached" && it.simpleName == "level" } + val reach = declarations.single { it.simpleName == "reach" } + listOf(level, reach).forEach { declined -> + assertEquals(null, declined.bindingName, declined.toString()) + assertEquals("GETTER", declined.kind, declined.toString()) + val reason = assertNotNull(declined.declineReason, declined.toString()) + assertTrue("org.objectweb.asm.Opcodes" in reason && "classpath" in reason, reason) + } + + val asm = File(org.objectweb.asm.Opcodes::class.java.protectionDomain.codeSource.location.toURI()) + val withSupertype = ArtifactScanner.scanJar( + fixtureClasses, + includePrefixes = listOf("fixture.artifactproperty"), + classpath = listOf(fixtureClasses, asm), + ).map { it.name } + assertTrue("fixture.artifactproperty.Detached.level" in withSupertype, withSupertype.toString()) + assertTrue("fixture.artifactproperty.reach" in withSupertype, withSupertype.toString()) + } + + /** + * #66: an extension *function* on the same receiver goes through the same cast, so it is declined + * the same way and bound once the classpath carries the supertype. + * + * Red before the fix: `probe` is bound (the first assertion fails). + */ + @Test + fun aReceiverWithASupertypeOffTheClasspathHasItsExtensionFunctionsDeclined() { + val names = entries.map { it.name } + assertTrue("fixture.artifactproperty.probe" !in names, names.toString()) + + val probe = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + .single { it.simpleName == "probe" } + assertEquals(null, probe.bindingName, probe.toString()) + val reason = assertNotNull(probe.declineReason, probe.toString()) + assertTrue("org.objectweb.asm.Opcodes" in reason && "classpath" in reason, reason) + + val asm = File(org.objectweb.asm.Opcodes::class.java.protectionDomain.codeSource.location.toURI()) + val withSupertype = ArtifactScanner.scanJar( + fixtureClasses, + includePrefixes = listOf("fixture.artifactproperty"), + classpath = listOf(fixtureClasses, asm), + ).map { it.name } + assertTrue("fixture.artifactproperty.probe" in withSupertype, withSupertype.toString()) + } + + // -------------------------------------------------------------------------- the fragment + + /** A property is not an extension slot at the boundary: `isExtension = false`, receiver still named. */ + @Test + fun aRenderedGetterIsAGetterKindWithItsReceiverNamed() { + val source = renderArtifactFragmentSource( + ArtifactFragment("F", "m", listOf(entry("fixture.artifactproperty.Counter.count="))), + ) + assertTrue("kind = python.multiplatform.reflection.CallableKind.SETTER" in source, source) + assertTrue("isExtension = false" in source, source) + assertTrue("receiverTypeName = \"fixture.artifactproperty.Counter\"" in source, source) + } + + /** The model and the table agree for properties too. */ + @Test + fun everyBoundPropertyHasAModelOfItsKind() { + val declarations = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactproperty")) + val byKey = declarations.filter { it.bindingName != null }.associateBy { it.bindingName!! } + entries.filter { it.kind == "GETTER" || it.kind == "SETTER" }.forEach { callable -> + val model = assertNotNull(byKey[callable.name], "no model for ${callable.name}") + assertEquals(callable.kind, model.kind, callable.name) + assertEquals(callable.receiverTypeName, model.receiver?.qualifiedName, callable.name) + } + assertEquals(listOf("value"), byKey.getValue("fixture.artifactproperty.Counter.count=").parameters.map { it.name }) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ReturnSupertypeTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ReturnSupertypeTest.kt new file mode 100644 index 000000000..f6c9d3979 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ReturnSupertypeTest.kt @@ -0,0 +1,219 @@ +package python.multiplatform.gradle.artifact + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **Where the inheritance relation comes from**, and what carrying it costs the table. + * + * `ebe3365f` pinned the second wall: `pythonx._coerce` compared an owned value's declared type name + * against the slot's for **equality**, so `BitmapPainter(...)` was refused where a `Painter` was + * wanted, and every `Painter` in Compose was out of reach except the one function that happens to + * declare the base type. + * + * ### The relation is the walker's to answer, and only the walker's + * + * `pythonx` holds names. A handle is an integer, `TypeTag.OBJECT` says nothing about what it points + * at, and there is no reflection shared by five targets to ask -- agent-rules §12 retires dynamic + * binding for exactly that reason. The walker, on the other hand, has the class files open: a + * supertype is `ClassNode.superName` and `ClassNode.interfaces`, one already-cached read per link. + * + * ### Which direction is carried, and why that is the cheap one + * + * The **value's** ancestry, not the slot's subtypes. A `Painter` slot accepts every `Painter` that + * will ever exist, which is not knowable from one walk; a produced value has one finite chain. So + * the question is asked once per declared *return type that crosses as a handle*, and nowhere else. + * + * ### And what it costs + * + * Measured rather than asserted to be small: see + * [theAncestryTheTableCarriesIsMeasuredRatherThanAssumed], which prints the number of entries that + * gain anything, the characters added, and the longest chain. + */ +class ReturnSupertypeTest { + + private val caches: File? = listOf( + File(System.getProperty("user.home"), ".gradle/caches/modules-2/files-2.1"), + File("/Volumes/macMini/caches/.gradle/caches/modules-2/files-2.1"), + ).firstOrNull { it.isDirectory } + + private fun jarUnder(group: String, artifact: String): File? = caches?.resolve(group)?.resolve(artifact) + ?.walkTopDown()?.firstOrNull { it.isFile && it.name.endsWith(".jar") && "sources" !in it.name } + + private val kotlinStdlibJar: File + get() = File(Class.forName("kotlin.text.Regex").protectionDomain.codeSource.location.toURI()) + + private fun composeClasspath(): List = listOfNotNull( + jarUnder("org.jetbrains.compose.material3", "material3-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-unit-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-text-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-geometry-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-util-desktop"), + jarUnder("org.jetbrains.compose.runtime", "runtime-desktop"), + jarUnder("org.jetbrains.compose.collection-internal", "collection-desktop"), + jarUnder("org.jetbrains.compose.annotation-internal", "annotation-desktop"), + kotlinStdlibJar, + ) + + /** + * The row the fixture's render test turns on: `BitmapPainter` **is a** `Painter`, taken out of + * the jar rather than written down here. + * + * The negative half is in the same test on purpose. `ImageBitmap` is what a `BitmapPainter` is + * built *from* and is not a `Painter`, so a rule that had said "any object fills any object slot" + * would pass the first assertion and fail the second -- and `Icon`, which overloads on exactly + * these two types, would then match both candidates and refuse for ambiguity instead. + */ + @Test + fun aBoundDeclarationCarriesWhatItsReturnTypeIsA() { + val graphics = jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop") ?: return + val entries = ArtifactScanner.scanJar( + graphics, + includePrefixes = listOf("androidx.compose.ui.graphics"), + classpath = composeClasspath(), + ) + // BitmapPainter's constructor is not bound (only value class constructors are), so its + // top-level factory function survives without ambiguity and carries return ancestry. + val painter = entries.first { it.name == "androidx.compose.ui.graphics.painter.BitmapPainter" } + assertEquals("androidx.compose.ui.graphics.painter.BitmapPainter", painter.returnTypeName) + assertEquals(listOf("androidx.compose.ui.graphics.painter.Painter"), painter.returnSupertypes) + + val bitmap = entries.first { it.name == "androidx.compose.ui.graphics.ImageBitmap" } + assertEquals("androidx.compose.ui.graphics.ImageBitmap", bitmap.returnTypeName) + assertTrue( + "androidx.compose.ui.graphics.painter.Painter" !in bitmap.returnSupertypes, + "an ImageBitmap must not claim to be a Painter: ${bitmap.returnSupertypes}", + ) + } + + /** + * The same ancestry on the declaration model, which is what the stub generator reads: a stub class + * extends what the binding layer's `_is_a` would accept it for (issue #31). One walk, so the two + * cannot disagree. + */ + @Test + fun theDeclarationModelCarriesTheSameAncestryAsTheBinding() { + val graphics = jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop") ?: return + val declarations = ArtifactScanner.scanDeclarations( + graphics, + includePrefixes = listOf("androidx.compose.ui.graphics"), + classpath = composeClasspath(), + ) + val painter = declarations.first { it.bindingName == "androidx.compose.ui.graphics.painter.BitmapPainter" } + assertEquals(listOf("androidx.compose.ui.graphics.painter.Painter"), painter.returnSupertypes) + } + + /** + * A return that does **not** cross as a handle carries no ancestry, because nothing could read + * one: a `Dp` result is a raw `FLOAT` with no identity to hang it on, and a `Boolean` even less. + * Asked only where it can be answered is also the whole of why the cost below is what it is. + */ + @Test + fun aReturnThatIsNotAHandleCarriesNoAncestry() { + val unit = jarUnder("org.jetbrains.compose.ui", "ui-unit-desktop") ?: return + val entries = ArtifactScanner.scanJar( + unit, + includePrefixes = listOf("androidx.compose.ui.unit"), + classpath = composeClasspath(), + ) + val nonHandles = entries.filter { it.returnTag != "OBJECT" } + assertTrue(nonHandles.isNotEmpty(), "expected some declaration to return a value rather than a handle") + assertEquals( + emptyList(), + nonHandles.filter { it.returnSupertypes.isNotEmpty() }.map { it.name to it.returnSupertypes }, + "a non-handle return carried an ancestry nothing can read", + ) + } + + /** + * **The price.** Over the four packages `:ksp-fixtures:compose` actually walks, how many entries + * gain anything, how many characters that is against the rendered fragments, and how long the + * longest chain gets. + * + * Printed rather than pinned to a literal -- they are facts about a Compose version -- with + * bounds loose enough to survive one and tight enough to catch the shape going wrong: a chain + * that ran away, or an ancestry attached to everything. + */ + @Test + fun theAncestryTheTableCarriesIsMeasuredRatherThanAssumed() { + val jars = listOfNotNull( + jarUnder("org.jetbrains.compose.material3", "material3-desktop"), + jarUnder("org.jetbrains.compose.foundation", "foundation-layout-desktop"), + jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop"), + ) + if (jars.size < 3) return + val classpath = composeClasspath() + val entries = jars.flatMap { jar -> + ArtifactScanner.scanJar( + jar, + includePrefixes = listOf( + "androidx.compose.material3", + "androidx.compose.foundation.layout", + "androidx.compose.ui.graphics", + "androidx.compose.ui.res", + ), + classpath = classpath, + ) + } + assertTrue(entries.size > 200, "expected a substantial walk; got ${entries.size}") + + val carrying = entries.filter { it.returnSupertypes.isNotEmpty() } + val addedChars = carrying.sumOf { entry -> + entry.returnSupertypes.sumOf { it.length + SUPERTYPE_SEPARATOR.length } + } + val rendered = renderArtifactFragmentSource(ArtifactFragment("Measured", "artifact:measured", entries)).length + val longest = carrying.maxOfOrNull { it.returnSupertypes.size } ?: 0 + println( + "return ancestry: ${carrying.size} of ${entries.size} entries carry one, " + + "+$addedChars characters against $rendered rendered " + + "(${"%.3f".format(100.0 * addedChars / rendered)}%), longest chain $longest", + ) + + assertTrue(carrying.isNotEmpty(), "nothing carries an ancestry, so this measures nothing") + assertTrue( + carrying.size * 4 < entries.size, + "${carrying.size} of ${entries.size} entries carry an ancestry -- it is being asked too widely", + ) + assertTrue(longest in 1..8, "longest chain $longest") + assertTrue(addedChars * 100 < rendered, "the ancestry added $addedChars characters to $rendered") + } + + /** + * The two halves of the string travel together and come apart again exactly where they were + * joined. `<:` is the separator because neither character is legal in a Kotlin fully-qualified + * name -- the same property `${'$'}composer` and `@Composable` rely on -- so a name that + * contained one would be a name no Kotlin declaration can have. + */ + @Test + fun theRenderedNameJoinsTheDeclaredTypeToItsAncestryAndNothingElse() { + val plain = ArtifactCallable( + name = "p.q", arity = 0, paramTags = emptyList(), returnTag = "OBJECT", lambdaBody = "{ x() }", + returnTypeName = "p.A", + ) + assertEquals("p.A", renderedReturnTypeName(plain)) + assertEquals( + "p.A<:p.B<:p.C", + renderedReturnTypeName(plain.copy(returnSupertypes = listOf("p.B", "p.C"))), + ) + assertEquals(null, renderedReturnTypeName(plain.copy(returnTypeName = null))) + // No Kotlin fully-qualified name can contain either character, so the grammar cannot be + // ambiguous -- checked against every name this walk produced rather than argued. + val graphics = jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop") ?: return + val names = ArtifactScanner.scanJar( + graphics, + includePrefixes = listOf("androidx.compose.ui.graphics"), + classpath = composeClasspath(), + ).flatMap { listOfNotNull(it.returnTypeName) + it.returnSupertypes } + assertEquals( + emptyList(), + names.filter { '<' in it || ':' in it }, + "a declared type name contains the separator's characters", + ) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/TextStateBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/TextStateBindingTest.kt new file mode 100644 index 000000000..70a4f49d0 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/TextStateBindingTest.kt @@ -0,0 +1,190 @@ +package python.multiplatform.gradle.artifact + +import python.multiplatform.gradle.stubs.renderKotlinFqnStubs +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Issue #73 over `TextStateFixtures.kt`: why `androidx.compose.foundation.text.input.TextFieldState` was + * stubbed as a class with no members and no way to make one, reduced to the two walker rules that + * caused it. + * + * 1. **A constructor with a value-class parameter was dropped without a word.** `kotlinc` compiles it to + * a JVM-`private` `` behind a public `ACC_SYNTHETIC` bridge ending in + * `DefaultConstructorMarker`, and that bridge is the descriptor `@Metadata` records. The walker's + * check for `@Deprecated(level = HIDDEN)` constructors refused every synthetic ``, so it + * refused this one too -- with no model, so nothing said so. `javap -v` on foundation-desktop + * 1.11.1's `TextFieldState`: `(Ljava/lang/String;JLkotlin/jvm/internal/DefaultConstructorMarker;)V`, + * `ACC_PUBLIC, ACC_SYNTHETIC`, no annotation. + * 2. **A `CharSequence` result had no boundary type.** `kotlin.CharSequence` is a built-in with no class + * file, so the object-handle case could not name it and `TextFieldState.text` was declined + * ("no boundary type for property type kotlin.CharSequence"). + * + * (The third cause, that `:ksp-fixtures:compose` did not walk `androidx.compose.foundation.text.input` + * at all, is configuration, and `TextFieldStateRenderTest` there is its test.) + * + * ### Red before the implementation + * + * Red: [aConstructorWithAValueClassParameterBinds], [aHiddenValueClassConstructorStaysOutAndItsVisibleSiblingKeepsTheBareName] + * (no `Legacy` entry at all), [aCharSequencePropertyIsReadAsAStr], [aCharSequenceFunctionResultIsAStr] and + * [theStubGivesTheClassItsConstructorAndItsTextProperty] -- each `entry(...)` fails with "is not + * bound". Already green before, and kept as guards: [theExtensionsOnTheStateBindAsMethodsOfIt], + * [aCharSequenceParameterIsStillDeclined], and the `Plain` half of the hidden-constructor test. + */ +class TextStateBindingTest { + + private val pkg = "fixture.artifacttextstate" + + private val fixtureClasses: File + get() { + val location = Class.forName("$pkg.Editable").protectionDomain.codeSource.location + return File(location.toURI()).also { assertTrue(it.isDirectory, "expected a directory of .class files: $it") } + } + + private val entries by lazy { ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf(pkg)) } + + private val declarations by lazy { ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf(pkg)) } + + private fun entry(name: String): ArtifactCallable = + assertNotNull(entries.singleOrNull { it.name == name }, "$name is not bound; bound: ${entries.map { it.name }}") + + // ------------------------------------------------------------------------ the constructor + + /** `TextFieldState(initialText = "", initialSelection = TextRange(...))`'s shape. */ + @Test + fun aConstructorWithAValueClassParameterBinds() { + val editable = entry("$pkg.Editable") + assertEquals(2, editable.arity) + assertEquals(listOf("initialText", "initialSelection"), editable.paramNames) + assertEquals(listOf("kotlin.String", "$pkg.Span"), editable.paramTypeNames) + // `Span` cannot be built from a number (its constructor is internal): it crosses as a handle. + assertEquals(listOf("STRING", "OBJECT"), editable.paramTags) + assertEquals("OBJECT", editable.returnTag) + assertEquals("$pkg.Editable", editable.returnTypeName) + // Both defaults are Python's to leave out (`TextFieldState()` from Python). + assertEquals(listOf(true, true), editable.paramHasDefault) + // Kotlin source calling the constructor by its Kotlin name -- never the bridge, never ``. + assertTrue("$pkg.Editable((args[0] as String), (args[1] as $pkg.Span))" in editable.lambdaBody, editable.lambdaBody) + assertFalse("DefaultConstructorMarker" in editable.lambdaBody, editable.lambdaBody) + + val model = declarations.single { it.owner == pkg && it.simpleName == "Editable" } + assertEquals("$pkg.Editable", model.bindingName, model.toString()) + assertNull(model.declineReason, model.toString()) + } + + /** + * `PointerInputChange`'s and `TextStyle`'s shape. The hidden constructor's bridge carries + * `kotlin.Deprecated(level = HIDDEN)` and must stay out -- if it did not, the two would share a name + * and both would be suffixed (`Legacy__Span`, `Legacy__Span_Boolean`), and the second would not + * compile. `Plain` is the hidden constructor with no bridge, out before and after. + */ + @Test + fun aHiddenValueClassConstructorStaysOutAndItsVisibleSiblingKeepsTheBareName() { + val names = entries.map { it.name } + assertEquals(1, entry("$pkg.Legacy").arity) + assertTrue(names.none { it.startsWith("$pkg.Legacy__") }, names.toString()) + assertEquals(1, entry("$pkg.Plain").arity) + assertTrue(names.none { it.startsWith("$pkg.Plain__") }, names.toString()) + } + + // ---------------------------------------------------------------------- CharSequence results + + /** `TextFieldState.text`: read as Kotlin's own `toString()` of it, which crosses as `str`. */ + @Test + fun aCharSequencePropertyIsReadAsAStr() { + val text = entry("$pkg.Editable.text") + assertEquals("GETTER", text.kind) + assertEquals("STRING", text.returnTag) + assertEquals("kotlin.CharSequence", text.returnTypeName) + assertEquals("{ args -> ((args[0] as $pkg.Editable).text).toString() }", text.lambdaBody) + + val draft = entry("$pkg.Editable.draft") + assertEquals("STRING", draft.returnTag) + assertEquals("{ args -> ((args[0] as $pkg.Editable).draft)?.toString() }", draft.lambdaBody, "null must stay null") + assertTrue(entries.none { it.name == "$pkg.Editable.text=" }, "a val has no setter") + } + + @Test + fun aCharSequenceFunctionResultIsAStr() { + val snapshot = entry("$pkg.snapshot") + assertEquals("STRING", snapshot.returnTag) + assertEquals("kotlin.CharSequence", snapshot.returnTypeName) + assertEquals( + "{ args -> ((args[0] as $pkg.Editable).artifact_ext_fixture_artifacttextstate_snapshot()).toString() }", + snapshot.lambdaBody, + ) + } + + /** Only a declared *result* is converted (issue #73's scope): a parameter has no class to cast to. */ + @Test + fun aCharSequenceParameterIsStillDeclined() { + assertTrue(entries.none { it.name == "$pkg.lengthOf" }, entries.map { it.name }.toString()) + val lengthOf = declarations.single { it.simpleName == "lengthOf" } + assertNull(lengthOf.bindingName) + val reason = assertNotNull(lengthOf.declineReason) + assertTrue("kotlin.CharSequence" in reason, reason) + } + + // ------------------------------------------------------------------------- the extensions + + /** `setTextAndPlaceCursorAtEnd(String)` and `clearText()`'s shapes: methods of the state's proxy. */ + @Test + fun theExtensionsOnTheStateBindAsMethodsOfIt() { + val replaceAll = entry("$pkg.replaceAll") + assertEquals("$pkg.Editable", replaceAll.receiverTypeName) + assertEquals(listOf("OBJECT", "STRING"), replaceAll.paramTags) + assertEquals("$pkg.Editable", entry("$pkg.clear").receiverTypeName) + } + + // -------------------------------------------------------------------------------- the stub + + /** + * What pythonx-compose reads: the class keeps its stub, with the constructor as its `__init__` and + * `text` as a `str` property, instead of a module function `Editable(...) -> Any` that takes the + * class's name and leaves its properties a comment. + */ + @Test + fun theStubGivesTheClassItsConstructorAndItsTextProperty() { + entry("$pkg.Editable") + val stub = renderKotlinFqnStubs(declarations).getValue("fixture/artifacttextstate/__init__.pyi") + assertTrue("class Editable:" in stub, stub) + assertTrue(" def __init__(self, initialText: str = ..., initialSelection: Span = ...) -> None:" in stub, stub) + assertTrue(" @property\n def text(self) -> str:" in stub, stub) + assertTrue(" @property\n def draft(self) -> str | None:" in stub, stub) + assertTrue(" replaceAll: _t.ClassVar[" in stub, stub) + assertFalse(Regex("^def Editable\\(", RegexOption.MULTILINE).containsMatchIn(stub), "the constructor took the class's name: $stub") + assertTrue("class Legacy:" in stub && " def __init__(self, span: Span) -> None:" in stub, stub) + // The table key `fixture.artifacttextstate.Editable` has no module-level `def`; the marker is where + // `WalkedArtifactStubTest`'s "every table key is stubbed" finds it. + assertTrue("\"\"\"Kotlin constructor: $pkg.Editable (" in stub, stub) + } + + /** + * A value class the boundary opens (`Meters(value: Double)`, bound as `FLOAT`) is built and returned + * as its raw number, so its constructor must stay a module function returning that number: an + * `__init__` would promise a `Meters` instance the runtime never gives. Found by + * `:ksp-fixtures:artifact`'s `everyStubbedFunctionIsATableKeyAndEveryTableKeyIsStubbed` reporting + * `fixture.valueclass.Meters` as "callable but not stubbed" after the first version of this change. + */ + @Test + fun aValueClassConstructorThatReturnsItsPrimitiveStaysAModuleFunction() { + val double = python.multiplatform.gradle.model.KotlinTypeModel("kotlin.Double") + val meters = python.multiplatform.gradle.model.KotlinTypeModel( + "fixture.vc.Meters", + valueClass = python.multiplatform.gradle.model.ValueClassModel(double, constructorIsPublic = true, propertyIsPublic = true), + ) + val constructor = python.multiplatform.gradle.model.DeclarationModel( + simpleName = "Meters", owner = "fixture.vc", ownerIsClass = false, receiver = null, + parameters = listOf(python.multiplatform.gradle.model.DeclaredParameter("value", double, declaresDefault = false, boundaryTag = "FLOAT")), + returnType = meters, returnBoundaryTag = "FLOAT", bindingName = "fixture.vc.Meters", isConstructor = true, + ) + val stub = renderKotlinFqnStubs(listOf(constructor)).getValue("fixture/vc/__init__.pyi") + assertTrue(Regex("^def Meters\\(value: float\\) -> float:", RegexOption.MULTILINE).containsMatchIn(stub), stub) + assertFalse("__init__" in stub, stub) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/defaultsfixture/DefaultFixtures.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/defaultsfixture/DefaultFixtures.kt new file mode 100644 index 000000000..80dc338d4 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/defaultsfixture/DefaultFixtures.kt @@ -0,0 +1,91 @@ +// Fixture, not production source: compiled as part of this module's own test sourceSet so that +// `DefaultOmissionTest` can locate it on disk (via `Class.forName(...).protectionDomain.codeSource`, +// exactly as `ArtifactScannerTest` already does for `kotlin-stdlib` and for the value-class fixture) +// and walk it with ASM as if it were a third-party jar. +// +// A package of its own rather than more declarations in `fixture.artifactvalueclass`: several of +// `ArtifactScannerTest`'s and `DeclarationModelTest`'s assertions pin that package's *whole* output +// as an exact list, and adding a declaration there would fail them for no reason connected to +// defaults. +package fixture.artifactdefaults + +/** An ordinary class, so that a declaration here can have an OBJECT receiver the way + * `androidx.compose.ui.Modifier` does. */ +class Bag(val text: String) + +/** + * One required parameter and one defaulted one -- the shape the judgement rests on: a Python caller + * must be able to write `greet("hi")` and reach the Kotlin default for `punctuation`. + */ +fun greet(name: String, punctuation: String = "!"): String = name + punctuation + +/** + * Two defaulted parameters, so that the *middle* of the list can be the one omitted. + * `docs/archive/pythonx-adapter-design.md` §4.5 names exactly this as what an arity-prefix scheme cannot + * express: "pass `text` and `font_size`, skip `modifier` and `color`". + */ +fun label(bag: Bag, prefix: String = "[", suffix: String = "]"): String = prefix + bag.text + suffix + +/** The `Modifier.padding(horizontal =, vertical =)` shape: an extension whose receiver and return + * are the same ordinary class, with every value parameter defaulted. */ +fun Bag.padded(horizontal: Double = 1.0, vertical: Double = 2.0): Bag = + Bag(text + "|h=" + horizontal + ",v=" + vertical) + +/** + * Seven defaulted parameters: one past `ArtifactScanner.MAX_OMITTABLE_PARAMETERS`. + * + * The cap exists because presence branching costs one generated call expression per subset of the + * defaulted parameters, and 2^n is a build-time cost paid on every build. Nothing in the measured + * corpus reaches it (the widest bound declaration anywhere in `foundation-layout`, `kotlin-stdlib` + * and JUnit 4 declares four defaults), so the cap needs a fixture of its own to fire at all. + */ +@Suppress("LongParameterList") +fun wide( + a: Int = 1, + b: Int = 2, + c: Int = 3, + d: Int = 4, + e: Int = 5, + f: Int = 6, + g: Int = 7, +): Int = a + b + c + d + e + f + g + +/** Exactly at the cap, so that the boundary is pinned from both sides. */ +@Suppress("LongParameterList") +fun sixWide( + a: Int = 1, + b: Int = 2, + c: Int = 3, + d: Int = 4, + e: Int = 5, + f: Int = 6, +): Int = a + b + c + d + e + f + +/** + * A **required** parameter whose name is a Kotlin hard keyword, beside a defaulted one. + * + * Metadata records the name unescaped, and the partial branch has to name every argument it passes + * -- so it has to write `object = ...`, which does not parse. The generator puts the backticks back. + * The keyword parameter is the required one on purpose: if it were the defaulted one, the only + * branch that named it would be the one that omits it, and nothing would ever be written. + */ +fun keyworded(`object`: String, value: Int = 0): String = `object` + value + +/** + * Two overloads of one Kotlin name with every parameter defaulted on both, which is the shape that + * made `foundation-layout` fail to compile before [ArtifactScanner.applyDefaultOmission] existed. + * + * `WindowInsets(Dp, Dp, Dp, Dp)` and `WindowInsets(Int, Int, Int, Int)` are the real instance; this + * is the same thing in two types this module owns. A generated branch that writes **no** argument is + * `insets()`, which Kotlin reports as `Overload resolution ambiguity` -- there is nothing left to + * type-annotate, which is precisely why the tie cannot be broken. A branch that writes *one* is + * unambiguous, because the argument's type picks the overload. + */ +fun insets(left: Double = 0.0, right: Double = 0.0): String = "d:" + left + "," + right + +fun insets(left: Int = 0, right: Int = 0): String = "i:" + left + "," + right + +/** No default anywhere: its generated body must stay byte-identical to what the walker emitted + * before defaults existed, which is what keeps this change off the 43 of 70 `foundation-layout` + * entries that declare none. */ +fun plain(a: Int, b: Int): Int = a + b diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/objectfixture/ObjectFixtures.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/objectfixture/ObjectFixtures.kt new file mode 100644 index 000000000..4a00cee3d --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/objectfixture/ObjectFixtures.kt @@ -0,0 +1,38 @@ +// Fixture, not production source: compiled into this module's own test classes and walked as +// bytecode by `ObjectMemberBindingTest`, the way `defaultsfixture` is walked by `DefaultOmissionTest`. +// +// A package of its own, so that the tests which pin another fixture package's output as an exact +// list are not disturbed. +package fixture.artifactobject + +class Gap(val size: Int) + +/** The `Arrangement` shape: a constant, an overloaded function, and a function taking the object's own + * kind of value -- all *instance* methods of a singleton, which JVM bytecode cannot call statically. */ +object Spacing { + val Tight: Gap = Gap(1) + + fun spacedBy(count: Int): Gap = Gap(count) + + fun spacedBy(count: Int, label: String): Gap = Gap(count + label.length) + + fun describe(gap: Gap): String = "gap " + gap.size + + override fun toString(): String = "Spacing" +} + +/** `@JvmStatic` members are already static; they must not be bound twice. */ +object StaticSpacing { + @JvmStatic + fun twice(count: Int): Int = count * 2 +} + +/** Not an object: an ordinary class's instance methods need an instance Python cannot make here. */ +class Plain { + fun instanceMethod(count: Int): Int = count +} + +/** JVM-public, Kotlin-internal: generated source outside this module cannot name it. */ +internal object HiddenSpacing { + fun hiddenFunction(count: Int): Int = count +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/propertyfixture/PropertyFixtures.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/propertyfixture/PropertyFixtures.kt new file mode 100644 index 000000000..114e11e9d --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/propertyfixture/PropertyFixtures.kt @@ -0,0 +1,92 @@ +// Fixture, not production source: compiled with this module's tests so `PropertyBindingTest` can walk +// it as bytecode plus `@Metadata`, the same way `ValueClassFixtures.kt` is walked (issue #38). +@file:Suppress("unused", "UNCHECKED_CAST", "UNUSED_PARAMETER") + +package fixture.artifactproperty + +/** An ordinary class: a `val`, a `var`, a nullable `var`, and two properties nobody outside may name. */ +class Counter(var count: Int, val name: String) { + var note: String? = null + private var hidden: Int = 0 + internal val internalProp: Int = 1 + + /** A `var` whose setter is not public: the getter is bound, the setter is not. */ + var guarded: Int = 0 + private set +} + +/** `MutableState`'s shape: a generic `var` of the class's own unbounded type parameter. */ +class Box(var content: T) { + val size: Int get() = 1 +} + +/** A bounded class type parameter: `Owner` is not within bounds, so a property mentioning `T` + * is declined, and no setter is bound for anything (it would write through `Owner`). */ +class Bounded(var item: T) { + var plain: Int = 0 +} + +/** `State`'s shape: an interface with an abstract `val` of its unbounded type parameter. */ +interface Holder { + val held: T +} + +/** An `object`'s properties are `STATIC_GETTER`s already, never `GETTER`s. */ +object Registry { + val size: Int = 3 +} + +/** A value class the boundary opens: it reaches Python as its raw primitive, so no proxy of it exists. */ +@JvmInline +value class Grams(val amount: Double) + +// ------------------------------------------------------------------------------------- generics + +/** Unbounded: bound, with `T` read as `kotlin.Any?` and written out at the call. */ +fun identity(value: T): T = value + +/** Nothing to infer `T` from: only an explicit type argument makes the generated call compile. */ +fun emptyBox(): Box = Box(null as T) + +/** Bounded: `Any?` is not within `Comparable`, so this stays declined. */ +fun > largest(a: T, b: T): T = if (a > b) a else b + +/** Reified: the type argument is read at run time, so substituting it would change the body. */ +inline fun typeNameOf(): String = T::class.simpleName ?: "?" + +// -------------------------------------------------------------------------- extension properties + +/** `Icons.Filled.Add`'s shape: a top-level extension property on an object-handle receiver. */ +val Counter.doubled: Int get() = count * 2 + +/** A receiver that crosses as a primitive: there is no proxy of an `Int` to read it on. */ +val Int.squared: Int get() = this * this + +/** An extension `var`: its getter is bound, its setter is not (getters only, as decided). */ +var Counter.mirrored: Int + get() = count + set(value) { + count = value + } + +/** Generic: property syntax has no place to write a type argument. */ +val Box.contentOrNull: T? get() = content + +// ------------------------------------------------------------- a receiver the classpath cannot see + +/** + * `androidx.compose.ui.platform.DefaultArchitectureComponentsOwner`'s shape: a public class with a + * supertype (`org.objectweb.asm.Opcodes`, standing in for lifecycle's `ViewModelStoreOwner`) that the + * walk's classpath -- this fixture directory alone -- does not carry. Generated Kotlin reading + * `level` off it would not compile ("Cannot access ... which is a supertype of ..."), so its + * properties are declined; given a classpath that carries the supertype, they bind. + */ +class Detached : org.objectweb.asm.Opcodes { + val level: Int = 1 +} + +/** The same receiver, read through an extension property. */ +val Detached.reach: Int get() = level + +/** The same receiver, called through an extension function (#66). */ +fun Detached.probe(): Int = level diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/textstatefixture/TextStateFixtures.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/textstatefixture/TextStateFixtures.kt new file mode 100644 index 000000000..fe6509729 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/textstatefixture/TextStateFixtures.kt @@ -0,0 +1,67 @@ +// Fixture, not production source: compiled with this module's tests so `TextStateBindingTest` can walk +// it as bytecode plus `@Metadata`, the same way `PropertyFixtures.kt` is walked (issue #73). +@file:Suppress("unused", "UNUSED_PARAMETER") + +package fixture.artifacttextstate + +/** + * `androidx.compose.ui.text.TextRange`'s shape: a value class whose constructor is `internal` and whose + * wrapped value is private, so it can neither be built from a raw number nor unwrapped and crosses as + * an object handle. + */ +@JvmInline +value class Span internal constructor(private val packed: Long) + +/** `TextRange(index: Int)`'s shape: the one way outside code makes a [Span]. */ +fun spanAt(index: Int): Span = Span(index.toLong()) + +/** + * `androidx.compose.foundation.text.input.TextFieldState`'s shape: a public constructor whose second + * parameter is a value class and both of whose parameters declare a default, and a `text` read as + * `CharSequence`. + * + * The value-class parameter is the point: `kotlinc` compiles this constructor to a JVM-`private` + * `(String, long)` behind a public **synthetic** `(String, long, DefaultConstructorMarker)`, + * and the bridge is the descriptor `@Metadata` records. + */ +class Editable(initialText: String = "", initialSelection: Span = Span(initialText.length.toLong())) { + internal val buffer = StringBuilder(initialText) + + val text: CharSequence get() = buffer + + /** A nullable `CharSequence`: `null` while empty. */ + val draft: CharSequence? get() = if (buffer.isEmpty()) null else buffer +} + +/** `TextFieldState.setTextAndPlaceCursorAtEnd(String)`'s shape. */ +fun Editable.replaceAll(text: String) { + buffer.setLength(0) + buffer.append(text) +} + +/** `TextFieldState.clearText()`'s shape. */ +fun Editable.clear() { + buffer.setLength(0) +} + +/** A `CharSequence` returned by a function rather than read off a property. */ +fun Editable.snapshot(): CharSequence = text + +/** A `CharSequence` *parameter* stays declined: only a declared result is converted. */ +fun lengthOf(text: CharSequence): Int = text.length + +/** + * `PointerInputChange`'s and `TextStyle`'s shape: two constructors with a value-class parameter, one of + * them `@Deprecated(level = HIDDEN)`. Both compile to a synthetic `DefaultConstructorMarker` bridge; + * only the hidden one's carries `kotlin.Deprecated`, and Kotlin source cannot call it at all. + */ +class Legacy(val span: Span) { + @Deprecated("gone", level = DeprecationLevel.HIDDEN) + constructor(span: Span, flag: Boolean) : this(span) +} + +/** An ordinary hidden constructor with no value-class parameter: a plain synthetic ``, no bridge. */ +class Plain(val n: Int) { + @Deprecated("gone", level = DeprecationLevel.HIDDEN) + constructor(n: Int, flag: Boolean) : this(n) +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/valueclassfixture/MultiPartA.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/valueclassfixture/MultiPartA.kt new file mode 100644 index 000000000..ad5673919 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/valueclassfixture/MultiPartA.kt @@ -0,0 +1,15 @@ +// See `ValueClassFixtures.kt` for why this lives in the walker's own test sourceSet rather than a +// dependency of it. +// +// `@JvmMultifileClass` + a shared `@JvmName` across this file and `MultiPartB.kt` reproduces +// `kotlin.text`'s own shape: `kotlin.text.trimIndent` compiles onto the package-private +// `StringsKt__IndentKt`, behind the public facade `StringsKt`. Here, `partGreeting` compiles onto +// this file's own package-private part class, behind the public facade `MultiFacadeKt` -- and is +// called, from Python, under the Kotlin name `fixture.artifactvalueclass.multipart.partGreeting`, +// never under either JVM class name. +@file:JvmName("MultiFacadeKt") +@file:JvmMultifileClass + +package fixture.artifactvalueclass.multipart + +fun partGreeting(): String = "hello from part A" diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/valueclassfixture/MultiPartB.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/valueclassfixture/MultiPartB.kt new file mode 100644 index 000000000..5ce3500df --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/valueclassfixture/MultiPartB.kt @@ -0,0 +1,12 @@ +// Second part of the same multi-file facade as `MultiPartA.kt`. `shoutViaFacade` combines the two +// hard cases the walker's KDoc names -- a multi-file part *and* an extension receiver -- in the one +// shape `kotlin.text.trimIndent` actually has (`String.trimIndent()` lives on `StringsKt__IndentKt`, +// one of several parts behind the `StringsKt` facade). +@file:JvmName("MultiFacadeKt") +@file:JvmMultifileClass + +package fixture.artifactvalueclass.multipart + +fun partNumber(x: Int): Int = x * 2 + +fun String.shoutViaFacade(): String = uppercase() + "!!" diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/valueclassfixture/ValueClassFixtures.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/valueclassfixture/ValueClassFixtures.kt new file mode 100644 index 000000000..30b77d2f2 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/valueclassfixture/ValueClassFixtures.kt @@ -0,0 +1,135 @@ +// Fixture, not production source: compiled as part of this module's own test sourceSet so that +// `ArtifactScannerTest` can locate it on disk (via `Class.forName(...).protectionDomain.codeSource`, +// exactly as it already does for `kotlin-stdlib`) and walk it with ASM as if it were a third-party +// jar. It never cooperates with the walker -- that is the whole point of the fixture. +package fixture.artifactvalueclass + +/** + * A minimal `@JvmInline value class` with a *public* primary constructor and a public accessor. + * + * `kotlin.time.Duration` is the real-world case the old `-` filter was written against + * (`getInWholeSeconds-impl`), but `Duration`'s own constructor is `internal` -- there is no Kotlin + * syntax generated code outside the module could use to build one from a raw `Long`, so it cannot + * prove a *positive* round trip no matter how well the walker understands its metadata. `Meters` is + * the same shape with a constructor the walker is actually allowed to call. + */ +@JvmInline +value class Meters(val value: Double) + +/** Both parameters and the return are the value class: proves unwrap-on-the-way-in and + * wrap-on-the-way-out in the same declaration, under a name no other overload shares. */ +fun sumMeters(a: Meters, b: Meters): Meters = Meters(a.value + b.value) + +/** + * Same Kotlin name as the next declaration, unrelated JVM shape: this overload has no value-class + * parameter, so the compiler does **not** mangle its name (`addMeters(II)I`, no suffix), while the + * one below does (`addMeters-(DD)D`). Before this walker read `@Metadata`, that meant the two + * were invisible to each other: the `-` filter dropped the value-class overload outright, so the old + * code saw exactly one `addMeters` candidate and bound it -- silently wrong, because Python + * `addMeters(1, 2)` would have called an overload chosen by nothing more principled than which one + * survived a different filter. Once both resolve to the same Kotlin name, grouping must drop both, + * the same as `org.junit.Assert.assertEquals`. + */ +fun addMeters(a: Int, b: Int): Int = a + b + +fun addMeters(a: Meters, b: Meters): Meters = Meters(a.value + b.value) + +/** An extension function whose *receiver* is the value class, not just a parameter -- the shape + * `Modifier.padding(Dp)` has by half (the receiver `Modifier` is not a value class there, but the + * parameter is; here it is the receiver that needs unwrap-on-the-way-in and the return that needs + * wrap-on-the-way-out). */ +fun Meters.doubled(): Meters = Meters(value * 2) + +/** An extension with an ordinary (non-value-class) receiver -- the same shape as + * `kotlin.text.trimIndent`, minus the multi-file split. */ +fun String.shout(): String = uppercase() + "!" + +/** Never reaches the table: a `suspend` function's JVM shape takes a trailing `Continuation` + * parameter, which [boundaryTypeOf] declines -- but this pins that the walker's *metadata* layer + * also recognises and skips it explicitly, per AGENTS.md's "제외한 것은 조용히 빠뜨리지 마라". */ +suspend fun neverBound(): Int = 1 + +/** `internal` is JVM-`public` (Kotlin does not always mangle a top-level `internal` name), so only + * `@Metadata`'s own visibility -- not the JVM access flag every other filter here already checks -- + * can tell this apart from [sumMeters]. */ +internal fun secretlyInternal(): Int = 42 + +/** + * An ordinary public class: neither a primitive nor a value class, and therefore the shape + * `androidx.compose.ui.Modifier` has. Nothing about it can be erased to something the boundary + * already carries, so it can only cross as an opaque handle + * (`python.multiplatform.reflection.HandleTable`). + */ +class Rope(val length: Double) + +/** OBJECT in return position: Python receives a handle it did not previously hold. */ +fun makeRope(length: Double): Rope = Rope(length) + +/** OBJECT in parameter position: the handle Python holds resolves back to the same instance. */ +fun ropeLength(rope: Rope): Double = rope.length + +/** + * `Modifier.padding(Dp): Modifier` reproduced with types this module controls: an extension whose + * receiver **and** return are the same ordinary class, which is what makes chaining possible at all. + */ +fun Rope.lengthened(by: Double): Rope = Rope(length + by) + +/** Two overloads whose *value* parameter lists are identical and whose receivers are not -- the + * case `Int.times`/`Double.times` has in `androidx.compose.ui.unit`. Disambiguating on value + * parameters alone cannot separate these, so the receiver has to join the name. */ +fun Rope.tagged(): String = "rope" + +fun Meters.tagged(): String = "meters" + +/** + * A function-typed parameter: the shape 44 of the 46 declined public top-level `Modifier` extensions + * had (`ArtifactScannerTest.composeModifierExtensionsSurviveBothGates` counts them), and the one + * `ArtifactScanner.functionSlotOrNull` opened. + * + * This used to be *the* declined witness: `resolveKotlinType` refused it because Kotlin's built-in + * `Function0` has no class file to name, and `DeclarationModelTest` used it for the other half of + * `docs/design/pyi-generation-design.md` §2.2's third property. It binds now, so the declined witness moved + * to [withSuspendCallback] and this one records the positive: a declaration that **invokes** what it + * is handed, which is what `:ksp-fixtures:artifact` exercises against `kotlin.system.measureTimeMillis` + * for real. + */ +fun withCallback(action: () -> Unit): Int { + action() + return 1 +} + +/** + * The function-typed shape that is still declined, and not for want of a *name*: its slot is + * describable and its arity checks out. + * + * A `suspend` lambda's compiled type is `Function1, Any?>`, it answers + * `COROUTINE_SUSPENDED` rather than a value, and Kotlin will not let source assign a `Function1` to a + * `suspend` function type at all. `Modifier.pointerInput` is the real-world case -- the one + * `a179b747`'s open note named beside `clickable`, and the one that turns out to be a different limit + * from the one that note was about. + */ +fun withSuspendCallback(action: suspend () -> Unit): Int = if (action === action) 1 else 0 + +/** + * `kotlin.time.Duration.getInWholeSeconds-impl(J)J`'s exact shape: a value class with a *public* + * constructor and accessor (unlike `Duration`'s own `internal` one), but where the mangled method is + * a true member declared inside the class body rather than a top-level extension declared elsewhere. + * `doubled` compiles onto `Seconds` itself as `getDoubled-impl(J)J` -- one JVM parameter (the unboxed + * receiver), zero declared Kotlin parameters -- which is what makes it decline even though nothing + * about its *visibility* would. + */ +@JvmInline +value class Seconds(val raw: Long) { + val doubled: Long get() = raw * 2 +} + +interface Alignment { + companion object { + val Center: Alignment = object : Alignment {} + val TopStart: Alignment = object : Alignment {} + } +} + +object AbsoluteAlignment { + val TopLeft: Alignment = object : Alignment {} +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/CompanionFactoryStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/CompanionFactoryStubTest.kt new file mode 100644 index 000000000..9d70c1518 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/CompanionFactoryStubTest.kt @@ -0,0 +1,105 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import python.multiplatform.gradle.model.ValueClassModel +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #78: `TextRange(2)` and `TextRange.Zero`. + * + * Kotlin has a top-level factory `TextRange(index: Int)` (bound as the overload set `TextRange__Int`, + * `TextRange__Int_Int`) and a companion constant `TextRange.Zero` (bound under the package + * `androidx.compose.ui.text.TextRange`). At run time the module `...text.TextRange` is callable. The + * stub of the parent package has to say both: one attribute `TextRange` whose type has `__call__` + * (the overload set) and `Zero` -- a bare `def TextRange` hides `TextRange.Zero` from a checker. + * + * Red before the fix: the parent stub has `@overload def TextRange(...)` and no `Zero` anywhere in it. + */ +class CompanionFactoryStubTest { + + private val pkg = "androidx.compose.ui.text" + private val range = KotlinTypeModel( + "$pkg.TextRange", + valueClass = ValueClassModel(KotlinTypeModel("kotlin.Long"), constructorIsPublic = false, propertyIsPublic = true), + ) + private val int = KotlinTypeModel("kotlin.Int") + + private fun factory(binding: String, vararg names: String) = DeclarationModel( + simpleName = "TextRange", + owner = pkg, + ownerIsClass = false, + receiver = null, + parameters = names.map { DeclaredParameter(it, int, false, "INT") }, + returnType = range, + returnBoundaryTag = "OBJECT", + bindingName = binding, + ) + + private val zero = DeclarationModel( + simpleName = "Zero", + owner = "$pkg.TextRange", + ownerIsClass = true, + receiver = null, + parameters = emptyList(), + returnType = range, + returnBoundaryTag = "OBJECT", + bindingName = "$pkg.TextRange.Zero", + kind = "STATIC_GETTER", + ) + + private val declarations = listOf( + factory("$pkg.TextRange__Int", "index"), + factory("$pkg.TextRange__Int_Int", "start", "end"), + zero, + ) + + private fun parentStub() = renderKotlinFqnStubs(declarations).getValue("androidx/compose/ui/text/__init__.pyi") + + @Test + fun theFactoryNameIsOneAttributeWhoseTypeIsCallable() { + val text = parentStub() + assertTrue(Regex("""^TextRange: _TextRange_callable_module$""", RegexOption.MULTILINE).containsMatchIn(text), text) + assertTrue("def __call__(self, index: int)" in text, text) + assertTrue("def __call__(self, start: int, end: int)" in text, text) + assertTrue("@_t.overload" in text, text) + } + + @Test + fun theCompanionConstantIsAMemberOfThatSameType() { + val text = parentStub() + val protocol = text.substringAfter("class _TextRange_callable_module").substringBefore("\nTextRange:") + assertTrue(Regex("""^ Zero: """, RegexOption.MULTILINE).containsMatchIn(protocol), text) + } + + @Test + fun noBareDefOfTheBaseNameRemains() { + val text = parentStub() + assertFalse(Regex("""^def TextRange\(""", RegexOption.MULTILINE).containsMatchIn(text), text) + assertFalse(Regex("""^def TextRange:""", RegexOption.MULTILINE).containsMatchIn(text), text) + } + + @Test + fun theExplicitTableKeySpellingsStayModuleFunctions() { + val text = parentStub() + assertTrue(Regex("""^def TextRange__Int\(index: int\)""", RegexOption.MULTILINE).containsMatchIn(text), text) + assertTrue(Regex("""^def TextRange__Int_Int\(""", RegexOption.MULTILINE).containsMatchIn(text), text) + } + + @Test + fun theModulesOwnStubStillExistsForAnExplicitImport() { + val files = renderKotlinFqnStubs(declarations) + assertTrue("androidx/compose/ui/text/TextRange/__init__.pyi" in files, files.keys.toString()) + } + + @Test + fun aFunctionWithNoModuleOfItsNameStaysAPlainOverloadSet() { + val plain = renderKotlinFqnStubs(declarations.filter { it.kind != "STATIC_GETTER" }) + .getValue("androidx/compose/ui/text/__init__.pyi") + assertTrue(Regex("""^@_t\.overload\ndef TextRange\(""", RegexOption.MULTILINE).containsMatchIn(plain), plain) + assertFalse("_callable_module" in plain, plain) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/KotlinNamesOnlyStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/KotlinNamesOnlyStubTest.kt new file mode 100644 index 000000000..9269df548 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/KotlinNamesOnlyStubTest.kt @@ -0,0 +1,123 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * AGENTS.md section 12 rule 1: the binder never exports a Kotlin namespace under another name, and has + * no feature that turns `androidx` into `pythonx`. Pythonic renaming is the `pythonx-compose` + * package's job. + * + * These tests look at everything the generator can emit for a set of androidx declarations, not at + * one function, so a renaming feature added anywhere in the stub pipeline fails them. + */ +class KotlinNamesOnlyStubTest { + + private val modifier = KotlinTypeModel("androidx.compose.ui.Modifier") + private val float = KotlinTypeModel("kotlin.Float") + + private fun decl( + name: String, + owner: String = "androidx.compose.foundation.layout", + parameters: List = emptyList(), + binding: String = "$owner.$name", + ) = DeclarationModel( + simpleName = name, + owner = owner, + ownerIsClass = false, + receiver = modifier, + receiverBoundaryTag = "OBJECT", + parameters = parameters, + returnType = modifier, + returnBoundaryTag = "OBJECT", + bindingName = binding, + ) + + private fun param(name: String?, default: Boolean = false) = DeclaredParameter(name, float, default, "FLOAT") + + private val declarations = listOf( + decl("fillMaxWidth", parameters = listOf(param("fraction", default = true))), + decl("windowInsetsPadding", parameters = listOf(param("windowInsets"))), + ) + + /** Every file the stub pipeline emits for [declarations]: `PythonStubsTask` writes exactly this. */ + private fun everythingEmitted(): Map = renderKotlinFqnStubs(declarations) + + @Test + fun theStubLivesUnderTheKotlinModulePathAndNothingLivesUnderPythonx() { + val files = everythingEmitted() + assertTrue("androidx/compose/foundation/layout/__init__.pyi" in files, files.keys.toString()) + val renamed = files.keys.filter { it.startsWith("pythonx/") } + assertEquals(emptyList(), renamed, "the binder must not emit a pythonx namespace") + assertFalse(files.keys.any { it.endsWith("py.typed") || it.endsWith("_pm_dispatch.json") }, files.keys.toString()) + } + + @Test + fun kotlinNamesAreNeverSnakeCased() { + val all = everythingEmitted().values.joinToString("\n") + assertTrue("def fillMaxWidth(" in all, all) + assertTrue("fraction: float" in all, all) + assertFalse("fill_max_width" in all, all) + assertFalse("window_insets" in all, all) + } + + @Test + fun theStubNeverMentionsAPythonxModule() { + val files = everythingEmitted() + val all = files.keys.joinToString("\n") + "\n" + files.values.joinToString("\n") + assertFalse("pythonx" in all, all) + } + + // ------------------------------------------------- signatures: keyword names and defaults + + private fun body(vararg d: DeclarationModel) = + renderKotlinFqnStubs(d.toList()).getValue("androidx/compose/foundation/layout/__init__.pyi") + + /** The receiver is positional-only; declared parameters are positional-or-keyword by their Kotlin + * name, and a defaulted one carries `= ...`. */ + @Test + fun declaredParametersAreKeywordCapableByTheirKotlinNameAndDefaultsAreMarked() { + val b = body(decl("fillMaxWidth", parameters = listOf(param("fraction", default = true)))) + assertTrue("def fillMaxWidth(receiver: androidx.compose.ui.Modifier, /, fraction: float = ...) -> androidx.compose.ui.Modifier:" in b, b) + } + + @Test + fun aRequiredParameterHasNoDefaultMarker() { + val b = body(decl("windowInsetsPadding", parameters = listOf(param("windowInsets")))) + assertTrue("def windowInsetsPadding(receiver: androidx.compose.ui.Modifier, /, windowInsets: float) -> androidx.compose.ui.Modifier:" in b, b) + } + + /** `in` is a Python keyword: it cannot be a keyword argument in `.pyi`, so it and everything + * before it go positional-only, and the unwritable one is spelled `__a`. No renamed + * spelling (`in_`) is invented. */ + @Test + fun aParameterNamedLikeAPythonKeywordForcesAPositionalOnlyPrefix() { + val b = body( + decl("scan", parameters = listOf(param("first"), param("in"), param("last", default = true))), + ) + assertTrue("def scan(receiver: androidx.compose.ui.Modifier, first: float, __a1: float, /, last: float = ...) -> androidx.compose.ui.Modifier:" in b, b) + assertFalse("in_" in b, b) + } + + @Test + fun anUnknownParameterNameListStaysAnonymousAndPositionalOnly() { + val b = body( + decl("assertEquals", parameters = listOf(param(null), param(null))).copy(parameterNamesKnown = false), + ) + assertTrue("def assertEquals(receiver: androidx.compose.ui.Modifier, __a0: float, __a1: float, /) -> androidx.compose.ui.Modifier:" in b, b) + } + + /** Python rejects a non-default parameter after a default one, and `inspect.signature` on the + * binder's own callable makes such a parameter keyword-only (`KotlinSurface.kt`): so does the + * stub, and the default stays marked. */ + @Test + fun aRequiredParameterAfterADefaultIsKeywordOnlyLikeTheRuntimeSignature() { + val b = body(decl("mix", parameters = listOf(param("a", default = true), param("b")))) + assertTrue("def mix(receiver: androidx.compose.ui.Modifier, /, a: float = ..., *, b: float) -> androidx.compose.ui.Modifier:" in b, b) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt new file mode 100644 index 000000000..b12bf797a --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt @@ -0,0 +1,167 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import python.multiplatform.gradle.model.ValueClassModel +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #53: what a Kotlin `object`'s stub says. + * + * An object is a Python module (`androidx.compose.ui.Alignment`) whose attributes are its constants + * and functions. Its nested types (`Alignment.Horizontal`) are **also** named under that module, and + * the renderer used to refuse them: `androidx.compose.ui.Alignment` is a module, a type cannot be + * both, so every constant's type fell back to `Any`. The nested types belong in the object's own + * module -- `Alignment.Horizontal` is `androidx/compose/ui/Alignment/__init__.pyi`'s `Horizontal`. + */ +class ObjectStubTest { + + private val pkgUi = "androidx.compose.ui" + private val pkgLayout = "androidx.compose.foundation.layout" + private val alignment = "$pkgUi.Alignment" + private val arrangement = "$pkgLayout.Arrangement" + + private val alignmentHorizontal = KotlinTypeModel("$alignment.Horizontal") + private val arrangementBoth = KotlinTypeModel("$arrangement.HorizontalOrVertical") + private val float = KotlinTypeModel("kotlin.Float") + private val dp = KotlinTypeModel( + "androidx.compose.ui.unit.Dp", + valueClass = ValueClassModel(float, constructorIsPublic = true, propertyIsPublic = true), + ) + + private fun constant(binding: String, type: KotlinTypeModel) = DeclarationModel( + simpleName = binding.substringAfterLast('.'), + owner = binding.substringBeforeLast('.'), + ownerIsClass = true, + receiver = null, + parameters = emptyList(), + returnType = type, + returnBoundaryTag = "OBJECT", + bindingName = binding, + kind = "STATIC_GETTER", + ) + + private fun function(binding: String, type: KotlinTypeModel, vararg params: DeclaredParameter) = DeclarationModel( + simpleName = binding.substringAfterLast('.').substringBefore("__"), + owner = binding.substringBeforeLast('.'), + ownerIsClass = true, + receiver = null, + parameters = params.toList(), + returnType = type, + returnBoundaryTag = "OBJECT", + bindingName = binding, + ) + + private val end = constant("$alignment.End", alignmentHorizontal) + + private fun file(files: Map, module: String) = + files.getValue(module.replace('.', '/') + "/__init__.pyi") + + // ---------------------------------------------------- constants: the declared type, not Any + + @Test + fun anObjectConstantIsAnnotatedWithItsDeclaredNestedType() { + val text = file(renderKotlinFqnStubs(listOf(end)), alignment) + assertTrue(Regex("""^End: Horizontal$""", RegexOption.MULTILINE).containsMatchIn(text), text) + assertFalse("End: _t.Any" in text, text) + } + + @Test + fun theNestedTypeIsAClassInTheObjectsOwnModule() { + val text = file(renderKotlinFqnStubs(listOf(end)), alignment) + assertTrue("class Horizontal:" in text, text) + assertTrue("Kotlin: $alignment.Horizontal" in text, text) + } + + @Test + fun anotherModuleSpellsTheNestedTypeThroughTheObjectsModule() { + val column = function( + "$pkgLayout.Column", + KotlinTypeModel("kotlin.Unit"), + DeclaredParameter("horizontalAlignment", alignmentHorizontal, true, "OBJECT"), + ) + val files = renderKotlinFqnStubs(listOf(end, column)) + val layout = file(files, pkgLayout) + assertTrue("horizontalAlignment: $alignment.Horizontal = ..." in layout, layout) + assertTrue("import $alignment\n" in layout, layout) + } + + @Test + fun theObjectItselfStaysAnyBecauseItsNameIsTheModule() { + // `Alignment.Center: Alignment` -- the type is the module's own name; no class can stand there. + val center = constant("$alignment.Center", KotlinTypeModel(alignment)) + val text = file(renderKotlinFqnStubs(listOf(center)), alignment) + assertTrue("Center: _t.Any" in text, text) + } + + @Test + fun aNestedTypeWhoseNameAnObjectMemberTakesStaysAny() { + // The module already defines `Horizontal` as a constant; a class of that name would shadow it. + val clash = constant("$alignment.Horizontal", alignmentHorizontal) + val text = file(renderKotlinFqnStubs(listOf(clash)), alignment) + assertFalse("class Horizontal:" in text, text) + assertTrue("Horizontal: _t.Any" in text, text) + } + + // ---------------------------------------------------------------- functions of an object + + @Test + fun anObjectFunctionAndItsOverloadsAreInTheObjectsStub() { + val spacedByDp = function( + "$arrangement.spacedBy__Dp", arrangementBoth, + DeclaredParameter("space", dp, false, "FLOAT"), + ) + val spacedByAligned = function( + "$arrangement.spacedBy__Dp_Horizontal", KotlinTypeModel("$arrangement.Horizontal"), + DeclaredParameter("space", dp, false, "FLOAT"), + DeclaredParameter("alignment", alignmentHorizontal, false, "OBJECT"), + ) + val start = constant("$arrangement.Start", KotlinTypeModel("$arrangement.Horizontal")) + val text = file(renderKotlinFqnStubs(listOf(spacedByDp, spacedByAligned, start)), arrangement) + assertTrue("def spacedBy__Dp(space: androidx.compose.ui.unit.Dp | float) -> HorizontalOrVertical:" in text, text) + assertTrue("def spacedBy__Dp_Horizontal(" in text, text) + // The Kotlin name reaches both, as `@overload`s in table-key order. + assertTrue("@_t.overload\ndef spacedBy(space: androidx.compose.ui.unit.Dp | float) -> HorizontalOrVertical: ..." in text, text) + assertTrue("Start: Horizontal" in text, text) + assertFalse("_t.Any" in text, text) + } + + // ------------------------------------------- an extension property on a type nested in an object + + /** + * #68: `Icons.Default.Add`. `Default` is an object constant of `Icons` typed `Icons.Filled`, and `Add` + * is an extension property declared in the package `...icons.filled` on that nested type. The stub + * class is `Filled` in the object's own module `...icons.Icons` (#53), and `Add` is its property. + * + * Not red against develop at fa2558e3: #53 already resolves the receiver into the object's module, + * and the regenerated Compose stubs (CI run 37098191598) carry `Default: Filled` and + * `Filled.Add`. The marker comment of #68 came from the stubs built before #53. This pins it. + */ + @Test + fun anExtensionPropertyOnATypeNestedInAnObjectIsAttributeOfThatTypesClass() { + val icons = "androidx.compose.material.icons.Icons" + val filled = KotlinTypeModel("$icons.Filled") + val default = constant("$icons.Default", filled) + val add = DeclarationModel( + simpleName = "Add", + owner = "androidx.compose.material.icons.filled", + ownerIsClass = false, + receiver = filled, + receiverBoundaryTag = "OBJECT", + parameters = emptyList(), + returnType = KotlinTypeModel("androidx.compose.ui.graphics.vector.ImageVector"), + returnBoundaryTag = "OBJECT", + bindingName = "androidx.compose.material.icons.filled.Add", + kind = "GETTER", + ) + val files = renderKotlinFqnStubs(listOf(default, add)) + val text = file(files, icons) + assertTrue(Regex("""^Default: Filled$""", RegexOption.MULTILINE).containsMatchIn(text), text) + assertTrue("class Filled:" in text, text) + assertTrue(" def Add(self) -> androidx.compose.ui.graphics.vector.ImageVector:" in text, text) + files.values.forEach { assertFalse("is not stubbed" in it, it) } + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PropertyStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PropertyStubTest.kt new file mode 100644 index 000000000..137a326c2 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PropertyStubTest.kt @@ -0,0 +1,137 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #38: a `GETTER`/`SETTER` is stubbed where the binding layer serves it -- as a `@property` of + * its receiver's stub class -- and never as a module attribute. + * + * Red before the implementation: the renderer treated a property row like any other, so + * `MutableState.value` became `def value()` in a module `androidx/compose/runtime/MutableState`, and + * the `Kotlin property:` marker existed nowhere. + */ +class PropertyStubTest { + + private val runtime = "androidx.compose.runtime" + private val mutableState = KotlinTypeModel("$runtime.MutableState") + private val any = KotlinTypeModel("kotlin.Any", isNullable = true) + + private val factory = DeclarationModel( + simpleName = "mutableStateOf", + owner = runtime, + ownerIsClass = false, + receiver = null, + parameters = listOf(DeclaredParameter("value", any, declaresDefault = false, boundaryTag = "OBJECT")), + returnType = mutableState, + returnBoundaryTag = "OBJECT", + bindingName = "$runtime.mutableStateOf", + ) + + private val getter = DeclarationModel( + simpleName = "value", + owner = "$runtime.MutableState", + ownerIsClass = true, + receiver = mutableState, + receiverBoundaryTag = "OBJECT", + parameters = emptyList(), + returnType = any, + returnBoundaryTag = "OBJECT", + bindingName = "$runtime.MutableState.value", + kind = "GETTER", + ) + + private val setter = getter.copy( + kind = "SETTER", + bindingName = "$runtime.MutableState.value=", + parameters = listOf(DeclaredParameter("value", any, declaresDefault = false, boundaryTag = "OBJECT")), + returnType = KotlinTypeModel("kotlin.Unit"), + returnBoundaryTag = "UNIT", + ) + + @Test + fun aVarIsAPropertyWithASetterOnItsReceiversClass() { + val files = renderKotlinFqnStubs(listOf(factory, getter, setter)) + assertEquals(setOf("androidx/compose/runtime/__init__.pyi"), files.keys, "a property made a module of its own") + val stub = files.getValue("androidx/compose/runtime/__init__.pyi") + assertTrue("class MutableState:" in stub, stub) + assertTrue(" @property\n def value(self) -> _t.Any | None:" in stub, stub) + assertTrue(" @value.setter\n def value(self, value: _t.Any | None) -> None:" in stub, stub) + assertTrue("Kotlin property: androidx.compose.runtime.MutableState.value " in stub, stub) + assertTrue("Kotlin property: androidx.compose.runtime.MutableState.value=" in stub, stub) + assertFalse(Regex("^def value", RegexOption.MULTILINE).containsMatchIn(stub), "a property became a module function") + } + + @Test + fun aValHasNoSetter() { + val stub = renderKotlinFqnStubs(listOf(factory, getter)).getValue("androidx/compose/runtime/__init__.pyi") + assertTrue("@property" in stub, stub) + assertFalse("setter" in stub, stub) + } + + /** + * An extension property is stubbed on its receiver's class too, not in the package that declares + * it -- `Icons.Filled.Add` is read off `Icons.Default`, never imported from `icons.filled`. + */ + @Test + fun anExtensionPropertyIsStubbedOnItsReceiver() { + val receiver = KotlinTypeModel("p.Counter") + val doubled = DeclarationModel( + simpleName = "doubled", + owner = "p.ext", + ownerIsClass = false, + receiver = receiver, + receiverBoundaryTag = "OBJECT", + parameters = emptyList(), + returnType = KotlinTypeModel("kotlin.Int"), + returnBoundaryTag = "INT", + bindingName = "p.ext.doubled", + kind = "GETTER", + ) + val files = renderKotlinFqnStubs(listOf(doubled)) + assertEquals(setOf("p/__init__.pyi"), files.keys) + val stub = files.getValue("p/__init__.pyi") + assertTrue("class Counter:" in stub, stub) + assertTrue(" def doubled(self) -> int:" in stub, stub) + assertTrue("Kotlin property: p.ext.doubled " in stub, stub) + } + + /** + * A receiver whose name its own module already spends on a function has no stub class to put + * the property in; the key is still accounted for, as a comment, rather than silently absent. + */ + @Test + fun aPropertyWhoseReceiverHasNoStubClassIsStillAccountedFor() { + val receiver = KotlinTypeModel("p.Counter") + val constructorLike = DeclarationModel( + simpleName = "Counter", + owner = "p", + ownerIsClass = false, + receiver = null, + parameters = emptyList(), + returnType = receiver, + returnBoundaryTag = "OBJECT", + bindingName = "p.Counter", + ) + val count = DeclarationModel( + simpleName = "count", + owner = "p.Counter", + ownerIsClass = true, + receiver = receiver, + receiverBoundaryTag = "OBJECT", + parameters = emptyList(), + returnType = KotlinTypeModel("kotlin.Int"), + returnBoundaryTag = "INT", + bindingName = "p.Counter.count", + kind = "GETTER", + ) + val stub = renderKotlinFqnStubs(listOf(constructorLike, count)).getValue("p/__init__.pyi") + assertTrue(Regex("^# Kotlin property: p\\.Counter\\.count is not stubbed", RegexOption.MULTILINE).containsMatchIn(stub), stub) + assertFalse("class Counter" in stub, stub) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PyiRenderingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PyiRenderingTest.kt new file mode 100644 index 000000000..e1efbc675 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PyiRenderingTest.kt @@ -0,0 +1,148 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import python.multiplatform.gradle.model.ValueClassModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The Kotlin-FQN stub product of `docs/design/pyi-generation-design.md` §5.3: the modules + * `PythonProxySource` injects into `sys.modules`, one `def` per table key, under the Kotlin names. + * There is no second, renamed product (AGENTS.md §12 rule 1); `KotlinNamesOnlyStubTest` pins that. + * + * The *types* say what the boundary carries, not what the declaration says. `PythonProxySource` + * marshals a `Dp` parameter as a raw float and a `Modifier` return as a `HandleTable` integer -- so + * the stub says `float` and `int`, which is what that module actually accepts and returns. + * + * Everything asserted here is generated text. + */ +class PyiRenderingTest { + + private val modifier = KotlinTypeModel("androidx.compose.ui.Modifier") + private val dp = KotlinTypeModel( + "androidx.compose.ui.unit.Dp", + valueClass = ValueClassModel(KotlinTypeModel("kotlin.Float"), constructorIsPublic = true, propertyIsPublic = true), + ) + private val paddingValues = KotlinTypeModel("androidx.compose.foundation.layout.PaddingValues") + + private fun tagOf(type: KotlinTypeModel): String = when (type.qualifiedName) { + "kotlin.Float", "kotlin.Double" -> "FLOAT" + "kotlin.Int", "kotlin.Long" -> "INT" + "kotlin.String" -> "STRING" + "kotlin.Unit" -> "UNIT" + else -> type.valueClass?.let { tagOf(it.underlying) } ?: "OBJECT" + } + + private fun padding(vararg params: Pair, binding: String) = DeclarationModel( + simpleName = "padding", + owner = "androidx.compose.foundation.layout", + ownerIsClass = false, + receiver = modifier, + receiverBoundaryTag = "OBJECT", + parameters = params.map { + DeclaredParameter(it.first, it.second, declaresDefault = params.size > 1, boundaryTag = tagOf(it.second)) + }, + returnType = modifier, + returnBoundaryTag = "OBJECT", + bindingName = binding, + ) + + private val paddingOverloads = listOf( + padding("start" to dp, "top" to dp, "end" to dp, "bottom" to dp, binding = "androidx.compose.foundation.layout.padding__Dp_Dp_Dp_Dp"), + padding("all" to dp, binding = "androidx.compose.foundation.layout.padding__Dp"), + padding("paddingValues" to paddingValues, binding = "androidx.compose.foundation.layout.padding__PaddingValues"), + padding("horizontal" to dp, "vertical" to dp, binding = "androidx.compose.foundation.layout.padding__Dp_Dp"), + ) + + // ------------------------------------------------------------------- Kotlin-FQN product + + /** + * The Kotlin-FQN stub describes exactly what `PythonProxySource.renderOne` publishes: a module + * named by everything before the last dot of the table key, and one function named by the leaf. + */ + @Test + fun theKotlinFqnProductIsOneModulePerTableKeyPrefix() { + val files = renderKotlinFqnStubs(paddingOverloads) + val body = files.getValue("androidx/compose/foundation/layout/__init__.pyi") + assertTrue("def padding__Dp(" in body, body) + assertTrue("def padding__PaddingValues(" in body, body) + // The base name of an overload set is served by the binding layer's dispatcher, so it is + // stubbed as `@overload`s beside the explicit table-key spellings (`TypedStubTest`). + assertTrue("@_t.overload\ndef padding(" in body, body) + } + + /** + * **The receiver is positional-only, declared parameters are keyword-capable by their Kotlin name, + * and the types are the boundary's.** + * + * The runtime accepts keyword arguments by Kotlin parameter name and honours Kotlin defaults, so + * `padding__Dp(m, all=16)` is a valid call and the stub says so. `Dp` marshals as a raw float and + * `Modifier` as a `HandleTable` integer, so those are the annotations. The Kotlin signature goes + * in the docstring, which is where a reader wants it and where no checker can act on it. + */ + @Test + fun kotlinFqnParametersAreKeywordCapableAndCarryTheBoundarysOwnTypes() { + val body = renderKotlinFqnStubs(paddingOverloads).getValue("androidx/compose/foundation/layout/__init__.pyi") + assertTrue("def padding__Dp(receiver: androidx.compose.ui.Modifier, /, all: androidx.compose.ui.unit.Dp | float) -> androidx.compose.ui.Modifier:" in body, body) + // `padding(start, top, end, bottom)` has four defaulted parameters. + assertTrue("def padding__Dp_Dp_Dp_Dp(receiver: androidx.compose.ui.Modifier, /, start: androidx.compose.ui.unit.Dp | float = ..., top: androidx.compose.ui.unit.Dp | float = ..., end: androidx.compose.ui.unit.Dp | float = ..., bottom: androidx.compose.ui.unit.Dp | float = ...) -> androidx.compose.ui.Modifier:" in body, body) + assertTrue( + "\"\"\"Kotlin: androidx.compose.ui.Modifier.padding(all: androidx.compose.ui.unit.Dp): " + + "androidx.compose.ui.Modifier\"\"\"" in body, + body, + ) + } + + /** A static on a class gets the class's own module, because that is the `sys.modules` entry the + * runtime creates for it: `junit.runner.Version.id` publishes onto `junit.runner.Version`. */ + @Test + fun aStaticOnAClassGetsTheClassAsItsModule() { + val id = DeclarationModel( + simpleName = "id", + owner = "junit.runner.Version", + ownerIsClass = true, + receiver = null, + parameters = emptyList(), + returnType = KotlinTypeModel("kotlin.String"), + returnBoundaryTag = "STRING", + bindingName = "junit.runner.Version.id", + ) + val files = renderKotlinFqnStubs(listOf(id)) + assertEquals(listOf("junit/runner/Version/__init__.pyi"), files.keys.toList()) + assertTrue("def id() -> str:" in files.values.single(), files.values.single()) + } + + /** §3.2: a Java declaration has no parameter names in the bytecode, so the generator invents + * none -- `__a0`, positional-only, rather than a keyword a caller could believe in. */ + @Test + fun aJavaDeclarationGetsAnonymousPositionalParameters() { + val assertEqualsDecl = DeclarationModel( + simpleName = "assertEquals", + owner = "org.junit.Assert", + ownerIsClass = true, + receiver = null, + parameters = listOf( + DeclaredParameter(null, KotlinTypeModel("kotlin.Long"), false, "INT"), + DeclaredParameter(null, KotlinTypeModel("kotlin.Long"), false, "INT"), + ), + returnType = KotlinTypeModel("kotlin.Unit"), + returnBoundaryTag = "UNIT", + bindingName = "org.junit.Assert.assertEquals__Long_Long", + parameterNamesKnown = false, + ) + val body = renderKotlinFqnStubs(listOf(assertEqualsDecl)).values.single() + assertTrue("def assertEquals__Long_Long(__a0: int, __a1: int, /) -> None:" in body, body) + } + + /** A declaration the binder declined has no table key and therefore no runtime attribute; a stub + * for it would promise a call that raises. §4.5's rule, applied generally. */ + @Test + fun aDeclinedDeclarationIsNotStubbed() { + val declined = padding("all" to dp, binding = "x").copy(bindingName = null, declineReason = "ambiguous overload group") + assertEquals(emptyMap(), renderKotlinFqnStubs(listOf(declined))) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/StubCaseLayoutTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/StubCaseLayoutTest.kt new file mode 100644 index 000000000..0ffc91cf7 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/StubCaseLayoutTest.kt @@ -0,0 +1,93 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #44: no two emitted stub paths may be equal under case folding. `androidx.compose.ui.graphics` + * has a class module `Shadow` and a package `shadow`; on macOS and Windows those are one directory. + * + * The rule: a class module whose path collides case-insensitively with a sibling (or an ancestor of + * one) is emitted inside its parent package's `__init__.pyi`, as the class, with its functions as + * static methods. Modules that do not collide keep their own `/__init__.pyi`. + */ +class StubCaseLayoutTest { + + private val graphics = "androidx.compose.ui.graphics" + private val unit = KotlinTypeModel("kotlin.Unit") + private val float = KotlinTypeModel("kotlin.Float") + + private fun fn(binding: String, vararg params: DeclaredParameter) = DeclarationModel( + simpleName = binding.substringAfterLast('.'), + owner = binding.substringBeforeLast('.'), + ownerIsClass = false, + receiver = null, + parameters = params.toList(), + returnType = unit, + returnBoundaryTag = "UNIT", + bindingName = binding, + ) + + private val blur = DeclaredParameter("radius", float, false, "FLOAT") + + /** The Compose shape: a class module `Shadow` and a package `shadow`, plus an unrelated class module. */ + private val declarations = listOf( + fn("$graphics.Shadow.create", blur), + fn("$graphics.Shadow.Companion.default"), + fn("$graphics.shadow.drawShadow", blur), + fn("$graphics.Color.fromArgb", blur), + fn("$graphics.draw"), + ) + + private val files get() = renderKotlinFqnStubs(declarations) + + /** Red against the renderer before #44: `Shadow/__init__.pyi` and `shadow/__init__.pyi` are both emitted. */ + @Test + fun noTwoEmittedPathsAreEqualUnderCasefold() { + val paths = files.keys + val clashes = paths.groupBy { it.lowercase() }.values.filter { it.size > 1 } + assertEquals(emptyList(), clashes, paths.sorted().toString()) + // Directories too: `Shadow/` without an `__init__.pyi` would still collide with `shadow/`. + val directories = paths.flatMap { path -> + val parts = path.split('/').dropLast(1) + parts.indices.map { parts.take(it + 1).joinToString("/") } + }.toSet() + assertEquals(emptyList(), directories.groupBy { it.lowercase() }.values.filter { it.size > 1 }, directories.toString()) + } + + @Test + fun theCollidingClassModuleIsTheClassInItsParentsInit() { + val parent = files.getValue("androidx/compose/ui/graphics/__init__.pyi") + assertTrue("class Shadow:" in parent, parent) + assertTrue(" @staticmethod\n def create(radius: float) -> None:" in parent, parent) + // A nested class module goes into the nested class. + assertTrue(" class Companion:" in parent, parent) + assertTrue(" @staticmethod\n def default() -> None:" in parent, parent) + assertTrue("def draw() -> None:" in parent, parent) + } + + @Test + fun thePackageKeepsItsOwnPath() { + val shadow = files.getValue("androidx/compose/ui/graphics/shadow/__init__.pyi") + assertTrue("def drawShadow(radius: float) -> None:" in shadow, shadow) + assertFalse("create" in shadow, shadow) + } + + @Test + fun aClassModuleThatDoesNotCollideKeepsItsOwnPath() { + val color = files.getValue("androidx/compose/ui/graphics/Color/__init__.pyi") + assertTrue("def fromArgb(radius: float) -> None:" in color, color) + assertFalse("class Color:" in files.getValue("androidx/compose/ui/graphics/__init__.pyi")) + } + + @Test + fun withoutACollisionNothingMoves() { + val noPackage = renderKotlinFqnStubs(declarations.filter { ".shadow." !in it.bindingName!! }) + assertTrue("androidx/compose/ui/graphics/Shadow/__init__.pyi" in noPackage.keys, noPackage.keys.toString()) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/StubKeywordNameTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/StubKeywordNameTest.kt new file mode 100644 index 000000000..9f9addcb2 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/StubKeywordNameTest.kt @@ -0,0 +1,48 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.KotlinTypeModel +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * A Kotlin declaration may be named like a Python keyword: Compose's `Shadow.None`. Written as + * `None: Shadow` it makes the whole `.pyi` a syntax error, and mypy then checks nothing in that + * module. Such a name cannot be written as an attribute in Python at all (`Shadow.None` does not + * parse), so the stub leaves it out and says how to reach it. + */ +class StubKeywordNameTest { + + private val graphics = "androidx.compose.ui.graphics" + private val shadow = KotlinTypeModel("$graphics.Shadow") + + private fun decl(binding: String, kind: String) = DeclarationModel( + simpleName = binding.substringAfterLast('.'), + owner = binding.substringBeforeLast('.'), + ownerIsClass = false, + receiver = null, + parameters = emptyList(), + returnType = shadow, + returnBoundaryTag = "OBJECT", + bindingName = binding, + kind = kind, + ) + + private val keywordLine = Regex("""^\s*(None|True|False|class|def|from|import|lambda|pass)\s*[:(]""", RegexOption.MULTILINE) + + @Test + fun aConstantNamedLikeAPythonKeywordIsNotWrittenAsAnAttribute() { + val files = renderKotlinFqnStubs(listOf(decl("$graphics.Shadow.None", "STATIC_GETTER"))) + val text = files.values.joinToString("\n") + assertFalse(keywordLine.containsMatchIn(text), text) + assertTrue("getattr" in text && "None" in text, "the stub says how to reach it:\n$text") + } + + @Test + fun aFunctionNamedLikeAPythonKeywordIsNotWrittenAsADef() { + val files = renderKotlinFqnStubs(listOf(decl("$graphics.Shadow.from", "FUNCTION"))) + val text = files.values.joinToString("\n") + assertFalse(Regex("""^\s*def from\(""", RegexOption.MULTILINE).containsMatchIn(text), text) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/SupertypeStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/SupertypeStubTest.kt new file mode 100644 index 000000000..910c7b743 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/SupertypeStubTest.kt @@ -0,0 +1,57 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.KotlinTypeModel +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #71: a stub class lists every Kotlin supertype that has a stub class, in a valid MRO. + * `Arrangement.HorizontalOrVertical` is both a `Horizontal` and a `Vertical`; listing only the first + * made mypy reject it in a `Vertical` slot. + */ +class SupertypeStubTest { + + private val pkg = "androidx.compose.foundation.layout" + private val arrangement = "$pkg.Arrangement" + + private fun producing(binding: String, type: String, supertypes: List) = DeclarationModel( + simpleName = binding.substringAfterLast('.'), + owner = binding.substringBeforeLast('.'), + ownerIsClass = true, + receiver = null, + parameters = emptyList(), + returnType = KotlinTypeModel(type), + returnBoundaryTag = "OBJECT", + bindingName = binding, + returnSupertypes = supertypes, + ) + + private fun text(vararg d: DeclarationModel, module: String = arrangement) = + renderKotlinFqnStubs(d.toList()).getValue(module.replace('.', '/') + "/__init__.pyi") + + @Test + fun aClassListsEveryKotlinSupertypeThatHasAStubClass() { + val both = producing( + "$arrangement.SpaceBetween", "$arrangement.HorizontalOrVertical", + listOf("$arrangement.Horizontal", "$arrangement.Vertical", "kotlin.Any", "java.lang.Object"), + ) + val body = text(both) + assertTrue("class HorizontalOrVertical(Horizontal, Vertical):" in body, body) + assertTrue("class Horizontal:" in body, body) + assertTrue("class Vertical:" in body, body) + assertFalse("object" in body.substringAfter("class HorizontalOrVertical").substringBefore(":"), body) + } + + @Test + fun aBaseAnotherListedBaseAlreadyImpliesIsDroppedSoTheMroIsValid() { + // Sub is a Base and a Mid, and Mid is a Base: `class Sub(Base, Mid)` is an MRO error in Python. + val sub = producing("$arrangement.makeSub", "$arrangement.Sub", listOf("$arrangement.Base", "$arrangement.Mid")) + val mid = producing("$arrangement.makeMid", "$arrangement.Mid", listOf("$arrangement.Base")) + val body = text(sub, mid) + assertTrue("class Sub(Mid):" in body, body) + assertTrue("class Mid(Base):" in body, body) + assertFalse("class Sub(Base" in body, body) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/TypedStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/TypedStubTest.kt new file mode 100644 index 000000000..10e0a811a --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/TypedStubTest.kt @@ -0,0 +1,297 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import python.multiplatform.gradle.model.ValueClassModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #31: the stub names the Kotlin type a declaration says, instead of the `int` handle the + * boundary carries. + * + * What is pinned here, all over generated text: + * + * - one stub class per bound Kotlin type, in the module of its own Kotlin package, and used in every + * annotation (`Modifier`, not `int`); + * - an extension function is also reachable from its receiver's stub class, under both the Kotlin + * name and the overload-suffixed table key, the way the runtime attaches it + * (`PythonxAdapter._attach`); + * - a value class over a primitive is `Dp | float` as a parameter and the raw primitive as a + * result, because that is what crosses; a value class the binder cannot open is a plain class; + * - a Kotlin function type is `Callable[...]`, a nullable type `| None`; + * - an overload set is `@overload`ed under its base name **in table-key order**, which is the order + * the binder's `_Overloads` tries candidates in (`ArtifactScanner.scanJar` sorts by table key); + * - a required parameter after a defaulted one is keyword-only, as `inspect.signature` says + * (`KotlinSurface.kt`). + */ +class TypedStubTest { + + private val pkgUi = "androidx.compose.ui" + private val pkgLayout = "androidx.compose.foundation.layout" + private val pkgUnit = "androidx.compose.ui.unit" + + private val modifier = KotlinTypeModel("$pkgUi.Modifier") + private val float = KotlinTypeModel("kotlin.Float") + private val dp = KotlinTypeModel( + "$pkgUnit.Dp", + valueClass = ValueClassModel(float, constructorIsPublic = true, propertyIsPublic = true), + ) + private val textUnit = KotlinTypeModel( + "$pkgUnit.TextUnit", + valueClass = ValueClassModel(KotlinTypeModel("kotlin.Long"), constructorIsPublic = false, propertyIsPublic = false), + ) + private val paddingValues = KotlinTypeModel("$pkgLayout.PaddingValues") + private val unit = KotlinTypeModel("kotlin.Unit") + + private fun p(name: String, type: KotlinTypeModel, tag: String, default: Boolean = false) = + DeclaredParameter(name, type, default, tag) + + private fun ext( + name: String, + binding: String = "$pkgLayout.$name", + owner: String = pkgLayout, + vararg params: DeclaredParameter, + ) = DeclarationModel( + simpleName = name, + owner = owner, + ownerIsClass = false, + receiver = modifier, + receiverBoundaryTag = "OBJECT", + parameters = params.toList(), + returnType = modifier, + returnBoundaryTag = "OBJECT", + bindingName = binding, + ) + + private val padding = listOf( + ext("padding", "$pkgLayout.padding__Dp_Dp_Dp_Dp", + params = arrayOf(p("start", dp, "FLOAT", true), p("top", dp, "FLOAT", true), p("end", dp, "FLOAT", true), p("bottom", dp, "FLOAT", true))), + ext("padding", "$pkgLayout.padding__Dp", params = arrayOf(p("all", dp, "FLOAT"))), + ext("padding", "$pkgLayout.padding__PaddingValues", params = arrayOf(p("paddingValues", paddingValues, "OBJECT"))), + ext("padding", "$pkgLayout.padding__Dp_Dp", params = arrayOf(p("horizontal", dp, "FLOAT", true), p("vertical", dp, "FLOAT", true))), + ) + + private val fillMaxWidth = ext("fillMaxWidth", params = arrayOf(p("fraction", float, "FLOAT", true))) + + private fun files(vararg d: DeclarationModel) = renderKotlinFqnStubs(d.toList()) + private fun file(files: Map, module: String) = + files.getValue(module.replace('.', '/') + "/__init__.pyi") + + // ------------------------------------------------------------------ classes in annotations + + @Test + fun receiverAndReturnAreTheKotlinClassNotAnInt() { + val layout = file(files(fillMaxWidth), pkgLayout) + assertTrue( + "def fillMaxWidth(receiver: androidx.compose.ui.Modifier, /, fraction: float = ...) -> androidx.compose.ui.Modifier:" in layout, + layout, + ) + assertFalse(": int" in layout || "-> int" in layout, layout) + assertTrue("import androidx.compose.ui\n" in layout, layout) + } + + @Test + fun theClassIsStubbedInTheModuleOfItsOwnPackage() { + val files = files(fillMaxWidth) + val ui = file(files, pkgUi) + assertTrue("class Modifier:" in ui, ui) + assertTrue("\"\"\"Kotlin: androidx.compose.ui.Modifier\"\"\"" in ui, ui) + // A class is never stubbed in the module of the function that merely mentions it. + assertFalse("class Modifier" in file(files, pkgLayout)) + } + + @Test + fun aTypeFromTheSameModuleIsReferencedBare() { + val ui = file(files(fillMaxWidth), pkgUi) + // The extension is attached to its receiver's class, which lives in `androidx.compose.ui`. + assertTrue("-> Modifier" in ui, ui) + assertFalse("androidx.compose.ui.Modifier" in ui.substringAfter("class _Modifier_fillMaxWidth").substringBefore("class Modifier:"), ui) + } + + // ------------------------------------------------------- extension functions as methods + + @Test + fun anExtensionIsAlsoAnAttributeOfItsReceiversClass() { + val ui = file(files(fillMaxWidth), pkgUi) + assertTrue("class _Modifier_fillMaxWidth(_t.Protocol):" in ui, ui) + assertTrue("def __call__(self, fraction: float = ...) -> Modifier: ..." in ui, ui) + assertTrue(" fillMaxWidth: _t.ClassVar[_Modifier_fillMaxWidth]" in ui, ui) + // Callable both as `Modifier.fillMaxWidth(...)` and `m.fillMaxWidth(...)`: the attribute is a + // callable object, not a function that would bind `self`. + assertFalse("def fillMaxWidth(self" in ui, ui) + } + + @Test + fun theTableKeySpellingIsAlsoAnAttribute() { + val ui = file(files(*padding.toTypedArray()), pkgUi) + assertTrue("padding__Dp: _t.ClassVar[_Modifier_padding__Dp]" in ui, ui) + assertTrue("padding__PaddingValues: _t.ClassVar[_Modifier_padding__PaddingValues]" in ui, ui) + assertTrue("padding: _t.ClassVar[_Modifier_padding]" in ui, ui) + } + + // ----------------------------------------------------------------------------- overloads + + /** The binder tries candidates in table-key order (the artefact table is sorted by name), and a + * checker takes the first match, so the stub's order has to be the same one. */ + @Test + fun anOverloadSetIsOverloadedUnderItsBaseNameInTableKeyOrder() { + val files = files(*padding.toTypedArray()) + val layout = file(files, pkgLayout) + val order = Regex("""@_t\.overload\ndef padding\((.*)\) -> """).findAll(layout).map { it.groupValues[1] }.toList() + assertEquals(4, order.size, layout) + assertTrue(order[0].contains("all: androidx.compose.ui.unit.Dp | float"), order.toString()) + assertTrue(order[1].contains("horizontal"), order.toString()) + assertTrue(order[2].contains("start"), order.toString()) + assertTrue(order[3].contains("paddingValues"), order.toString()) + // ... and the explicit table-key spellings are still there, one def each. + assertTrue("def padding__Dp(receiver: androidx.compose.ui.Modifier, /, all:" in layout, layout) + assertTrue("def padding__PaddingValues(" in layout, layout) + + val ui = file(files, pkgUi) + val methodOrder = Regex("""class _Modifier_padding\(_t\.Protocol\):\n((?: .*\n)+)""").find(ui)!!.groupValues[1] + val calls = Regex("""def __call__\(self, (\w+)""").findAll(methodOrder).map { it.groupValues[1] }.toList() + assertEquals(listOf("all", "horizontal", "start", "paddingValues"), calls, ui) + } + + /** Python's `int`/`float` cannot tell two Kotlin overloads apart, so a checker calls every later + * one unreachable; the diagnostic is silenced on those, and never on the first. */ + @Test + fun everyOverloadAfterTheFirstSilencesTheCheckersNeverMatchedDiagnostic() { + val layout = file(files(*padding.toTypedArray()), pkgLayout) + val defs = layout.lines().filter { it.startsWith("def padding(") } + assertEquals(4, defs.size, layout) + assertFalse("type: ignore" in defs[0], defs[0]) + assertTrue(defs.drop(1).all { it.endsWith(" # type: ignore[overload-cannot-match]") }, defs.toString()) + val ui = file(files(*padding.toTypedArray()), pkgUi) + val calls = ui.substringAfter("class _Modifier_padding(").substringBefore("class _Modifier_padding__Dp").lines().filter { "def __call__" in it } + assertEquals(4, calls.size, ui) + assertFalse("type: ignore" in calls[0], calls[0]) + assertTrue(calls.drop(1).all { "type: ignore[overload-cannot-match]" in it }, calls.toString()) + } + + /** `androidx.compose.ui.graphics.Shadow` (a class module) and `...graphics.shadow` (a package) are + * two paths on a case-sensitive filesystem and one on macOS or Windows, where the later write + * silently replaces the earlier. */ + @Test + fun pathsThatDifferOnlyByCaseAreReported() { + val paths = listOf( + "androidx/compose/ui/graphics/Shadow/__init__.pyi", + "androidx/compose/ui/graphics/shadow/__init__.pyi", + "androidx/compose/ui/graphics/__init__.pyi", + ) + assertEquals( + listOf(listOf("androidx/compose/ui/graphics/Shadow/__init__.pyi", "androidx/compose/ui/graphics/shadow/__init__.pyi")), + caseCollidingPaths(paths), + ) + assertEquals(emptyList(), caseCollidingPaths(paths.drop(1).take(1) + paths.drop(2))) + } + + @Test + fun aSingleDeclarationUnderAnOverloadSuffixStillGetsItsBaseName() { + val only = listOf(padding[1]) + val layout = file(files(*only.toTypedArray()), pkgLayout) + assertTrue("def padding(receiver: androidx.compose.ui.Modifier, /, all:" in layout, layout) + assertFalse("@_t.overload" in layout, "one declaration is not an overload set: $layout") + } + + // ----------------------------------------------------------------------------- value classes + + @Test + fun aValueClassParameterIsTheClassOrItsRawPrimitiveAndItsResultIsTheRawPrimitive() { + val takes = ext("padding", "$pkgLayout.padding__Dp", params = arrayOf(p("all", dp, "FLOAT"))) + val returns = DeclarationModel( + simpleName = "defaultPadding", owner = pkgLayout, ownerIsClass = false, receiver = null, + parameters = emptyList(), returnType = dp, returnBoundaryTag = "FLOAT", bindingName = "$pkgLayout.defaultPadding", + ) + val files = files(takes, returns) + val layout = file(files, pkgLayout) + assertTrue("all: androidx.compose.ui.unit.Dp | float) ->" in layout, layout) + assertTrue("def defaultPadding() -> float:" in layout, layout) + val units = file(files, pkgUnit) + assertTrue("class Dp:" in units, units) + assertTrue("value class" in units && "kotlin.Float" in units, units) + } + + @Test + fun aValueClassTheBinderCannotOpenIsAnOrdinaryClass() { + val takes = DeclarationModel( + simpleName = "fontSize", owner = pkgLayout, ownerIsClass = false, receiver = null, + parameters = listOf(p("size", textUnit, "OBJECT")), returnType = unit, returnBoundaryTag = "UNIT", + bindingName = "$pkgLayout.fontSize", + ) + val layout = file(files(takes), pkgLayout) + assertTrue("def fontSize(size: androidx.compose.ui.unit.TextUnit) -> None:" in layout, layout) + } + + // ------------------------------------------------------------------ function types, nullable + + @Test + fun aFunctionTypeIsCallableAndANullableTypeIsOptional() { + val function1 = KotlinTypeModel("kotlin.Function1", arguments = listOf(KotlinTypeModel("kotlin.Boolean"), unit)) + val function0 = KotlinTypeModel("kotlin.Function0", arguments = listOf(unit)) + val checkbox = DeclarationModel( + simpleName = "Checkbox", owner = "androidx.compose.material3", ownerIsClass = false, receiver = null, + parameters = listOf( + p("checked", KotlinTypeModel("kotlin.Boolean"), "BOOLEAN"), + p("onCheckedChange", function1.copy(isNullable = true), "OBJECT"), + p("modifier", modifier, "OBJECT", default = true), + p("content", function0, "OBJECT"), + ), + returnType = unit, returnBoundaryTag = "UNIT", bindingName = "androidx.compose.material3.Checkbox", + ) + val body = file(files(checkbox), "androidx.compose.material3") + assertTrue( + "def Checkbox(checked: bool, onCheckedChange: _t.Callable[[bool], None] | None, " + + "modifier: androidx.compose.ui.Modifier = ..., *, content: _t.Callable[[], None]) -> None:" in body, + body, + ) + } + + // ------------------------------------------------------------------------------ inheritance + + @Test + fun aClassExtendsItsNearestSupertypeWhenTheScannerKnowsOne() { + val painter = KotlinTypeModel("androidx.compose.ui.graphics.painter.Painter") + val bitmapPainter = KotlinTypeModel("androidx.compose.ui.graphics.painter.BitmapPainter") + val make = DeclarationModel( + simpleName = "loadPainter", owner = "androidx.compose.ui.graphics.painter", ownerIsClass = false, receiver = null, + parameters = emptyList(), returnType = bitmapPainter, returnBoundaryTag = "OBJECT", + bindingName = "androidx.compose.ui.graphics.painter.loadPainter", + returnSupertypes = listOf(painter.qualifiedName), + ) + val body = file(files(make), "androidx.compose.ui.graphics.painter") + assertTrue("class BitmapPainter(Painter):" in body, body) + assertTrue("class Painter:" in body, body) + } + + // ---------------------------------------------------------------------- name collisions + + /** A Kotlin factory function and the class it makes share a name (`PaddingValues(...)`). One + * Python module cannot define both, and the runtime resolves that name to the function. */ + @Test + fun aClassWhoseNameIsAFunctionInItsModuleFallsBackToAny() { + val factory = DeclarationModel( + simpleName = "PaddingValues", owner = pkgLayout, ownerIsClass = false, receiver = null, + parameters = listOf(p("all", dp, "FLOAT")), returnType = paddingValues, returnBoundaryTag = "OBJECT", + bindingName = "$pkgLayout.PaddingValues", + ) + val layout = file(files(factory), pkgLayout) + assertTrue("def PaddingValues(all: androidx.compose.ui.unit.Dp | float) -> _t.Any:" in layout, layout) + assertFalse("class PaddingValues" in layout, layout) + } + + // ------------------------------------------------------------------------ Kotlin names only + + @Test + fun theTypedStubsStillCarryOnlyKotlinNames() { + val all = files(*padding.toTypedArray(), fillMaxWidth) + assertEquals(emptyList(), all.keys.filter { it.startsWith("pythonx/") }) + val text = all.keys.joinToString("\n") + all.values.joinToString("\n") + assertFalse("pythonx" in text, text) + assertFalse("fill_max_width" in text || "padding_values" in text, text) + } +} diff --git a/python-multiplatform-ksp/.gitignore b/python-multiplatform-ksp/.gitignore new file mode 100644 index 000000000..4dc423b60 --- /dev/null +++ b/python-multiplatform-ksp/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +.pytest_cache/ +*.egg-info/ +.pytest_tmp/ diff --git a/python-multiplatform-ksp/build.gradle.kts b/python-multiplatform-ksp/build.gradle.kts new file mode 100644 index 000000000..a171a8475 --- /dev/null +++ b/python-multiplatform-ksp/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + `maven-publish` +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation("com.google.devtools.ksp:symbol-processing-api:${libs.versions.ksp.get()}") + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlin.test.junit) +} + +tasks.test { + useJUnit() +} + +// Without this, DEFAULT_PROCESSOR_COORDINATES (generated by +// python-multiplatform-gradle-plugin's `generateCoordinates` task, from this same group/artifactId/ +// version) names an artifact that never gets published anywhere -- every consumer outside this repo +// resolves the plugin id fine and then fails on the processor dependency itself. +publishing { + publications { + create("maven") { + from(components["java"]) + } + } +} diff --git a/python-multiplatform-ksp/pyproject.toml b/python-multiplatform-ksp/pyproject.toml new file mode 100644 index 000000000..460631868 --- /dev/null +++ b/python-multiplatform-ksp/pyproject.toml @@ -0,0 +1,33 @@ +# TypedPython soundness gate (docs/design/typedpython.md §4.2). +# The Kotlin processor in this module is built by Gradle; this file only describes the Python +# package that lives next to it in src/main/python. +[project] +name = "typedpython" +version = "0.1.0" +requires-python = ">=3.13" +# Pinned exactly: the gate reads Pyrefly's per-expression type report (--report-pysa), whose +# format is versioned by Pyrefly, not by a public API. +dependencies = ["pyrefly==1.3.2"] + +[project.scripts] +typedpython = "typedpython.__main__:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/main/python/typedpython"] + +# `compiled` is a builtin name (design §2.3): this .pth installs it into `builtins` on plain CPython. +[tool.hatch.build.targets.wheel.force-include] +"src/main/python/typedpython_builtins.pth" = "typedpython_builtins.pth" + +[project.optional-dependencies] +test = ["pytest"] + +[tool.pytest.ini_options] +testpaths = ["src/test/python"] +pythonpath = ["src/main/python"] +addopts = "--basetemp=.pytest_tmp" +markers = ["measurement: records cost; asserts only sanity bounds"] diff --git a/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/BindingPolicy.kt b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/BindingPolicy.kt new file mode 100644 index 000000000..04995ed70 --- /dev/null +++ b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/BindingPolicy.kt @@ -0,0 +1,248 @@ +package python.multiplatform.ksp + +import com.google.devtools.ksp.getVisibility +import com.google.devtools.ksp.isConstructor +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.Modifier +import com.google.devtools.ksp.symbol.Visibility + +private const val PYTHON_INTERNAL_ANNOTATION = "python.multiplatform.reflection.PythonInternal" + +/** `docs/design/binding-policy.md`: what the generator is allowed to expose, decided from the + * declaration alone -- no classpath resolution needed beyond what KSP already gives a symbol. */ +object BindingPolicy { + + fun isExcludedByPackage(qualifiedName: String, excludePackages: List): Boolean = + (ALWAYS_EXCLUDED_PACKAGE_PREFIXES + excludePackages).any { + qualifiedName == it || qualifiedName.startsWith("$it.") + } + + fun hasPythonInternal(annotated: KSAnnotated): Boolean = + annotated.annotations.any { annotation -> + val shortName = annotation.shortName.asString() + // Cheap check first (`shortName`, no resolution); only resolve the annotation type + // when the name plausibly matches, since resolving every annotation on every + // declaration would be the expensive path `getSymbolsWithAnnotation` was rejected + // for avoiding (docs/design/upcall.md §2.3, "Discovery key"). + shortName == "PythonInternal" && + annotation.annotationType.resolve().declaration.qualifiedName?.asString() == PYTHON_INTERNAL_ANNOTATION + } + + private fun isPublic(declaration: KSDeclaration): Boolean = declaration.getVisibility() == Visibility.PUBLIC + + /** Top-level functions only; member functions go through [isExposedMemberFunction]. */ + fun isExposedTopLevelFunction(function: KSFunctionDeclaration, excludePackages: List): Boolean { + val qualifiedName = function.qualifiedName?.asString() ?: return false + if (isExcludedByPackage(qualifiedName, excludePackages)) return false + if (!isExposedFunctionShape(function)) return false + val params = function.parameters + val isEntryPoint = function.simpleName.asString() == "main" && + (params.isEmpty() || (params.size == 1 && params[0].type.toShape().qualifiedName == "kotlin.Array")) + if (isEntryPoint) return false + return true + } + + /** + * The class-like declarations that get a `ReflectedClass`: classes, interfaces, enum classes + * and objects. What is deliberately *not* here: + * + * - **`ANNOTATION_CLASS`** -- reading an annotation off a declaration needs runtime + * reflection, which is the one thing this design cannot have (Kotlin/Native has none, + * GraalVM's closed world forbids it), and an annotation instance constructed from Python + * could not be attached to anything. There is nothing usable on the other end of the call. + * - **`ENUM_ENTRY`** -- not a type of its own to Python, but one value of its enum; exposed + * as a `STATIC_GETTER` on the enum instead. + * - **companion objects** -- folded into their owner's entries under the owner's name + * (`docs/design/binding-policy.md`: "companion 객체 자체를 따로 노출할 필요는 없다"). + * - **generic declarations** -- see [hasRenderableSignature]. + */ + fun isExposedClass(classDeclaration: KSClassDeclaration, excludePackages: List): Boolean { + val qualifiedName = classDeclaration.qualifiedName?.asString() ?: return false + if (isExcludedByPackage(qualifiedName, excludePackages)) return false + if (!isPublic(classDeclaration)) return false + if (hasPythonInternal(classDeclaration)) return false + if (Modifier.EXPECT in classDeclaration.modifiers) return false + if (classDeclaration.isCompanionObject) return false + if (!hasRenderableSignature(classDeclaration)) return false + return classDeclaration.classKind in EXPOSED_CLASS_KINDS + } + + /** + * Whether Python may build an instance: only an ordinary, concrete, non-`inner` class. + * + * An abstract or sealed class cannot be constructed at all -- "Cannot create an instance of + * an abstract class", observed as a compile failure of the *generated* fragment, because the + * generator emitted a constructor entry for any class with a public primary constructor. An + * `inner` class needs an outer instance the boundary has nowhere to put. Interfaces, objects + * and enums have no Python-callable constructor by construction. + */ + fun isConstructible(classDeclaration: KSClassDeclaration): Boolean { + if (classDeclaration.classKind != ClassKind.CLASS) return false + if (Modifier.ABSTRACT in classDeclaration.modifiers) return false + if (Modifier.SEALED in classDeclaration.modifiers) return false + if (Modifier.INNER in classDeclaration.modifiers) return false + return true + } + + /** Member function of an already-[isExposedClass] class. Constructors are handled + * separately via [KSClassDeclaration.primaryConstructor]. */ + fun isExposedMemberFunction(function: KSFunctionDeclaration): Boolean { + if (function.isConstructor()) return false + return isExposedFunctionShape(function) + } + + /** + * `copy` and `componentN` on a `data class`. + * + * `docs/design/binding-policy.md` lists compiler-generated members as not exposed, and this is the + * subset KSP actually reports -- observed, not assumed: a generated fragment for + * `data class Point(val x: Long, val y: Long)` carried `Point.copy`, `Point.component1` and + * `Point.component2`, but no `equals`, `hashCode` or `toString`. `componentN` has no meaning + * on the Python side (the properties are already exposed by name) and `copy` loses the + * default arguments that are its whole point, so both are noise in the table. + */ + fun isCompilerGeneratedDataClassMember(owner: KSClassDeclaration, function: KSFunctionDeclaration): Boolean { + if (Modifier.DATA !in owner.modifiers) return false + val name = function.simpleName.asString() + return name == "copy" || COMPONENT_N.matches(name) + } + + private val COMPONENT_N = Regex("component\\d+") + + fun isExposedProperty(property: KSPropertyDeclaration): Boolean { + if (!isPublic(property)) return false + if (hasPythonInternal(property)) return false + if (Modifier.EXPECT in property.modifiers) return false + if (property.extensionReceiver != null) return false + return true + } + + /** + * Whether an already-[isExposedProperty] property gets a `SETTER`/`STATIC_SETTER` entry too. + * + * `isMutable` alone is not the question, which is ROADMAP §13's second defect: a `var` with a + * `private set` is mutable and its setter is not something generated code may name. The + * generated fragment then failed to compile -- + * + * Cannot access 'privateSet': it is private in 'fixture.library.RestrictedSetters'. + * Cannot access 'topLevelPrivateSet': it is private in file. + * + * -- observed on `ksp-fixtures/library`'s `RestrictedSetters` before this check existed. + * + * `internal set` is excluded for a different reason, and the difference is worth stating + * because it does *not* announce itself: `internal` is enforced per Kotlin module and the + * fragment is generated into the same compilation as the sources it scans, so the assignment + * compiles. It is still not exposable. What the table describes is a module's public API -- + * the aggregator that reads this fragment lives in another module, and a Python caller has no + * notion of which Kotlin module it is "inside". Checking only for the shape that broke the + * build would have left the quiet one behind. + * + * A `var` KSP reports with no setter node at all is an ordinary public one: there is no + * accessor there to carry a visibility modifier. + */ + fun isExposedSetter(property: KSPropertyDeclaration): Boolean { + if (!property.isMutable) return false + val setter = property.setter ?: return true + val declared = setter.modifiers.intersect(VISIBILITY_MODIFIERS) + return declared.isEmpty() || declared == setOf(Modifier.PUBLIC) + } + + private val VISIBILITY_MODIFIERS = + setOf(Modifier.PUBLIC, Modifier.PRIVATE, Modifier.PROTECTED, Modifier.INTERNAL) + + /** + * Whether this declaration needs the asynchronous calling convention + * (`docs/design/upcall.md`). + * + * `suspend` used to be a rejection here, and silently: a C callback slot has to hand back a + * `PyObject *` before it returns and a suspension has nothing to hand back, so until there was + * somewhere to put the answer there was nothing correct to generate. There is now -- + * `python.multiplatform.ffi.upcall.PendingCall` starts the coroutine inside the frame and the + * boundary returns either the value (it never suspended) or an `asyncio.Future` (it did) -- so + * the modifier selects a *body shape* rather than excluding the declaration. + * + * This reads the modifier on the **declaration**. A `suspend` function *type* + * (`val h: suspend (Long) -> Long`) carries no such modifier and is not this question; it is + * still exposed as an opaque `OBJECT` handle, which `GeneratedSuspendTest` pins. + */ + fun isSuspending(function: KSFunctionDeclaration): Boolean = Modifier.SUSPEND in function.modifiers + + private fun isExposedFunctionShape(function: KSFunctionDeclaration): Boolean { + if (!isPublic(function)) return false + if (hasPythonInternal(function)) return false + if (Modifier.EXPECT in function.modifiers) return false + if (function.extensionReceiver != null) return false + if (isComposable(function)) return false + if (!hasRenderableSignature(function)) return false + return true + } + + /** + * A `@Composable` in the consumer's **own source**, which KSP cannot bind and must not try to. + * + * `docs/design/ecosystem.md` §4 item 1 states the reason: a generated entry is a lambda over + * `Array`, and a `@Composable` may only be invoked from a `@Composable` context, so the + * generated call does not compile. That is not hypothetical -- putting one hand-written + * composable in a module carrying this processor produced exactly + * + * e: Fragment_....kt:20:50 @Composable invocations can only happen from the context of + * a @Composable function + * + * and the module could not be built at all. Nothing had noticed because no fixture had ever put + * a composable in a processed source set. + * + * **Deliberately not the answer the artefact walker gives.** `ArtifactScanner` *does* bind a + * composable, by emitting its call site as bytecode (`ComposableThunks.kt`) with the + * `$composer`/`$changed`/`$default` parameters exposed as ordinary slots. That route needs the + * callee's compiled JVM signature, which for a declaration in the source set being compiled + * right now does not exist yet -- so it is genuinely unavailable here rather than merely + * unimplemented. Declining is the honest state, and it leaves the module compiling. + * + * Matched by simple name so that no dependency on the Compose runtime is introduced: a processor + * that had to resolve `androidx.compose.runtime.Composable` would need Compose on the classpath + * of every consumer, which is most of them do not have. + */ + private fun isComposable(function: KSFunctionDeclaration): Boolean = + function.annotations.any { it.shortName.asString() == "Composable" } + + /** + * Generic declarations are not exposed, whatever their visibility. + * + * A generated entry is a lambda over `Array`, so every parameter and every receiver + * needs a cast to a type spelled out in source. A type parameter has no such spelling: + * `args[0] as T` is an unresolved reference from the fragment's file, and a cast to the raw + * `Box` is "One type argument expected". Both were observed as compile failures of generated + * code, not as anything the processor itself could detect. + * + * This is wider than `docs/design/binding-policy.md` records -- that excluded only + * `inline` + `reified`, the subset where the type is erased. Erasing to `Box<*>` instead was + * rejected: the receiver would still not satisfy a member declared over `T`. + */ + private fun hasRenderableSignature(declaration: KSDeclaration): Boolean = declaration.typeParameters.isEmpty() + + /** + * The type-usage counterpart to [hasRenderableSignature]: every parameter and the return type + * of [function] must be an [isExposableType], not just spelled without a bare type parameter. + * + * Kept as a separate predicate rather than folded into [isExposedFunctionShape] so + * [FragmentScanner] can tell "not exposed because of shape" (private, `@PythonInternal`, ...) + * apart from "not exposed because a type in the signature has no callable entry" and warn only + * on the latter -- the former is ordinary and silent by design, the latter is the failure mode + * `docs/design/upcall.md` §5.3 measured as a classifier that compiles and checkcasts but + * has nothing behind it. + */ + fun hasExposableTypes(function: KSFunctionDeclaration): Boolean = + function.parameters.all { isExposableType(it.type) } && + (function.returnType?.let { isExposableType(it) } ?: true) + + /** [hasExposableTypes] for a property's own type. */ + fun hasExposableTypes(property: KSPropertyDeclaration): Boolean = isExposableType(property.type) + + private val EXPOSED_CLASS_KINDS = + setOf(ClassKind.CLASS, ClassKind.INTERFACE, ClassKind.ENUM_CLASS, ClassKind.OBJECT) +} diff --git a/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/Constants.kt b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/Constants.kt new file mode 100644 index 000000000..e9540882c --- /dev/null +++ b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/Constants.kt @@ -0,0 +1,46 @@ +package python.multiplatform.ksp + +/** + * The well-known package fragments are emitted into and the aggregator scans. + * `docs/design/upcall.md` §2.1/§2 -- not load-bearing for discovery (the aggregator scans + * the whole package), but fixed so generated code from different modules never collides. + */ +const val FRAGMENTS_PACKAGE = "python.multiplatform.generated.fragments" + +/** Where the app-role aggregator emits `FunctionTable`. */ +const val AGGREGATOR_PACKAGE = "python.multiplatform.generated" + +const val FRAGMENT_PREFIX = "Fragment_" + +/** + * `docs/design/binding-policy.md`: the library's own packages are excluded wholesale so the ~600 + * internal FFI/object-model declarations never end up in a user's table. Every consumer's + * `excludePackages` option is added on top of this, not instead of it. + */ +val ALWAYS_EXCLUDED_PACKAGE_PREFIXES = listOf( + "python.native.ffi", + "python.multiplatform.ffi", + "python.multiplatform.reflection", + "python.multiplatform.ref", + // generated code must never be re-scanned as if it were user source, or a library that is + // itself processed in a later round would emit a fragment of its own fragment. + FRAGMENTS_PACKAGE, + AGGREGATOR_PACKAGE, +) + +const val OPTION_ROLE = "python.multiplatform.role" +const val OPTION_MODULE_NAME = "python.multiplatform.moduleName" +const val OPTION_EXCLUDE_PACKAGES = "python.multiplatform.excludePackages" + +const val ROLE_LIBRARY = "library" +const val ROLE_APP = "app" + +/** [docs/design/upcall.md] §11.4: an entry point Python has no use for and cannot call. */ +fun isMainFunction(name: String, hasNoParamsOrArgsArray: Boolean): Boolean = + name == "main" && hasNoParamsOrArgsArray + +/** Fragment object name for a given (already sanitised) module name. */ +fun fragmentObjectName(moduleName: String): String = "$FRAGMENT_PREFIX$moduleName" + +/** Periods and hyphens collide with Kotlin identifier syntax; `docs/design/upcall.md` §2.1. */ +fun sanitiseModuleName(raw: String): String = raw.replace('.', '_').replace('-', '_') diff --git a/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/FragmentDiscovery.kt b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/FragmentDiscovery.kt new file mode 100644 index 000000000..9aeb73ff6 --- /dev/null +++ b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/FragmentDiscovery.kt @@ -0,0 +1,36 @@ +package python.multiplatform.ksp + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSClassDeclaration + +/** + * How the app-role processor finds every `Fragment_*` object -- its own dependencies' and its + * own. `docs/design/upcall.md` §2.3: the only working implementation today goes through + * `Resolver.getDeclarationsFromPackage`, which is `@KspExperimental`. This interface is the + * seam the doc asks for: if that API changes, only [PackageScanFragmentDiscovery] below needs + * to change, not [python.multiplatform.ksp.AppProcessor] or anything downstream of it. + */ +interface FragmentDiscovery { + /** Qualified names of every `Fragment_*` object visible from this compilation, in a stable + * (sorted) order so the generated `FunctionTable` is reproducible across builds. */ + fun discoverFragments(resolver: Resolver): List +} + +/** + * The only implementation today. Queries the well-known package on the classpath (JAR on JVM, + * `.klib` metadata on Kotlin/Native -- `docs/design/upcall.md` §2.3's "Platform uniformity" + * table), which is why this discovers fragments compiled by other modules without those modules + * publishing anything beyond their ordinary build artifact. + */ +class PackageScanFragmentDiscovery : FragmentDiscovery { + @OptIn(KspExperimental::class) + override fun discoverFragments(resolver: Resolver): List = + resolver.getDeclarationsFromPackage(FRAGMENTS_PACKAGE) + .filterIsInstance() + .filter { it.simpleName.asString().startsWith(FRAGMENT_PREFIX) } + .mapNotNull { it.qualifiedName?.asString() } + .distinct() + .sorted() + .toList() +} diff --git a/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/FragmentScanner.kt b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/FragmentScanner.kt new file mode 100644 index 000000000..bdc559d52 --- /dev/null +++ b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/FragmentScanner.kt @@ -0,0 +1,535 @@ +package python.multiplatform.ksp + +import com.google.devtools.ksp.getVisibility +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFile +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSNode +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.KSValueParameter +import com.google.devtools.ksp.symbol.Visibility + +/** What one module's worth of scanning produced, plus the files it came from -- the latter is + * what [com.google.devtools.ksp.processing.Dependencies] needs for incremental processing. */ +class ScanResult(val fragment: FragmentModel, val originatingFiles: List) + +/** `kotlin.Enum`'s synthetics. Both return a collection, which the boundary cannot marshal. */ +private val ENUM_SYNTHETIC_FUNCTIONS = setOf("values", "valueOf") +private const val ENUM_SYNTHETIC_ENTRIES_PROPERTY = "entries" + +/** Where the generated body for a `suspend fun` starts its coroutine. */ +private const val PENDING_CALL = "python.multiplatform.ffi.upcall.PendingCall" + +/** + * The `callable` lambda for one function entry. + * + * A `suspend fun` cannot be called from the ordinary `(Array) -> Any?` body -- there is no + * continuation there to give it -- so its call goes inside `PendingCall.start { }`, which is itself + * a `suspend` lambda and needs nothing but `kotlin-stdlib` to build. The entry then evaluates to a + * `PendingCall` and carries `isSuspend = true`, which is what tells the trampoline to unwrap it. + * See `docs/design/upcall.md` §5.2 and [BindingPolicy.isSuspending]. + * + * [resultExpr] has already been through [wrapReturnExpression], so a `suspend fun` returning `Int` + * widens inside the coroutine and the boundary still sees the `Long` its `INT` tag promises. + */ +private fun functionBody(function: KSFunctionDeclaration, resultExpr: String): String = + if (BindingPolicy.isSuspending(function)) "{ args -> $PENDING_CALL.start { $resultExpr } }" + else "{ args -> $resultExpr }" + +/** + * [CallableEntryModel.paramNames] for a declared parameter list, in declaration order. + * + * Unlike the artefact walker's ASM path over a compiled jar -- which carries no name at all for a + * classfile with no debug info, and says so in its own `paramNames` doc -- every parameter KSP + * sees came from Kotlin source, where a name is mandatory syntax. `it.name` is nullable only + * because [KSValueParameter] is shared with a lambda parameter's synthetic node; a function's own + * declared parameter always resolves one, and the `?:` fallback exists so a producer bug here is a + * blank string in generated source rather than a `NullPointerException` during the scan. + */ +private fun paramNamesOf(params: List): List = + params.map { it.name?.asString() ?: "" } + +/** [CallableEntryModel.paramHasDefault] for a declared parameter list, in declaration order. */ +private fun paramHasDefaultOf(params: List): List = + params.map { it.hasDefault } + +/** + * Walks every file KSP knows about in this compilation and turns the exposed surface + * (`docs/design/binding-policy.md`) into a [FragmentModel] -- the pure, KSP-independent shape + * [renderFragmentSource] turns into text. + * + * ### What each Kotlin shape becomes + * + * | Kotlin | entries | + * |---|---| + * | top-level `fun` | `FUNCTION` | + * | top-level `val` / `var` | `STATIC_GETTER` / `STATIC_SETTER` | + * | `class` | `CONSTRUCTOR` (concrete classes only), `METHOD`, `GETTER` / `SETTER` | + * | `companion object` member | folded into the owner: `FUNCTION`, `STATIC_GETTER` / `STATIC_SETTER` | + * | `object` | the same, under the object's own name; no constructor | + * | `interface` | `METHOD`, `GETTER` / `SETTER`; no constructor -- receivers arrive as handles | + * | `enum class` | one `STATIC_GETTER` per entry, plus `name`, `ordinal`, `valueOf`, and its own members | + * | `annotation class` | nothing -- see [BindingPolicy.isExposedClass] | + * + * A `var` gets its setter entry only when the *setter* is public -- `private`/`protected`/ + * `internal set` is a read-only property as far as the table is concerned. See + * [BindingPolicy.isExposedSetter]. + * + * A `suspend fun` gets the entry its non-suspending counterpart would -- same [CallableEntryModel.kind], + * same receiver slot, same [CallableEntryModel.returnTag] -- with its call wrapped in + * `PendingCall.start { }` and [CallableEntryModel.isSuspend] set. See [functionBody]. + */ +class FragmentScanner(private val excludePackages: List, private val logger: KSPLogger) { + + fun scan(resolver: Resolver, moduleName: String): ScanResult { + val files = resolver.getAllFiles().toList() + + val entries = mutableListOf() + val classes = mutableListOf() + + files.flatMap { it.declarations }.forEach { declaration -> + when (declaration) { + is KSFunctionDeclaration -> + if (BindingPolicy.isExposedTopLevelFunction(declaration, excludePackages) && + exposableOrWarn(declaration.qualifiedName!!.asString().substringBeforeLast('.'), declaration) + ) { + entries += topLevelFunctionEntry(declaration) + } + + is KSPropertyDeclaration -> + if (isExposedTopLevelProperty(declaration)) { + // A top-level property is a module attribute in Python: no receiver, and + // the call site is the package rather than a type. + val packageName = declaration.qualifiedName!!.asString().substringBeforeLast('.') + if (exposableOrWarn(packageName, declaration)) { + entries += staticPropertyEntries(packageName, packageName, declaration) + } + } + + is KSClassDeclaration -> scanClassTree(declaration, entries, classes) + + else -> {} + } + } + + return ScanResult(FragmentModel(moduleName, entries.distinctByName(), classes), files) + } + + /** + * The type-usage counterpart to the silence [BindingPolicy.isSuspending]'s doc records for a + * plain shape rejection: a declaration that clears every shape check + * ([BindingPolicy.isExposedFunctionShape]-family) but names a type + * ([BindingPolicy.hasExposableTypes]) with no callable entry of its own is not an ordinary, + * expected exclusion the way `private`/`internal` visibility is -- it is the exact silent + * failure mode `docs/design/upcall.md` §5.3 measured for `suspend` function types, and + * the whole point of this task is that it should not go unlogged a second time. + */ + private fun warnUnexposableType(name: String, declaration: KSNode) { + logger.warn( + "python-multiplatform-ksp: $name has a type with no exposable classifier (a `suspend` " + + "function type, or one nested in a generic argument) and is not exposed to Python -- " + + "see docs/design/upcall.md §5.3.", + declaration, + ) + } + + /** [BindingPolicy.hasExposableTypes], warning through [warnUnexposableType] on a `false`. */ + private fun exposableOrWarn(owner: String, function: KSFunctionDeclaration): Boolean { + val exposable = BindingPolicy.hasExposableTypes(function) + if (!exposable) warnUnexposableType("$owner.${function.simpleName.asString()}", function) + return exposable + } + + /** [exposableOrWarn] for a property's own type. */ + private fun exposableOrWarn(owner: String, property: KSPropertyDeclaration): Boolean { + val exposable = BindingPolicy.hasExposableTypes(property) + if (!exposable) warnUnexposableType("$owner.${property.simpleName.asString()}", property) + return exposable + } + + private fun isExposedTopLevelProperty(property: KSPropertyDeclaration): Boolean { + val qualifiedName = property.qualifiedName?.asString() ?: return false + if (BindingPolicy.isExcludedByPackage(qualifiedName, excludePackages)) return false + return BindingPolicy.isExposedProperty(property) + } + + /** + * One class-like declaration and everything nested inside it. + * + * A declaration that is not itself exposed stops the walk rather than being skipped past: + * `@PythonInternal` on a class means its whole surface, and a nested class of a private class + * is not reachable from generated source anyway. + */ + private fun scanClassTree( + classDeclaration: KSClassDeclaration, + entries: MutableList, + classes: MutableList, + ) { + if (!BindingPolicy.isExposedClass(classDeclaration, excludePackages)) return + + val (classEntries, classModel) = classEntriesAndModel(classDeclaration) + entries += classEntries + classes += classModel + + classDeclaration.declarations.filterIsInstance() + .filter { !it.isCompanionObject && it.classKind != ClassKind.ENUM_ENTRY } + .forEach { scanClassTree(it, entries, classes) } + } + + private fun topLevelFunctionEntry(function: KSFunctionDeclaration): CallableEntryModel { + val qualifiedName = function.qualifiedName!!.asString() + val paramShapes = function.parameters.map { it.type.toShape() } + val returnShape = function.returnType?.toShape() ?: TypeShape("kotlin.Unit", false) + val argExprs = paramShapes.mapIndexed { i, shape -> castExpression(shape, "args[$i]") } + val callExpr = "$qualifiedName(${argExprs.joinToString(", ")})" + return CallableEntryModel( + name = qualifiedName, + arity = paramShapes.size, + paramTags = paramShapes.map(::tagFor), + returnTag = tagFor(returnShape), + kind = "FUNCTION", + lambdaBody = functionBody(function, wrapReturnExpression(returnShape, callExpr)), + isSuspend = BindingPolicy.isSuspending(function), + paramNames = paramNamesOf(function.parameters), + paramTypeNames = paramShapes.map { it.rendered }, + returnTypeName = returnShape.rendered, + paramHasDefault = paramHasDefaultOf(function.parameters), + ) + } + + private fun classEntriesAndModel(classDeclaration: KSClassDeclaration): Pair, ClassModel> { + val qualifiedName = classDeclaration.qualifiedName!!.asString() + val isObject = classDeclaration.classKind == ClassKind.OBJECT + val isEnum = classDeclaration.classKind == ClassKind.ENUM_CLASS + val entries = mutableListOf() + + if (BindingPolicy.isConstructible(classDeclaration)) { + classDeclaration.primaryConstructor + ?.takeIf { it.getVisibility() == Visibility.PUBLIC && !BindingPolicy.hasPythonInternal(it) } + ?.let { ctor -> + if (BindingPolicy.hasExposableTypes(ctor)) { + entries += constructorEntry(qualifiedName, ctor) + } else { + warnUnexposableType("$qualifiedName.", ctor) + } + } + } + + if (isEnum) entries += enumEntries(classDeclaration, qualifiedName) + + classDeclaration.declarations.filterIsInstance() + .filter { BindingPolicy.isExposedMemberFunction(it) } + .filter { !isEnum || it.simpleName.asString() !in ENUM_SYNTHETIC_FUNCTIONS } + .filter { !BindingPolicy.isCompilerGeneratedDataClassMember(classDeclaration, it) } + .filter { exposableOrWarn(qualifiedName, it) } + .forEach { function -> + entries += if (isObject) { + staticFunctionEntry(qualifiedName, qualifiedName, function) + } else { + memberFunctionEntry(qualifiedName, function) + } + } + + val properties = classDeclaration.declarations.filterIsInstance() + .filter { BindingPolicy.isExposedProperty(it) } + .filter { !isEnum || it.simpleName.asString() != ENUM_SYNTHETIC_ENTRIES_PROPERTY } + .filter { property -> exposableOrWarn(qualifiedName, property) } + .toList() + properties.forEach { property -> + entries += if (isObject) { + staticPropertyEntries(qualifiedName, qualifiedName, property) + } else { + memberPropertyEntries(qualifiedName, property) + } + } + + entries += companionEntries(classDeclaration, qualifiedName) + + // Only a publicly readable field can be read from the generated fragment at all; a + // private `PyObject` field is as invisible to `tp_traverse` as it is to Python, and + // emitting a read of one produced source that did not compile. + val pyObjectFields = properties.filter { isPyObjectType(it.type.resolve()) } + val traverseBody = if (pyObjectFields.isEmpty()) null else traverseBody(qualifiedName, pyObjectFields) + + val deduped = entries.distinctByName() + val model = ClassModel( + name = qualifiedName, + memberNames = deduped.map { it.name }, + traverseBody = traverseBody, + kind = reflectedClassKind(classDeclaration), + enumEntryNames = if (isEnum) enumEntryNames(classDeclaration) else emptyList(), + ) + return deduped to model + } + + private fun reflectedClassKind(classDeclaration: KSClassDeclaration): String = + when (classDeclaration.classKind) { + ClassKind.INTERFACE -> "INTERFACE" + ClassKind.ENUM_CLASS -> "ENUM" + ClassKind.OBJECT -> "OBJECT" + else -> "CLASS" + } + + private fun enumEntryNames(classDeclaration: KSClassDeclaration): List = + classDeclaration.declarations.filterIsInstance() + .filter { it.classKind == ClassKind.ENUM_ENTRY } + .map { it.simpleName.asString() } + .toList() + + /** + * An enum's fixed instance set, plus the two `kotlin.Enum` members Python needs to map a + * handle back onto the mirror it built. + * + * `values()` and `entries` are deliberately **not** exposed: both return a collection and the + * boundary has no collection marshalling, so Python would get an opaque handle it cannot + * iterate. `ReflectedClass.enumEntryNames` carries the same information as data, which is + * what an `enum.Enum` mirror is built from at import time; `valueOf` is exposed because it + * returns one instance and is the natural lookup for a name Python did not generate from. + * + * An entry's own class body (`RED { override fun ... }`) needs nothing here: the override is + * reached through the enum class's own `METHOD` entry. + */ + private fun enumEntries(classDeclaration: KSClassDeclaration, qualifiedName: String): List { + // Every one of these has a statically known Kotlin type -- an enum entry's STATIC_GETTER + // and `valueOf` both hand back `$qualifiedName` itself, and `name`/`ordinal` are + // `kotlin.Enum`'s own fixed shape -- so `returnTypeName` is filled here exactly as it is + // at every other call site in this file, for the same reason: this is data the processor + // has, not a guess. It currently has no effect on ownership for these specific entries -- + // `PythonProxySource.render` puts every `STATIC_GETTER`/`FUNCTION` an `ENUM` produces on + // the module-attribute path (`renderModuleStatic`/`_pm_static_property`), which does not + // consult `returnTypeName` at all (see that file's "What is still not rendered" table) -- + // but leaving it blank here would read as "KSP could not name this", which is not true. + val entries = enumEntryNames(classDeclaration).map { entryName -> + CallableEntryModel( + name = "$qualifiedName.$entryName", + arity = 0, + paramTags = emptyList(), + returnTag = Tag.OBJECT, + kind = "STATIC_GETTER", + lambdaBody = "{ $qualifiedName.$entryName }", + returnTypeName = qualifiedName, + ) + } + return entries + listOf( + CallableEntryModel( + name = "$qualifiedName.name", + arity = 0, + paramTags = emptyList(), + returnTag = Tag.STRING, + kind = "GETTER", + lambdaBody = "{ args -> (args[0] as $qualifiedName).name }", + returnTypeName = "kotlin.String", + ), + CallableEntryModel( + name = "$qualifiedName.ordinal", + arity = 0, + paramTags = emptyList(), + returnTag = Tag.INT, + kind = "GETTER", + lambdaBody = "{ args -> ((args[0] as $qualifiedName).ordinal).toLong() }", + returnTypeName = "kotlin.Int", + ), + CallableEntryModel( + name = "$qualifiedName.valueOf", + arity = 1, + paramTags = listOf(Tag.STRING), + returnTag = Tag.OBJECT, + kind = "FUNCTION", + lambdaBody = "{ args -> $qualifiedName.valueOf(args[0] as String) }", + paramNames = listOf("value"), + paramTypeNames = listOf("kotlin.String"), + returnTypeName = qualifiedName, + ), + ) + } + + /** + * A companion object's members, named and called through the *owner* rather than through + * `Owner.Companion`: `docs/design/binding-policy.md` exposes them as static members of the class, + * and Kotlin resolves `Owner.member` to the companion's member anyway, named companion or + * not. The companion gets no [ClassModel] of its own -- there is nothing Python can do with + * it that it cannot do through the owner. + */ + private fun companionEntries( + classDeclaration: KSClassDeclaration, + ownerQualifiedName: String, + ): List { + val companion = classDeclaration.declarations.filterIsInstance() + .firstOrNull { it.isCompanionObject } + ?.takeIf { it.getVisibility() == Visibility.PUBLIC && !BindingPolicy.hasPythonInternal(it) } + ?: return emptyList() + + val entries = mutableListOf() + companion.declarations.filterIsInstance() + .filter { BindingPolicy.isExposedMemberFunction(it) } + .filter { exposableOrWarn(ownerQualifiedName, it) } + .forEach { entries += staticFunctionEntry(ownerQualifiedName, ownerQualifiedName, it) } + companion.declarations.filterIsInstance() + .filter { BindingPolicy.isExposedProperty(it) } + .filter { exposableOrWarn(ownerQualifiedName, it) } + .forEach { entries += staticPropertyEntries(ownerQualifiedName, ownerQualifiedName, it) } + return entries + } + + private fun constructorEntry(classQualifiedName: String, ctor: KSFunctionDeclaration): CallableEntryModel { + val paramShapes = ctor.parameters.map { it.type.toShape() } + val argExprs = paramShapes.mapIndexed { i, shape -> castExpression(shape, "args[$i]") } + return CallableEntryModel( + name = "$classQualifiedName.", + arity = paramShapes.size, + paramTags = paramShapes.map(::tagFor), + returnTag = Tag.OBJECT, + kind = "CONSTRUCTOR", + lambdaBody = "{ args -> $classQualifiedName(${argExprs.joinToString(", ")}) }", + paramNames = paramNamesOf(ctor.parameters), + paramTypeNames = paramShapes.map { it.rendered }, + // The class being built, not a `TypeShape` -- there is no return-type usage to derive + // one from. `PythonProxySource.renderClass`'s own comment records that a CONSTRUCTOR + // result is never wrapped by this gate anyway (`self._pm_handle` is set directly), so + // this specific name never reaches `ownedTypeOf`; it is filled regardless for the same + // reason every other site here is -- consistency for a `.pyi` reader, and because + // leaving it `null` would read as "KSP could not name this", which is not true. + returnTypeName = classQualifiedName, + paramHasDefault = paramHasDefaultOf(ctor.parameters), + ) + } + + private fun memberFunctionEntry(classQualifiedName: String, function: KSFunctionDeclaration): CallableEntryModel { + val paramShapes = function.parameters.map { it.type.toShape() } + val returnShape = function.returnType?.toShape() ?: TypeShape("kotlin.Unit", false) + // args[0] is the receiver; declared parameters start at args[1]. + val argExprs = paramShapes.mapIndexed { i, shape -> castExpression(shape, "args[${i + 1}]") } + val receiver = "(args[0] as $classQualifiedName)" + val callExpr = "$receiver.${function.simpleName.asString()}(${argExprs.joinToString(", ")})" + return CallableEntryModel( + name = "$classQualifiedName.${function.simpleName.asString()}", + arity = paramShapes.size, + paramTags = paramShapes.map(::tagFor), + returnTag = tagFor(returnShape), + kind = "METHOD", + lambdaBody = functionBody(function, wrapReturnExpression(returnShape, callExpr)), + isSuspend = BindingPolicy.isSuspending(function), + // The receiver is `args[0]` but is not one of `function.parameters` -- KSP never + // counts it there for a METHOD, the same way `arity` does not -- so nothing here needs + // a `` slot the way an [python.multiplatform.reflection.ExposedCallable + // .isExtension] entry would. + paramNames = paramNamesOf(function.parameters), + paramTypeNames = paramShapes.map { it.rendered }, + returnTypeName = returnShape.rendered, + paramHasDefault = paramHasDefaultOf(function.parameters), + ) + } + + /** + * A function reached without an instance -- an `object`'s or a companion's. [nameOwner] is + * what Python sees it under and [callPrefix] is what the generated source calls; they hold + * the same value everywhere today and are separate parameters because the two roles are. + */ + private fun staticFunctionEntry( + nameOwner: String, + callPrefix: String, + function: KSFunctionDeclaration, + ): CallableEntryModel { + val paramShapes = function.parameters.map { it.type.toShape() } + val returnShape = function.returnType?.toShape() ?: TypeShape("kotlin.Unit", false) + val argExprs = paramShapes.mapIndexed { i, shape -> castExpression(shape, "args[$i]") } + val callExpr = "$callPrefix.${function.simpleName.asString()}(${argExprs.joinToString(", ")})" + return CallableEntryModel( + name = "$nameOwner.${function.simpleName.asString()}", + arity = paramShapes.size, + paramTags = paramShapes.map(::tagFor), + returnTag = tagFor(returnShape), + kind = "FUNCTION", + lambdaBody = functionBody(function, wrapReturnExpression(returnShape, callExpr)), + isSuspend = BindingPolicy.isSuspending(function), + paramNames = paramNamesOf(function.parameters), + paramTypeNames = paramShapes.map { it.rendered }, + returnTypeName = returnShape.rendered, + paramHasDefault = paramHasDefaultOf(function.parameters), + ) + } + + private fun memberPropertyEntries( + classQualifiedName: String, + property: KSPropertyDeclaration, + ): List { + val propShape = property.type.toShape() + val propTag = tagFor(propShape) + val propName = property.simpleName.asString() + val receiver = "(args[0] as $classQualifiedName)" + val getter = CallableEntryModel( + name = "$classQualifiedName.$propName", + arity = 0, + paramTags = emptyList(), + returnTag = propTag, + kind = "GETTER", + lambdaBody = "{ args -> ${wrapReturnExpression(propShape, "$receiver.$propName")} }", + returnTypeName = propShape.rendered, + ) + if (!BindingPolicy.isExposedSetter(property)) return listOf(getter) + val setter = CallableEntryModel( + name = "$classQualifiedName.$propName=", + arity = 1, + paramTags = listOf(propTag), + returnTag = Tag.UNIT, + kind = "SETTER", + lambdaBody = "{ args -> $receiver.$propName = ${castExpression(propShape, "args[1]")} }", + // KSP synthesises a setter node even for a `var` with no written accessor body, and + // its parameter's name is "value" in that case -- but a caller who wrote `set(newValue) + // { ... }` gets to keep that name here rather than a hardcoded "value" that would be + // wrong for exactly the property that bothered to say otherwise. + paramNames = listOf(property.setter?.parameter?.name?.asString() ?: "value"), + paramTypeNames = listOf(propShape.rendered), + ) + return listOf(getter, setter) + } + + /** [memberPropertyEntries] for a property with no instance to read it from: the value lives + * at `callPrefix.name`, and `args[0]` is the new value rather than a receiver. */ + private fun staticPropertyEntries( + nameOwner: String, + callPrefix: String, + property: KSPropertyDeclaration, + ): List { + val propShape = property.type.toShape() + val propTag = tagFor(propShape) + val propName = property.simpleName.asString() + val getter = CallableEntryModel( + name = "$nameOwner.$propName", + arity = 0, + paramTags = emptyList(), + returnTag = propTag, + kind = "STATIC_GETTER", + lambdaBody = "{ ${wrapReturnExpression(propShape, "$callPrefix.$propName")} }", + returnTypeName = propShape.rendered, + ) + if (!BindingPolicy.isExposedSetter(property)) return listOf(getter) + val setter = CallableEntryModel( + name = "$nameOwner.$propName=", + arity = 1, + paramTags = listOf(propTag), + returnTag = Tag.UNIT, + kind = "STATIC_SETTER", + lambdaBody = "{ args -> $callPrefix.$propName = ${castExpression(propShape, "args[0]")} }", + paramNames = listOf(property.setter?.parameter?.name?.asString() ?: "value"), + paramTypeNames = listOf(propShape.rendered), + ) + return listOf(getter, setter) + } + + private fun traverseBody(classQualifiedName: String, pyObjectFields: List): String { + val visits = pyObjectFields.joinToString("\n") { field -> + val name = field.simpleName.asString() + val receiver = "(obj as $classQualifiedName).$name" + if (field.type.resolve().isMarkedNullable) { + " $receiver?.let { visit(it.pointer.toRawValue()) }" + } else { + " visit($receiver.pointer.toRawValue())" + } + } + return "{ obj, visit ->\n$visits\n }" + } +} diff --git a/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/InstallSeam.kt b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/InstallSeam.kt new file mode 100644 index 000000000..f05ec1ef9 --- /dev/null +++ b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/InstallSeam.kt @@ -0,0 +1,132 @@ +package python.multiplatform.ksp + +import com.google.devtools.ksp.getVisibility +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSFile +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.Modifier +import com.google.devtools.ksp.symbol.Visibility + +/** `python.multiplatform.reflection.InstallsUpcallTable`, matched by qualified name. */ +const val INSTALL_SEAM_ANNOTATION = "python.multiplatform.reflection.InstallsUpcallTable" + +private const val INSTALL_SEAM_SIMPLE_NAME = "InstallsUpcallTable" + +/** The generated file's name, per seam; the package it lands in is the seam's own. */ +fun installSeamFileName(simpleName: String): String = "InstallUpcallTable_$simpleName" + +/** + * One `@InstallsUpcallTable expect fun` the compilation carries -- everything + * [renderInstallSeamSource] needs, with no KSP type in it, for the same reason + * [CallableEntryModel] carries none. + */ +data class InstallSeamModel( + val packageName: String, + val simpleName: String, + /** `internal` seams get an `internal actual`; an `expect`/`actual` pair must agree. */ + val isInternal: Boolean, +) + +/** + * Finds every install seam in this compilation, rejecting the shapes whose generated `actual` + * would not match its `expect`. + * + * A leaf compilation sees its whole source-set closure -- `commonMain` included, which is what + * lets the fragment scanner pick up common declarations in the first place -- so the `expect` + * written in `commonMain` is visible from here even though the `actual` this produces is not + * visible from there. + * + * Every rejection is [KSPLogger.error] rather than a silent skip: skipping leaves an `expect` + * with no `actual`, which surfaces later as a Kotlin error pointing at the user's `commonMain` + * with no hint that a processor decided against it. + */ +fun findInstallSeams(resolver: Resolver, logger: KSPLogger): List = + resolver.getAllFiles() + .flatMap { it.declarations } + .filterIsInstance() + .filter { it.hasInstallSeamAnnotation() } + .mapNotNull { it.toSeamModel(logger) } + .toList() + +/** The files the seams were declared in, for `Dependencies`. */ +fun installSeamOriginatingFiles(resolver: Resolver): List = + resolver.getAllFiles() + .flatMap { it.declarations } + .filterIsInstance() + .filter { it.hasInstallSeamAnnotation() } + .mapNotNull { it.containingFile } + .distinct() + .toList() + +private fun KSFunctionDeclaration.hasInstallSeamAnnotation(): Boolean = + annotations.any { annotation -> + // Same two-step as BindingPolicy.hasPythonInternal: the cheap name check first, and only + // then the resolution that would otherwise run over every annotation on every symbol. + annotation.shortName.asString() == INSTALL_SEAM_SIMPLE_NAME && + annotation.annotationType.resolve().declaration.qualifiedName?.asString() == INSTALL_SEAM_ANNOTATION + } + +private fun KSFunctionDeclaration.toSeamModel(logger: KSPLogger): InstallSeamModel? { + val name = simpleName.asString() + + if (parentDeclaration != null) { + logger.error("@$INSTALL_SEAM_SIMPLE_NAME must be on a top-level function; '$name' is a member", this) + return null + } + if (Modifier.EXPECT !in modifiers) { + logger.error( + "@$INSTALL_SEAM_SIMPLE_NAME must be on an `expect fun`: the `actual` is generated into each " + + "target's own compilation, which is the only place that can name FunctionTable. '$name' is not expect.", + this, + ) + return null + } + if (parameters.isNotEmpty() || extensionReceiver != null) { + logger.error("@$INSTALL_SEAM_SIMPLE_NAME function '$name' must take no parameters and no receiver", this) + return null + } + val returnsUnit = returnType?.resolve()?.declaration?.qualifiedName?.asString() ?: "kotlin.Unit" + if (returnsUnit != "kotlin.Unit") { + logger.error("@$INSTALL_SEAM_SIMPLE_NAME function '$name' must return Unit, not $returnsUnit", this) + return null + } + val visibility = getVisibility() + if (visibility != Visibility.PUBLIC && visibility != Visibility.INTERNAL) { + logger.error("@$INSTALL_SEAM_SIMPLE_NAME function '$name' must be public or internal, not $visibility", this) + return null + } + + return InstallSeamModel( + packageName = packageName.asString(), + simpleName = name, + isInternal = visibility == Visibility.INTERNAL, + ) +} + +/** + * The generated `actual`. One statement, and the point of the whole seam: this file is emitted + * into the leaf compilation, which is the only source set that can name `FunctionTable`. + * + * `@PythonInternal` on it is not decoration. The function is an ordinary public top-level + * declaration in the *user's* package, so an incremental round that hands the scanner its own + * previous output would otherwise put `.` in the table as a callable Python could + * invoke to reinstall the table underneath itself. + */ +fun renderInstallSeamSource(seam: InstallSeamModel): String = buildString { + appendLine("// GENERATED by python-multiplatform-ksp. Do not edit.") + appendLine("package ${seam.packageName}") + appendLine() + appendLine("/**") + appendLine(" * The `actual` for `@$INSTALL_SEAM_SIMPLE_NAME ${seam.simpleName}`.") + appendLine(" *") + appendLine(" * `$AGGREGATOR_PACKAGE.FunctionTable` is generated into this target's own") + appendLine(" * compilation and is unresolvable from any source set that compilation depends on,") + appendLine(" * so this one line cannot be written anywhere shared code could reach it.") + appendLine(" */") + appendLine("@python.multiplatform.reflection.PythonInternal") + append(if (seam.isInternal) "internal actual fun " else "actual fun ") + appendLine("${seam.simpleName}() {") + appendLine(" $AGGREGATOR_PACKAGE.FunctionTable.installInto()") + appendLine("}") +} diff --git a/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/KsAdapters.kt b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/KsAdapters.kt new file mode 100644 index 000000000..c3c330baf --- /dev/null +++ b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/KsAdapters.kt @@ -0,0 +1,97 @@ +package python.multiplatform.ksp + +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSTypeAlias +import com.google.devtools.ksp.symbol.KSTypeReference +import com.google.devtools.ksp.symbol.Variance + +private const val PY_OBJECT_QUALIFIED_NAME = "python.multiplatform.ffi.PyObject" + +/** Reduces a resolved KSP type to the plain data [castExpression]/[wrapReturnExpression] work on. */ +fun KSType.toShape(): TypeShape { + val qualifiedName = declaration.qualifiedName?.asString() ?: "kotlin.Any" + return TypeShape(qualifiedName, isMarkedNullable, rendered = renderWithArguments(qualifiedName)) +} + +/** + * `pkg.Type` for a plain type, `pkg.Type` for a generic one -- what a cast in + * generated source needs, since `x as kotlin.collections.List` does not compile. + * + * Use-site variance (`Array`) is deliberately dropped: the cast is unchecked either + * way, and an invariant cast target still satisfies a projected parameter. A star projection + * stays a star, because there is nothing else to write for it. A type argument that is itself a + * type *parameter* (`T`) has no qualified name and would render as an unresolved reference; + * [BindingPolicy] rejects every declaration that can produce one before this is reached, and the + * fallbacks to `*` here are the belt to that pair of braces. + */ +private fun KSType.renderWithArguments(qualifiedName: String): String { + if (arguments.isEmpty()) return qualifiedName + val renderedArguments = arguments.joinToString(", ") { argument -> + if (argument.variance == Variance.STAR) return@joinToString "*" + val argumentType = argument.type?.resolve() ?: return@joinToString "*" + if (argumentType.declaration !is KSClassDeclaration) return@joinToString "*" + val inner = argumentType.declaration.qualifiedName?.asString() ?: return@joinToString "*" + argumentType.renderWithArguments(inner) + if (argumentType.isMarkedNullable) "?" else "" + } + return "$qualifiedName<$renderedArguments>" +} + +fun KSTypeReference.toShape(): TypeShape = resolve().toShape() + +/** + * Whether [type] can be named in generated source as a classifier Python can actually reach a + * call or member surface through -- not merely one the Kotlin compiler happens to accept. + * + * A `suspend` function type (`suspend (Long) -> Long`) is a legitimate `KSType` + * ([KSType.isSuspendFunctionType] is true) but its declaration + * (`kotlin.coroutines.SuspendFunctionN`) is compiler-synthesized, not something this + * compilation's KSP scan ever visits as source. It gets no `ReflectedClass`, so `invoke` has no + * table entry -- the generated cast (`args[0] as kotlin.coroutines.SuspendFunction1<...>`) + * compiles and the runtime checkcast passes regardless (`docs/design/upcall.md` §5.3, + * `GeneratedSuspendTest`), so nothing downstream catches this. What would cross is a handle + * Python can hold and hand back and nothing else. + * + * Recurses into type arguments so `List Unit>` is caught the same way a bare + * `suspend (Long) -> Long` is: the generated cast nests the same unusable classifier one level + * down (`kotlin.collections.List>`), and it + * compiles the same way -- observed, not assumed. + * + * A `typealias` for a suspend function type (`typealias LongHandler = suspend (Long) -> Long`) + * needs its own step: measured, not assumed like the case above -- [KSType.isSuspendFunctionType] + * answers `false` for the alias-typed `KSType` itself (its [KSType.declaration] is the + * [KSTypeAlias], and the check does not look through it), so without expanding the alias here + * `runsHandler(handler: LongHandler)` would cast to `fixture.library.LongHandler`, a real + * source-level classifier, and pass every check while still being uncallable underneath. Expanding + * one level of alias (not substituting a generic typealias's own type parameters, which this + * codebase has none of) is enough to make [KSType.isSuspendFunctionType] answer for the real + * shape. + */ +fun isExposableType(type: KSType): Boolean { + val aliasDeclaration = type.declaration as? KSTypeAlias + if (aliasDeclaration != null) return isExposableType(aliasDeclaration.type) + if (type.isSuspendFunctionType) return false + return type.arguments.all { argument -> + val argumentType = argument.type?.resolve() ?: return@all true + isExposableType(argumentType) + } +} + +/** [isExposableType] from the [KSTypeReference] a parameter, return type or property carries. */ +fun isExposableType(typeRef: KSTypeReference): Boolean = isExposableType(typeRef.resolve()) + +/** + * Whether [type] is [python.multiplatform.ffi.PyObject] or one of its subclasses (`PyInt`, + * `PyList`, ... every wrapper the object model exposes). A field of this type is a Python + * reference the Kotlin object holds, and `tp_traverse` must see it -- see + * `docs/design/object-lifetime.md`'s "Cycle collection is part of the table's job". + */ +fun isPyObjectType(type: KSType): Boolean { + val declaration = type.declaration + if (declaration.qualifiedName?.asString() == PY_OBJECT_QUALIFIED_NAME) return true + val classDeclaration = declaration as? KSClassDeclaration ?: return false + return classDeclaration.getAllSuperTypes().any { + it.declaration.qualifiedName?.asString() == PY_OBJECT_QUALIFIED_NAME + } +} diff --git a/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/Model.kt b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/Model.kt new file mode 100644 index 000000000..d669773e2 --- /dev/null +++ b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/Model.kt @@ -0,0 +1,90 @@ +package python.multiplatform.ksp + +/** + * `CallableKind` mirrors `python.multiplatform.reflection.CallableKind` by name -- kept as a + * string here (rather than an import of the runtime enum) so this whole model stays independent + * of the runtime module and is reachable from unit tests that never touch KSP or a real + * classpath. + */ +data class CallableEntryModel( + val name: String, + val arity: Int, + val paramTags: List, + val returnTag: Tag, + val kind: String, + /** The full lambda expression text, e.g. `{ args -> greet(args[0] as String) }`. */ + val lambdaBody: String, + /** + * Mirrors `python.multiplatform.reflection.ExposedCallable.isSuspend`: the declaration was a + * `suspend fun`, so [lambdaBody] starts a coroutine and evaluates to a `PendingCall` rather + * than to the value. [returnTag] still describes the *declared* return type, which is what the + * boundary marshals once the coroutine finishes. + */ + val isSuspend: Boolean = false, + /** + * Mirrors `ExposedCallable.paramNames`: the declared parameter names, in [paramTags] order. + * Always fillable here -- unlike the artefact walker's ASM path over a jar with no debug + * info, KSP reads Kotlin source, where every parameter has a name. Empty only for a shape + * with no parameters to name (arity 0), which is indistinguishable from "not supplied" and is + * why [python.multiplatform.reflection.ExposedCallable]'s own `init` treats an empty list as + * either. + */ + val paramNames: List = emptyList(), + /** + * Mirrors `ExposedCallable.paramTypeNames`: the declared Kotlin type of each parameter -- + * [TypeShape.rendered], not [paramTags] -- so a `Dp`-typed parameter (were one ever visible to + * KSP; this repository's only source of one is the artefact walker) would read `Dp` here and + * `FLOAT` there. Filled from the same [TypeShape] [paramTags] is already computed from, so + * this costs no extra type resolution. + */ + val paramTypeNames: List = emptyList(), + /** + * Mirrors `ExposedCallable.returnTypeName`. This is the field `fbab1a68` gates ownership of a + * `TypeTag.OBJECT` result on: [python.multiplatform.ffi.upcall.PythonProxySource] will not + * wrap a result in a finaliser-bearing owner unless the producer names the Kotlin type it + * came back as, because `OBJECT` also covers a `PyObject` that happens to be an `int`, and + * owning that would release a handle nobody issued. Leaving this `null` was the whole reason + * every KSP-generated entry kept leaking a handle per `TypeTag.OBJECT` result -- + * `WalkedArtifactComposeModifierTest`'s `emptyModifier()` measured one per run. Filled from + * [TypeShape.rendered] wherever a declared return type is known; `null` only where none is + * (there is no such case among the [CallableEntryModel]-producing call sites in + * `FragmentScanner` today). + */ + val returnTypeName: String? = null, + /** + * Mirrors `ExposedCallable.paramHasDefault`. A flag nothing acts on yet -- see the runtime + * field's own doc -- carried because [com.google.devtools.ksp.symbol.KSValueParameter + * .hasDefault] is a direct read, not a derived fact, so there is no reason to withhold it. + */ + val paramHasDefault: List = emptyList(), +) + +data class ClassModel( + val name: String, + val memberNames: List, + /** The full lambda expression text for `traverse`, or `null` if the class has no + * `PyObject`-typed fields and therefore needs no `tp_traverse` slot. */ + val traverseBody: String?, + /** Mirrors `python.multiplatform.reflection.ReflectedClassKind` by name, for the same reason + * [CallableEntryModel.kind] mirrors `CallableKind`. */ + val kind: String = "CLASS", + /** Entry names in declaration order, for [kind] `"ENUM"`; empty otherwise. */ + val enumEntryNames: List = emptyList(), +) + +data class FragmentModel( + val moduleName: String, + val entries: List, + val classes: List, +) + +/** + * Drops entries whose name was already taken, keeping the first. + * + * Two declarations can legally share one exposed name -- an instance property and a + * companion-object property of the same name, or an `expect`/`actual` pair both visible to a + * platform compilation. `UpcallTable.register` only rejects collisions *between* fragments, so a + * duplicate inside one fragment would silently make `resolve` return whichever entry landed last. + * Scanning order puts instance members first, so the receiver-taking entry is the survivor. + */ +fun List.distinctByName(): List = distinctBy { it.name } diff --git a/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/PythonBindingProcessorProvider.kt b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/PythonBindingProcessorProvider.kt new file mode 100644 index 000000000..b1e68e557 --- /dev/null +++ b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/PythonBindingProcessorProvider.kt @@ -0,0 +1,182 @@ +package python.multiplatform.ksp + +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import java.io.OutputStreamWriter + +/** + * The single entry point KSP loads (`META-INF/services/...SymbolProcessorProvider`). + * `docs/design/upcall.md` §2.5: one processor module, two roles selected by + * [OPTION_ROLE] -- a library emits a fragment, an app emits a fragment for itself and then + * aggregates every fragment it can see. + */ +class PythonBindingProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + val moduleName = sanitiseModuleName( + environment.options[OPTION_MODULE_NAME] + ?: error("$OPTION_MODULE_NAME is required (see docs/design/upcall.md §2.5)"), + ) + val excludePackages = environment.options[OPTION_EXCLUDE_PACKAGES] + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?: emptyList() + val scanner = FragmentScanner(excludePackages, environment.logger) + + return when (environment.options[OPTION_ROLE]) { + ROLE_LIBRARY -> LibraryProcessor(environment, scanner, moduleName) + ROLE_APP, null -> AppProcessor(environment, scanner, moduleName, PackageScanFragmentDiscovery()) + else -> error("$OPTION_ROLE must be '$ROLE_LIBRARY' or '$ROLE_APP'") + } + } +} + +private fun writeFile( + environment: SymbolProcessorEnvironment, + packageName: String, + fileName: String, + files: List, + aggregating: Boolean, + content: String, +) { + // `Dependencies(true)` with zero explicit files is a foot-gun: KSP treats it as depending on + // nothing rather than on everything, so a fragment appearing anywhere would never trigger + // re-aggregation. `Dependencies.ALL_FILES` is the named constant for "recompute this file on + // any change" -- the correct one for the aggregator, which reads the whole classpath. + val dependencies = if (aggregating && files.isEmpty()) Dependencies.ALL_FILES else Dependencies(aggregating, *files.toTypedArray()) + environment.codeGenerator.createNewFile(dependencies, packageName, fileName).use { stream -> + OutputStreamWriter(stream).use { it.write(content) } + } +} + +/** + * Emits the `actual` for every `@InstallsUpcallTable expect fun` this compilation carries. + * + * Called from the app role only, and from round 1: the generated file references `FunctionTable`, + * which round 2 has not written yet, and that is fine -- KSP rounds resolve symbols + * independently, and the Kotlin compilation that follows sees both files at once. It is the same + * forward reference the aggregator makes to fragments it discovers. + */ +private fun writeInstallSeams( + environment: SymbolProcessorEnvironment, + resolver: Resolver, +) { + val seams = findInstallSeams(resolver, environment.logger) + if (seams.isEmpty()) return + val originating = installSeamOriginatingFiles(resolver) + for (seam in seams) { + environment.logger.info( + "python-multiplatform-ksp: install seam ${seam.packageName}.${seam.simpleName}", + ) + writeFile( + environment, + seam.packageName, + installSeamFileName(seam.simpleName), + originating, + aggregating = true, + content = renderInstallSeamSource(seam), + ) + } +} + +/** `python.multiplatform.role = library`: scan once, emit one fragment. */ +class LibraryProcessor( + private val environment: SymbolProcessorEnvironment, + private val scanner: FragmentScanner, + private val moduleName: String, +) : SymbolProcessor { + private var invoked = false + + override fun process(resolver: Resolver): List { + if (invoked) return emptyList() + invoked = true + + // A library emits a fragment and no aggregator, so there is no `FunctionTable` here for a + // generated `actual` to call. Saying so is worth a build failure: the alternative is an + // `expect` with no `actual`, reported against the user's commonMain with nothing pointing + // at the role that decided it. + for (seam in findInstallSeams(resolver, environment.logger)) { + environment.logger.error( + "@$INSTALL_SEAM_ANNOTATION on ${seam.packageName}.${seam.simpleName}: only an app-role module " + + "aggregates a FunctionTable. Move it to the module that produces the binary, or set " + + "`pythonBindings { role.set(\"app\") }` on this one.", + ) + } + + val result = scanner.scan(resolver, moduleName) + if (result.fragment.entries.isEmpty() && result.fragment.classes.isEmpty()) return emptyList() + + writeFile( + environment, + FRAGMENTS_PACKAGE, + fragmentObjectName(moduleName), + result.originatingFiles, + aggregating = true, + content = renderFragmentSource(result.fragment), + ) + return emptyList() + } +} + +/** + * `python.multiplatform.role = app`: round 1 does what [LibraryProcessor] does for the app's own + * sources; round 2 discovers every `Fragment_*` visible on the classpath (its own, just + * generated, plus every dependency's) and emits the aggregator. See + * `docs/design/upcall.md` §2.3/§6 for why this needs two rounds -- round 1's own fragment + * has to exist as a compiled/generated symbol before [discovery] can see it alongside the + * others. + * + * Round 1 also emits the `actual` for every `@InstallsUpcallTable expect fun` + * ([writeInstallSeams]), which is how shared code reaches a `FunctionTable` it cannot name. + */ +class AppProcessor( + private val environment: SymbolProcessorEnvironment, + private val scanner: FragmentScanner, + private val moduleName: String, + private val discovery: FragmentDiscovery, +) : SymbolProcessor { + private var round = 0 + + override fun process(resolver: Resolver): List { + round++ + + if (round == 1) { + writeInstallSeams(environment, resolver) + val result = scanner.scan(resolver, moduleName) + if (result.fragment.entries.isNotEmpty() || result.fragment.classes.isNotEmpty()) { + writeFile( + environment, + FRAGMENTS_PACKAGE, + fragmentObjectName(moduleName), + result.originatingFiles, + aggregating = true, + content = renderFragmentSource(result.fragment), + ) + } + return emptyList() + } + + if (round == 2) { + val fragments = discovery.discoverFragments(resolver) + environment.logger.info("python-multiplatform-ksp: aggregating ${fragments.size} fragment(s): $fragments") + // Not `Dependencies(false)`: the doc's open question #5 flags this as unresolved -- + // tracking individual fragment files would let a source change outside this module + // skip re-aggregation, but discovery runs over the whole classpath, not a fixed file + // set, so there is nothing narrower to hand Dependencies short of `isAllSources`. + writeFile( + environment, + AGGREGATOR_PACKAGE, + "FunctionTable", + emptyList(), + aggregating = true, + content = renderAggregatorSource(fragments), + ) + } + + return emptyList() + } +} diff --git a/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/SourceRendering.kt b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/SourceRendering.kt new file mode 100644 index 000000000..c2e953ec8 --- /dev/null +++ b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/SourceRendering.kt @@ -0,0 +1,139 @@ +package python.multiplatform.ksp + +private const val FRAGMENT_INTERFACE = "python.multiplatform.reflection.FunctionTableFragment" +private const val EXPOSED_CALLABLE = "python.multiplatform.reflection.ExposedCallable" +private const val REFLECTED_CLASS = "python.multiplatform.reflection.ReflectedClass" +private const val TYPE_TAG = "python.multiplatform.reflection.TypeTag" +private const val CALLABLE_KIND = "python.multiplatform.reflection.CallableKind" +private const val REFLECTED_CLASS_KIND = "python.multiplatform.reflection.ReflectedClassKind" + +private fun String.quoted(): String = "\"" + replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + +private fun renderEntry(entry: CallableEntryModel): String { + val paramTags = entry.paramTags.joinToString(", ") { "$TYPE_TAG.$it" } + // Every one of these is omitted rather than rendered at its default (`false`/`null`/empty): + // the runtime default already says so, and every entry in every fragment would otherwise + // carry a line that means nothing. `isExtension`/`receiverTypeName` are not rendered at all + // -- not merely omitted -- because no KSP-produced entry is ever an extension + // (`BindingPolicy.isExposedFunctionShape` rejects `extensionReceiver != null` before a + // `CallableEntryModel` is built for it), so the runtime's own `false`/`null` defaults are + // never wrong here and there is nothing for this producer to say. + val suspendArg = if (entry.isSuspend) "\n| isSuspend = true," else "" + val paramNamesArg = if (entry.paramNames.isEmpty()) { + "" + } else { + "\n| paramNames = listOf(${entry.paramNames.joinToString(", ") { it.quoted() }})," + } + val paramTypeNamesArg = if (entry.paramTypeNames.isEmpty()) { + "" + } else { + "\n| paramTypeNames = listOf(${entry.paramTypeNames.joinToString(", ") { it.quoted() }})," + } + val returnTypeNameArg = if (entry.returnTypeName == null) "" else "\n| returnTypeName = ${entry.returnTypeName.quoted()}," + val paramHasDefaultArg = if (entry.paramHasDefault.isEmpty()) { + "" + } else { + "\n| paramHasDefault = listOf(${entry.paramHasDefault.joinToString(", ")})," + } + return """ + |$EXPOSED_CALLABLE( + | name = ${entry.name.quoted()}, + | arity = ${entry.arity}, + | paramTypes = listOf($paramTags), + | returnType = $TYPE_TAG.${entry.returnTag}, + | kind = $CALLABLE_KIND.${entry.kind},$suspendArg$paramNamesArg$paramTypeNamesArg$returnTypeNameArg$paramHasDefaultArg + | callable = ${entry.lambdaBody}, + |) + """.trimMargin() +} + +private fun renderClass(cls: ClassModel): String { + val members = cls.memberNames.joinToString(", ") { it.quoted() } + val traverseArg = if (cls.traverseBody != null) ",\n traverse = ${cls.traverseBody}" else "" + val entryNamesArg = if (cls.enumEntryNames.isEmpty()) { + "" + } else { + ",\n enumEntryNames = listOf(${cls.enumEntryNames.joinToString(", ") { it.quoted() }})" + } + return """ + |$REFLECTED_CLASS( + | name = ${cls.name.quoted()}, + | memberNames = listOf($members), + | kind = $REFLECTED_CLASS_KIND.${cls.kind}$entryNamesArg$traverseArg, + |) + """.trimMargin() +} + +/** + * Renders one `Fragment_.kt` file body -- what a library-role KSP invocation emits. + * + * The object is `public` rather than `internal` as `docs/design/upcall.md`'s sketch shows + * it: `internal` is enforced per Kotlin *module*, and the app module compiling generated source + * that references a library's fragment is a different module by that rule even inside the same + * Gradle build. An `internal` fragment fails to compile from the app side with "cannot access + * ... it is internal in ...". Nothing about the design needs the fragment hidden from Python -- + * it is reached only by name, through [python.multiplatform.reflection.UpcallTable] -- so the + * relaxation costs nothing. + */ +fun renderFragmentSource(model: FragmentModel): String { + val objectName = fragmentObjectName(model.moduleName) + val entries = model.entries.joinToString(",\n") { renderEntry(it).prependIndent(" ") } + val classes = model.classes.joinToString(",\n") { renderClass(it).prependIndent(" ") } + return buildString { + // Every argument read is `args[i] as SomeType` out of an `Array`, which is + // unchecked whenever the type has type arguments (`List`). Suppressing at file + // level rather than per cast keeps the generated source readable and the build quiet. + appendLine("@file:Suppress(\"UNCHECKED_CAST\")") + appendLine() + appendLine("// GENERATED by python-multiplatform-ksp. Do not edit.") + appendLine("package $FRAGMENTS_PACKAGE") + appendLine() + if (model.classes.any { it.traverseBody != null }) { + // `.pointer.toRawValue()` in a generated traverse body resolves through this import + // rather than a fully-qualified call, to keep the rendered lambda readable. + appendLine("import python.native.ffi.toRawValue") + appendLine() + } + appendLine("object $objectName : $FRAGMENT_INTERFACE {") + appendLine(" override val moduleName: String = ${model.moduleName.quoted()}") + appendLine() + appendLine(" override fun entries(): List<$EXPOSED_CALLABLE> = listOf(") + if (entries.isNotEmpty()) appendLine(entries) + appendLine(" )") + appendLine() + appendLine(" override fun classes(): List<$REFLECTED_CLASS> = listOf(") + if (classes.isNotEmpty()) appendLine(classes) + appendLine(" )") + appendLine("}") + } +} + +/** + * Renders `FunctionTable.kt` -- the app-role aggregator. [fragmentQualifiedNames] is the result + * of fragment discovery (`docs/design/upcall.md` §2.3): every `Fragment_*` object this app + * module and its dependencies emitted, found via + * [FragmentDiscovery][python.multiplatform.ksp.FragmentDiscovery] and referenced here + * explicitly. That explicit reference is what keeps every fragment reachable to the Kotlin/Native + * linker with no `ServiceLoader` and no `@EagerInitialization`. + */ +fun renderAggregatorSource(fragmentQualifiedNames: List): String = buildString { + appendLine("// GENERATED by python-multiplatform-ksp. Do not edit.") + appendLine("package $AGGREGATOR_PACKAGE") + appendLine() + appendLine("object FunctionTable {") + appendLine(" /** Every fragment this module can see: itself, plus every dependency that") + appendLine(" * carries one. Referencing them here -- rather than letting each fragment") + appendLine(" * register itself -- is what makes them reachable without a ServiceLoader.") + appendLine(" */") + appendLine(" val fragments: List<$FRAGMENT_INTERFACE> = listOf(") + fragmentQualifiedNames.forEach { appendLine(" $it,") } + appendLine(" )") + appendLine() + appendLine(" /** Convenience for the common case: replace whatever") + appendLine(" * [python.multiplatform.reflection.UpcallTable] currently holds with exactly") + appendLine(" * this module graph's fragments. */") + appendLine(" fun installInto() {") + appendLine(" python.multiplatform.reflection.UpcallTable.install(fragments)") + appendLine(" }") + appendLine("}") +} diff --git a/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/TypeShape.kt b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/TypeShape.kt new file mode 100644 index 000000000..73e8c2af9 --- /dev/null +++ b/python-multiplatform-ksp/src/main/kotlin/python/multiplatform/ksp/TypeShape.kt @@ -0,0 +1,77 @@ +package python.multiplatform.ksp + +/** + * The boundary's five-plus-two marshalling categories (`docs/design/upcall.md` §2.1, + * `python.multiplatform.reflection.TypeTag`). Kept as a processor-local copy rather than a + * dependency on the runtime module's enum so this file has no KSP or runtime import and its + * mapping logic is plain-JVM-testable. + */ +enum class Tag { INT, FLOAT, BOOLEAN, STRING, BYTES, UNIT, OBJECT } + +/** + * Everything the cast/wrap logic needs about one parameter or return type, independent of KSP. + * A `KSType` is reduced to this before any codegen decision is made, so [tagFor], + * [castExpression] and [wrapReturnExpression] can be unit-tested without a real `Resolver`. + */ +data class TypeShape( + val qualifiedName: String, + val nullable: Boolean, + /** + * The type as it must appear in generated source, **without** the trailing `?` -- + * [qualifiedName] plus its type arguments where it has them, e.g. + * `kotlin.collections.List`. A cast to the bare qualified name of a generic + * type is not valid Kotlin ("One type argument expected"), and that surfaced as a compile + * failure of the *generated* file rather than as anything the processor could notice. + */ + val rendered: String = qualifiedName, +) + +/** Which [Tag] a Kotlin type is marshalled as. Anything not one of the seven primitives crosses + * as [Tag.OBJECT] -- a raw Kotlin reference, not a boxed value. */ +fun tagFor(shape: TypeShape): Tag = when (shape.qualifiedName) { + "kotlin.Long", "kotlin.Int", "kotlin.Short", "kotlin.Byte" -> Tag.INT + "kotlin.Double", "kotlin.Float" -> Tag.FLOAT + "kotlin.Boolean" -> Tag.BOOLEAN + "kotlin.String" -> Tag.STRING + "kotlin.ByteArray" -> Tag.BYTES + "kotlin.Unit" -> Tag.UNIT + else -> Tag.OBJECT +} + +/** + * How to read one parameter out of the `args: Array` the trampoline builds, given the + * expression [argsExpr] that indexes into it (e.g. `"args[0]"`). + * + * The boundary carries exactly one representation per [Tag] -- `Long` for every INT-tagged + * value regardless of whether the Kotlin parameter is `Int`, `Short` or `Byte`; `Double` for + * every FLOAT-tagged one -- so a narrower declared type needs a narrowing conversion on top of + * the plain cast. `TestFragments.kt`'s hand-written entries only ever declare `Long`/`Double` + * parameters, which is why this case never shows up there; a generated entry over a user's + * `fun scale(by: Int)` needs it. + */ +fun castExpression(shape: TypeShape, argsExpr: String): String { + val q = if (shape.nullable) "?" else "" + return when (shape.qualifiedName) { + "kotlin.Long" -> "$argsExpr as Long$q" + "kotlin.Double" -> "$argsExpr as Double$q" + "kotlin.Boolean" -> "$argsExpr as Boolean$q" + "kotlin.String" -> "$argsExpr as String$q" + "kotlin.ByteArray" -> "$argsExpr as ByteArray$q" + "kotlin.Int" -> if (shape.nullable) "($argsExpr as Long?)?.toInt()" else "($argsExpr as Long).toInt()" + "kotlin.Short" -> if (shape.nullable) "($argsExpr as Long?)?.toShort()" else "($argsExpr as Long).toShort()" + "kotlin.Byte" -> if (shape.nullable) "($argsExpr as Long?)?.toByte()" else "($argsExpr as Long).toByte()" + "kotlin.Float" -> if (shape.nullable) "($argsExpr as Double?)?.toFloat()" else "($argsExpr as Double).toFloat()" + else -> "$argsExpr as ${shape.rendered}$q" + } +} + +/** Wraps a call-result expression [callExpr] into the canonical boundary representation for + * [shape]'s [Tag]. The inverse widening to [castExpression]'s narrowing. */ +fun wrapReturnExpression(shape: TypeShape, callExpr: String): String = when (shape.qualifiedName) { + "kotlin.Int", "kotlin.Short", "kotlin.Byte" -> + if (shape.nullable) "($callExpr)?.toLong()" else "($callExpr).toLong()" + "kotlin.Float" -> + if (shape.nullable) "($callExpr)?.toDouble()" else "($callExpr).toDouble()" + "kotlin.Unit" -> "$callExpr; Unit" + else -> callExpr +} diff --git a/python-multiplatform-ksp/src/main/python/typedpython/__init__.py b/python-multiplatform-ksp/src/main/python/typedpython/__init__.py new file mode 100644 index 000000000..507809dab --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/__init__.py @@ -0,0 +1,4 @@ +"""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 new file mode 100644 index 000000000..6534afebf --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/__main__.py @@ -0,0 +1,155 @@ +"""`python -m typedpython check [--mode checked|compiled] [--format text|json] PATHS...` + +Exit status: 0 no errors (warnings allowed), 1 errors, 2 the gate itself could not run. +""" +import argparse +import json +import sys +from pathlib import Path + +from typedpython import gate +from typedpython.paths import python_files +from typedpython.pyrefly import PyreflyError + +EXIT_CLEAN, EXIT_ERRORS, EXIT_FAILURE = 0, 1, 2 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="typedpython") + commands = parser.add_subparsers(dest="command", required=True) + check = commands.add_parser("check", help="type-check user Python code") + check.add_argument("paths", nargs="+", type=Path, help="files, or directories to check recursively") + check.add_argument("--mode", choices=["checked", "compiled"], default="checked") + check.add_argument("--format", choices=["text", "json"], default="text") + check.add_argument("--search-path", action="append", type=Path, default=[], + help="directory to resolve imports from (repeatable)") + demo = commands.add_parser( + "demo", help="compile a module, run its main() interpreted and compiled, and compare", + ) + demo.add_argument("module", type=Path) + demo.add_argument("argv", nargs="*", help="sys.argv[1:] for the module's main()") + demo.add_argument("--out", type=Path, default=Path("build/typedpython"), + help="where the extension is built (default: build/typedpython)") + build = commands.add_parser("build", help="compile modules incrementally (cached per module)") + build.add_argument("paths", nargs="+", type=Path, help="files, or directories to build recursively") + build.add_argument("--cache", type=Path, default=Path("build/typedpython-cache"), + help="incremental cache directory (default: build/typedpython-cache)") + build.add_argument("--out", type=Path, default=None, + help="copy the built extensions here (default: leave them in the cache)") + args = parser.parse_args(argv) + + if args.command == "build": + return _build(args.paths, args.cache, args.out) + if args.command == "demo": + return _demo(args.module, args.argv, args.out) + + try: + found = gate.check(python_files(args.paths), mode=args.mode, search_paths=args.search_path) + except PyreflyError as e: + print(f"typedpython: {e}", file=sys.stderr) + return EXIT_FAILURE + + if args.format == "json": + print(json.dumps([d.to_json() for d in found], indent=2)) + else: + for d in found: + print(f"{d.severity.upper()} {d.path}:{d.line}:{d.column}: {d.message} [{d.rule}]") + + return EXIT_ERRORS if any(d.severity == "error" for d in found) else EXIT_CLEAN + + +def _build(paths: list[Path], cache: Path, out: Path | None) -> int: + import os + import shutil + + from typedpython import pipeline + + sources = python_files(paths) + if not sources: + print("typedpython: no Python files to build", file=sys.stderr) + return EXIT_FAILURE + dirs = [p.resolve() for p in paths if p.is_dir()] or [p.resolve().parent for p in paths] + root = Path(os.path.commonpath([str(d) for d in dirs])) + try: + result = pipeline.compile_project(sources, root, cache) + except pipeline.CompileError as e: + print(f"typedpython: {e}", file=sys.stderr) + return EXIT_ERRORS + except PyreflyError as e: + print(f"typedpython: {e}", file=sys.stderr) + return EXIT_FAILURE + for m in result.modules: + why = f" ({', '.join(m.reasons)})" if m.reasons else "" + what = m.extension.name if m.extension else "no extension (interpreted)" + print(f"{m.status:7} {m.name}: {what}{why}") + for name, reason in sorted(m.skipped.items()): + print(f" interpreted: {name} — {reason}") + if out is not None and m.extension is not None: + out.mkdir(parents=True, exist_ok=True) + shutil.copy2(m.extension, out / m.extension.name) + return EXIT_CLEAN + + +def _demo(module: Path, module_argv: list[str], out: Path) -> int: + import contextlib + import importlib.util + import io + import time + + from typedpython import pipeline + + try: + result = pipeline.compile_module(module, out / module.stem) + except pipeline.CompileError as e: + print(f"typedpython: {e}", file=sys.stderr) + return EXIT_ERRORS + except PyreflyError as e: + print(f"typedpython: {e}", file=sys.stderr) + return EXIT_FAILURE + + def interpreted() -> object: + spec = importlib.util.spec_from_file_location(module.stem + "_interpreted", module) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def run(load: object) -> tuple[str, float]: + mod = load() # type: ignore[operator] + saved = sys.argv + sys.argv = [str(module), *module_argv] + captured = io.StringIO() + try: + with contextlib.redirect_stdout(captured): + start = time.perf_counter() + mod.main() + elapsed = time.perf_counter() - start + finally: + sys.argv = saved + return captured.getvalue(), elapsed + + 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}") + 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 " + f"speedup {t_i / t_c:.1f}x") + if out_i != out_c: + print("output differs:") + print(f" interpreted: {out_i!r}") + print(f" compiled: {out_c!r}") + return EXIT_ERRORS + print("identical output:") + print(out_c, end="") + return EXIT_CLEAN + + +def cli() -> None: + """Console-script entry point (`typedpython check ...`).""" + sys.exit(main(sys.argv[1:])) + + +if __name__ == "__main__": + cli() diff --git a/python-multiplatform-ksp/src/main/python/typedpython/builtin_stubs/__builtins__.pyi b/python-multiplatform-ksp/src/main/python/typedpython/builtin_stubs/__builtins__.pyi new file mode 100644 index 000000000..f773368b9 --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/builtin_stubs/__builtins__.pyi @@ -0,0 +1,3 @@ +# TypedPython's builtin names (docs/design/typedpython.md §2.3). A `__builtins__.pyi` on the type +# checker's search path declares names the interpreter provides without an import. +def compiled[T](f: T, /) -> T: ... diff --git a/python-multiplatform-ksp/src/main/python/typedpython/cbuild.py b/python-multiplatform-ksp/src/main/python/typedpython/cbuild.py new file mode 100644 index 000000000..3872a4bbe --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/cbuild.py @@ -0,0 +1,84 @@ +"""Build a generated C file (cgen) into a CPython extension with the platform C compiler. + +No setuptools, no Cython: one compiler invocation, `-shared -fPIC`, on macOS +`-undefined dynamic_lookup` (the extension resolves the C API from the process that loads it). +The headers and the extension suffix default to the host interpreter's (`sysconfig`), and can be +pointed at the CPython the app embeds (`python_include`, `ext_suffix`). +""" +from __future__ import annotations + +import importlib.machinery +import importlib.util +import shlex +import subprocess +import sys +import sysconfig +from pathlib import Path + +RUNTIME_DIR = Path(__file__).resolve().parent / "runtime" + +# -ffp-contract=off: a fused multiply-add rounds once where CPython rounds twice. +DEFAULT_FLAGS: tuple[str, ...] = ( + "-O2", + "-ffp-contract=off", + "-fno-fast-math", + "-Werror=implicit-function-declaration", +) + + +class CBuildError(RuntimeError): + """The C compiler rejected the generated source; the message carries its stderr.""" + + +def _python_include(python_include: Path | None) -> Path: + if python_include is None: + return Path(sysconfig.get_paths()["include"]) + p = Path(python_include) + if (p / "Python.h").is_file(): + return p + found = sorted(d for d in p.glob("python3*") if (d / "Python.h").is_file()) + if len(found) == 1: + return found[0] + raise CBuildError(f"no Python.h in {p} (nor in exactly one python3.*/ below it: {found})") + + +def compiler() -> list[str]: + return shlex.split(sysconfig.get_config_var("CC") or "cc") + + +def build(c_source: str, module_name: str, out_dir: Path, runtime_dir: Path | None = None, + flags: list[str] | tuple[str, ...] = DEFAULT_FLAGS, python_include: Path | None = None, + ext_suffix: str | None = None) -> Path: + """Compile `c_source` to `/`; return it.""" + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + stem = module_name.rpartition(".")[2] + suffix = ext_suffix if ext_suffix is not None else sysconfig.get_config_var("EXT_SUFFIX") + if not suffix: + raise CBuildError("no extension suffix (sysconfig EXT_SUFFIX is empty); pass ext_suffix") + runtime = Path(runtime_dir) if runtime_dir is not None else RUNTIME_DIR + if not (runtime / "tp_runtime.h").is_file(): + raise CBuildError(f"tp_runtime.h not found in {runtime}") + include = _python_include(python_include) + c_file = out_dir / f"{stem}.c" + c_file.write_text(c_source, encoding="utf-8") + so = out_dir / f"{stem}{suffix}" + cmd = compiler() + list(flags) + ["-fPIC", "-shared", f"-I{include}", f"-I{runtime}"] + if sys.platform == "darwin": + cmd += ["-undefined", "dynamic_lookup"] + cmd += [str(c_file), "-o", str(so)] + run = subprocess.run(cmd, capture_output=True, text=True) + if run.returncode != 0: + raise CBuildError(f"C compiler failed ({run.returncode}): {shlex.join(cmd)}\n{run.stderr}") + return so + + +def load(module_name: str, path: Path): + """Import the built extension at `path` as `module_name` (not registered in sys.modules).""" + loader = importlib.machinery.ExtensionFileLoader(module_name, str(path)) + spec = importlib.util.spec_from_file_location(module_name, str(path), loader=loader) + if spec is None: + raise ImportError(f"cannot make a spec for {path}") + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module diff --git a/python-multiplatform-ksp/src/main/python/typedpython/cgen.py b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py new file mode 100644 index 000000000..f5c4f7b6d --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/cgen.py @@ -0,0 +1,1338 @@ +"""TypedPython C back end: `ir.Module` -> one C source file (a CPython extension, PEP 489). + +The contract is `ir.py`; this file implements each node exactly as its docstring says, and reaches +anything with Python semantics only through the runtime helpers of `runtime/API.md` +(`tp_runtime.h`). What the generated file contains: + +Module shape (ir.py "Module shape"). Multi-phase init. The exec slot runs the module's ORIGINAL +source (read at build time and embedded as a C string literal, compiled with its own path as the +filename) in the extension module's own dict, so every name, class and global is the interpreted +one. It then stores each compiled function's interpreted original in `__typedpython_interpreted__` +(a dict), sets `__typedpython_deopts__ = 0`, and replaces the global `f` with a C function +(METH_FASTCALL | METH_KEYWORDS) bound to the module. + +Per function `f`: + `static int tp_impl_f(PyObject *tp_module, , *tp_out)` returns 0 ok, 1 deopt, + -1 error. Scalars are passed by value, OBJ params borrowed (the impl takes its own reference), + array params as `tp_*_array *`. + `tp_wrap_f` is the Python-visible function: wrong arity or any keyword -> the interpreted + function (CPython raises its own TypeError); entry guards (exact scalar types through + tp_unbox_*, the array aliasing guard tp_any_same, then tp_*_array_enter); the impl; then + tp_*_array_exit on every path (ok, error, deopt) before returning or redoing. A deopt (failed + guard, or the impl's 1 in a pure function) increments `__typedpython_deopts__` and calls + `__typedpython_interpreted__[f]` with the original arguments. + +Depth (issue #57): every impl function calls `tp_enter_call()` before it owns anything (a refusal +returns -1 directly: RecursionError set, nothing counted) and `tp_leave_call()` in the one exit +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; + 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 + 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. + +Local arrays (ir.NewArray / CopyArray, `Function` docstring "Local arrays"). A local of an array type +is a `tp_i64_array` / `tp_f64_array` struct held by value in the impl, zero-initialised at entry +(`{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 + 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 + 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 + a store into one sets no dirty bit. +`Index` / `StoreIndex` / `Len` use the same slot helper and `proven` handling as array params. +`Tuple` evaluates each element into an owned temporary, builds the tuple with `tp_tuple` (which +steals nothing), then releases the temporaries. + +Generated arithmetic is in separate C statements and the file sets `#pragma STDC FP_CONTRACT OFF` +(cbuild also passes -ffp-contract=off): a fused multiply-add would round differently from CPython. +""" +from __future__ import annotations + +from pathlib import Path + +from typedpython import ir +from typedpython.ir import Type + + +class CGenError(Exception): + """The IR cannot be lowered as specified (a contract violation by the producer of the IR).""" + + +_CT = {Type.I64: "int64_t", Type.F64: "double", Type.BOOL: "int", Type.OBJ: "PyObject *"} +_ARRAY_CT = {Type.F64_ARRAY: "tp_f64_array", Type.I64_ARRAY: "tp_i64_array"} +_CT_ARRAY_REV = {"tp_f64_array": Type.F64_ARRAY, "tp_i64_array": Type.I64_ARRAY} +_ARRAY_ELEM = {Type.F64_ARRAY: Type.F64, Type.I64_ARRAY: Type.I64} +_ARRAY_PFX = {Type.F64_ARRAY: "tp_f64_array", Type.I64_ARRAY: "tp_i64_array"} +_UNBOX = {Type.I64: "tp_unbox_i64", Type.F64: "tp_unbox_f64", Type.BOOL: "tp_unbox_bool"} +_BOX = {Type.I64: "tp_box_i64", Type.F64: "tp_box_f64", Type.BOOL: "tp_box_bool"} +_PYCMP = {ir.CompareKind.EQ: "Py_EQ", ir.CompareKind.NE: "Py_NE", ir.CompareKind.LT: "Py_LT", + ir.CompareKind.LE: "Py_LE", ir.CompareKind.GT: "Py_GT", ir.CompareKind.GE: "Py_GE"} +_I64_OP = {ir.BinOpKind.ADD: "tp_add_i64", ir.BinOpKind.SUB: "tp_sub_i64", + ir.BinOpKind.MUL: "tp_mul_i64", ir.BinOpKind.FLOORDIV: "tp_floordiv_i64", + ir.BinOpKind.MOD: "tp_mod_i64", ir.BinOpKind.TRUEDIV: "tp_truediv_i64"} +_F64_OP = {ir.BinOpKind.TRUEDIV: "tp_truediv_f64", ir.BinOpKind.FLOORDIV: "tp_floordiv_f64", + ir.BinOpKind.MOD: "tp_mod_f64"} +_F64_INLINE = {ir.BinOpKind.ADD: "+", ir.BinOpKind.SUB: "-", ir.BinOpKind.MUL: "*"} +_OBJ_OP = {k: f"TP_BINOP_{k.name}" for k in ir.BinOpKind} +_MATH = {ir.MathFunc.SQRT: 1, ir.MathFunc.EXP: 1, ir.MathFunc.LOG: 1, ir.MathFunc.SIN: 1, + ir.MathFunc.COS: 1, ir.MathFunc.TAN: 1, ir.MathFunc.FABS: 1, ir.MathFunc.ATAN2: 2, + ir.MathFunc.HYPOT: 2} +# I64 operations whose helper can return 1 (API.md): MOD never overflows (MIN % -1 == 0). +_I64_DEOPT = {ir.BinOpKind.ADD, ir.BinOpKind.SUB, ir.BinOpKind.MUL, ir.BinOpKind.FLOORDIV, + ir.BinOpKind.TRUEDIV} +_INT64_MIN, _INT64_MAX = -(2 ** 63), 2 ** 63 - 1 + + +# --- C literals and identifiers ------------------------------------------------------------------ + +def c_string(data: str | bytes) -> str: + """A C string literal for `data` (UTF-8). Octal escapes only (hex escapes are greedy), `?` + escaped against trigraphs; split after each newline so long sources stay readable.""" + raw = data.encode("utf-8") if isinstance(data, str) else data + out, pieces = [], [] + for b in raw: + ch = chr(b) + if ch in '\\"?': + out.append("\\" + ch) + elif 0x20 <= b < 0x7F: + out.append(ch) + else: + out.append("\\%03o" % b) + if b == 0x0A: + pieces.append('"' + "".join(out) + '"') + out = [] + if out or not pieces: + pieces.append('"' + "".join(out) + '"') + return "\n ".join(pieces) + + +def _ident(prefix: str, name: str) -> str: + """A C identifier for a Python name: ASCII names keep their spelling, others are hex-encoded + under a distinct prefix so the two forms cannot collide.""" + if name.isascii() and name.isidentifier(): + return f"{prefix}_{name}" + return f"{prefix}x_{name.encode('utf-8').hex()}" + + +def _i64_lit(v: int) -> str: + if isinstance(v, bool) or not isinstance(v, int) or not (_INT64_MIN <= v <= _INT64_MAX): + raise CGenError(f"I64 constant out of range or not an int: {v!r}") + if v == _INT64_MIN: + return "(-INT64_C(9223372036854775807) - 1)" + return f"INT64_C({v})" + + +def _f64_lit(v: float) -> str: + v = float(v) + if v != v: + return "(-Py_NAN)" if _sign_bit(v) else "Py_NAN" + if v in (float("inf"), float("-inf")): + return "Py_HUGE_VAL" if v > 0 else "(-Py_HUGE_VAL)" + return f"({v.hex()})" + + +def _sign_bit(v: float) -> bool: + import struct + return struct.pack(">d", v)[0] >> 7 == 1 + + +# --- IR walks ------------------------------------------------------------------------------------ + +def _exprs_of_stmt(s: ir.Stmt): + if isinstance(s, ir.Assign): + yield s.value + elif isinstance(s, ir.StoreIndex): + yield s.value + yield s.index + elif isinstance(s, ir.ExprStmt): + yield s.value + elif isinstance(s, ir.If): + yield s.cond + elif isinstance(s, ir.While): + yield s.cond + elif isinstance(s, ir.ForRange): + yield s.start + yield s.stop + elif isinstance(s, ir.Return) and s.value is not None: + yield s.value + + +def _children(s: ir.Stmt): + if isinstance(s, ir.If): + return s.then + s.orelse + if isinstance(s, (ir.While, ir.ForRange)): + return s.body + return () + + +def _walk_stmts(body): + for s in body: + yield s + yield from _walk_stmts(_children(s)) + + +def _sub_exprs(e: ir.Expr): + for name in ("left", "right", "operand", "index", "obj", "callee", "length", "fill"): + v = getattr(e, name, None) + if isinstance(v, ir.Expr): + yield v + for v in getattr(e, "args", ()) or (): + yield v + for v in getattr(e, "elements", ()) or (): + yield v + + +def _walk_exprs(e: ir.Expr): + yield e + for c in _sub_exprs(e): + yield from _walk_exprs(c) + + +def _all_exprs(f: ir.Function): + for s in _walk_stmts(f.body): + for e in _exprs_of_stmt(s): + yield from _walk_exprs(e) + + +def _can_deopt(e: ir.Expr, functions: dict[str, ir.Function]) -> bool: + if isinstance(e, ir.BinOp) and e.left.type is Type.I64 and e.op in _I64_DEOPT: + return not e.proven # a proven op cannot deopt (overflow = SystemError) + if isinstance(e, ir.UnaryOp) and e.op is ir.UnaryOpKind.NEG and e.operand.type is Type.I64: + return not e.proven + if isinstance(e, ir.Unbox): + return True + if isinstance(e, ir.Call): + if e.redo: + return False # the callee's deopt is absorbed by its interpreted redo + callee = functions.get(e.function) + # a callee's entry-global guard runs at the call (`_FnGen.ev_Call`) and can fail there + return callee is not None and (callee.may_deopt or bool(callee.entry_globals)) + return False + + +def _is_effect(e: ir.Expr) -> bool: + if isinstance(e, (ir.GetAttr, ir.CallObject, ir.Truth, ir.CompareObj, ir.ObjToFloat)): + return True + return isinstance(e, ir.BinOp) and e.type is Type.OBJ + + +# --- module -------------------------------------------------------------------------------------- + +class _ModGen: + def __init__(self, module: ir.Module): + self.module = module + self.functions = {f.name: f for f in module.functions} + if len(self.functions) != len(module.functions): + raise CGenError("duplicate compiled function names") + self.names: list[str] = [] + self.kwtuples: list[tuple[str, ...]] = [] + + def name_index(self, s: str) -> int: + if s not in self.names: + self.names.append(s) + return self.names.index(s) + + def kw_index(self, kw: tuple[str, ...]) -> int: + for k in kw: + self.name_index(k) + if kw not in self.kwtuples: + self.kwtuples.append(kw) + return self.kwtuples.index(kw) + + +def generate(module: ir.Module, source_path: Path) -> str: + """One C file implementing `module`; `source_path` is the original Python source, embedded.""" + source_path = Path(source_path) + source = source_path.read_text(encoding="utf-8") + mg = _ModGen(module) + for f in module.functions: + _check_function(f, mg.functions) + for f in module.functions: + mg.name_index(f.name) + bodies = [_FnGen(mg, f).generate() for f in module.functions] + init_name = module.name.rpartition(".")[2] + if not (init_name.isascii() and init_name.isidentifier()): + raise CGenError(f"module name {module.name!r} cannot name a PyInit_ function") + + out: list[str] = [] + w = out.append + w(f"/* Generated by typedpython.cgen from {source_path.name} — do not edit. */") + w("#define PY_SSIZE_T_CLEAN") + w("#include ") + w("#include ") + w('#include "tp_runtime.h"') + w("") + w("#if PY_VERSION_HEX < 0x030D0000") + w('#error "typedpython extensions need CPython 3.13 or newer (PyDict_GetItemRef)"') + w("#endif") + w("#pragma STDC FP_CONTRACT OFF") + w("") + w(f"static const char tp_source[] =\n {c_string(source)};") + w(f"static const char tp_source_path[] = {c_string(str(source_path))};") + w("") + n_names, n_kw = len(mg.names), len(mg.kwtuples) + w("/* Module state: interned names (globals, attributes, keywords, function names) and the") + w(" kwnames tuples of keyword calls. Owned by the module; released in tp_clear. */") + w("typedef struct {") + w(f" PyObject *names[{n_names + 1}];") + w(f" PyObject *kwnames[{n_kw + 1}];") + w("} tp_state;") + w("") + w("static const char *const tp_name_strings[] = {") + for s in mg.names: + w(f" {c_string(s)},") + w(" NULL") + w("};") + w("") + for f in module.functions: + w(_impl_signature(f) + ";") + w("") + w(_TEMPLATE_REDO) + for b in bodies: + w(b) + # method table + w("static PyMethodDef tp_methods[] = {") + for f in module.functions: + w(f" {{{c_string(f.name)}, (PyCFunction)(void (*)(void)){_ident('tp_wrap', f.name)}, " + f"METH_FASTCALL | METH_KEYWORDS, NULL}},") + w(" {NULL, NULL, 0, NULL}") + w("};") + w("") + w(_exec_function(mg)) + w(_TEMPLATE_STATE) + w("static PyModuleDef_Slot tp_slots[] = {") + w(" {Py_mod_exec, (void *)tp_exec},") + w(" {0, NULL}") + w("};") + w("") + w("static struct PyModuleDef tp_moduledef = {") + w(f" PyModuleDef_HEAD_INIT, {c_string(module.name)}, NULL, sizeof(tp_state), NULL, tp_slots,") + w(" tp_traverse, tp_clear, tp_free") + w("};") + w("") + w(f"PyMODINIT_FUNC PyInit_{init_name}(void) {{ return PyModuleDef_Init(&tp_moduledef); }}") + return "\n".join(out) + "\n" + + +def _check_function(f: ir.Function, functions: dict[str, ir.Function]) -> None: + deopting = [e for e in _all_exprs(f) if _can_deopt(e, functions)] + if deopting and not f.pure: + raise CGenError(f"{f.name}: a node that can deopt ({type(deopting[0]).__name__}) in an " + "impure function (ir.py: only a pure function may deopt after entry)") + if deopting and not f.may_deopt: + raise CGenError(f"{f.name}: may_deopt is False but a node can deopt " + f"({type(deopting[0]).__name__})") + effects = [e for e in _all_exprs(f) if _is_effect(e)] + # a store into a local array is not an effect (ir.py "Local arrays"); into a parameter it is + stores = [s for s in _walk_stmts(f.body) + if isinstance(s, ir.StoreIndex) and f.locals.get(s.array) not in ir.ARRAYS] + if f.pure and (effects or stores): + what = type(effects[0]).__name__ if effects else "StoreIndex" + raise CGenError(f"{f.name}: marked pure but contains an effect ({what})") + for e in _all_exprs(f): + if isinstance(e, (ir.BinOp, ir.UnaryOp)) and e.proven: + checked = (e.op in (ir.BinOpKind.ADD, ir.BinOpKind.SUB, ir.BinOpKind.MUL) + if isinstance(e, ir.BinOp) else e.op is ir.UnaryOpKind.NEG) + operand = e.left if isinstance(e, ir.BinOp) else e.operand + if not checked or operand.type is not Type.I64: + raise CGenError(f"{f.name}: proven is set on {e.op.name} of {operand.type} " + "(ir.py: I64 ADD/SUB/MUL and NEG only)") + if isinstance(e, ir.Call): + callee = functions.get(e.function) + if callee is None: + raise CGenError(f"{f.name}: Call of {e.function!r}, which is not compiled here") + if any(isinstance(p, ir.ArrayParam) for p in callee.params): + raise CGenError(f"{f.name}: Call of {e.function!r}, which takes array params") + if e.redo: + if callee.returns not in (Type.F64, Type.BOOL, Type.NONE): + raise CGenError(f"{f.name}: redo Call of {e.function!r}, which returns " + f"{callee.returns} (ir.py: F64, BOOL or NONE only)") + if not callee.pure: + raise CGenError(f"{f.name}: redo Call of impure {e.function!r} " + "(redoing it could repeat an effect)") + elif (callee.may_deopt or callee.entry_globals) and not f.pure: + raise CGenError(f"{f.name}: Call of deopting {e.function!r} from an impure caller " + "without redo") + if f.entry_globals: + _check_entry_globals(f, functions) + for p in f.params: + if isinstance(p, ir.ArrayParam): + if p.type not in ir.ARRAYS: + raise CGenError(f"{f.name}: array param {p.name} has type {p.type}") + if p.stored and f.pure: + raise CGenError(f"{f.name}: array param {p.name} is stored into in a pure function") + elif p.type not in _CT: + raise CGenError(f"{f.name}: param {p.name} has type {p.type}") + + +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.""" + seen = set() if seen is None else seen + if f.name in seen: + return True + seen.add(f.name) + for e in _all_exprs(f): + if _is_effect(e): + return False + if isinstance(e, ir.Call): + callee = functions.get(e.function) + if callee is None or not _is_closed(callee, functions, seen): + return False + return True + + +def _check_entry_globals(f: ir.Function, functions: dict[str, ir.Function]) -> None: + names = [g.name for g in f.entry_globals] + if len(set(names)) != len(names): + raise CGenError(f"{f.name}: entry_globals lists a name twice") + taken = {p.name for p in f.params} | set(f.locals) + for g in f.entry_globals: + if not isinstance(g, ir.Param) or g.type not in ir.SCALARS: + raise CGenError(f"{f.name}: entry global {g.name} must be a scalar Param, not {g.type}") + if g.name in taken: + raise CGenError(f"{f.name}: entry global {g.name} is also a parameter or local") + for s in _walk_stmts(f.body): + if isinstance(s, ir.Assign) and s.target in names: + raise CGenError(f"{f.name}: assignment to entry global {s.target}") + if isinstance(s, ir.ForRange) and s.var in names: + raise CGenError(f"{f.name}: entry global {s.var} used as a range variable") + if not _is_closed(f, functions): + raise CGenError(f"{f.name}: entry_globals in a function that is not closed (it can run " + "user code, which could rebind the global between entry and use)") + + +def _impl_signature(f: ir.Function) -> str: + params = ["PyObject *tp_module"] + for p in f.params: + if isinstance(p, ir.ArrayParam): + params.append(f"{_ARRAY_CT[p.type]} *{_ident('l', p.name)}") + elif p.type is Type.OBJ: + params.append(f"PyObject *{_ident('p', p.name)}") + else: + params.append(f"{_CT[p.type]} {_ident('l', p.name)}") + for g in f.entry_globals: # read and unboxed by the caller (wrapper or Call) + params.append(f"{_CT[g.type]} {_ident('l', g.name)}") + if f.returns is not Type.NONE: + ct = _CT[f.returns] + params.append(f"{ct}{'' if ct.endswith('*') else ' '}*tp_out") + return f"static int {_ident('tp_impl', f.name)}({', '.join(params)})" + + +# --- function bodies ----------------------------------------------------------------------------- + +class _FnGen: + def __init__(self, mg: _ModGen, f: ir.Function): + self.mg, self.f = mg, f + self.lines: list[str] = [] + self.depth = 1 + self.temps: list[tuple[str, str]] = [] # (C type, name) + self.arrays = {p.name: p for p in f.params if isinstance(p, ir.ArrayParam)} + self.local_arrays: dict[str, Type] = {} # name -> F64_ARRAY / I64_ARRAY + self.types: dict[str, Type] = {} + for p in f.params: + if not isinstance(p, ir.ArrayParam): + self.types[p.name] = p.type + for g in f.entry_globals: # the body reads each as Local(name) + self.types[g.name] = g.type + for name, t in f.locals.items(): + if name in self.arrays or (name in self.types and self.types[name] is not t): + raise CGenError(f"{f.name}: local {name} conflicts with a parameter") + if t in ir.ARRAYS: + if name in self.types: + raise CGenError(f"{f.name}: local array {name} conflicts with a parameter") + self.local_arrays[name] = t + continue + if t not in _CT: + raise CGenError(f"{f.name}: local {name} has type {t}") + self.types[name] = t + + # emit primitives + + def emit(self, line: str) -> None: + self.lines.append(" " * self.depth + line) + + def tmp(self, ctype: str) -> str: + name = f"tp_t{len(self.temps)}" + self.temps.append((ctype, name)) + return name + + def check(self, call: str) -> None: + """A helper with the 0 / 1 deopt / -1 error convention.""" + self.emit(f"tp_s = {call};") + self.emit("if (tp_s != 0) { tp_rc = tp_s; goto tp_exit; }") + + 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 + 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, " + "\"typedpython: proven operation overflowed\"); tp_rc = -1; goto tp_exit; }") + + 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) + + def _own_new(self, call: str) -> str: + """A fresh temporary owning the new reference `call` returns (NULL -> error exit).""" + t = self.tmp("PyObject *") + self.emit(f"{t} = {call};") + self.emit(f"if ({t} == NULL) {{ tp_rc = -1; goto tp_exit; }}") + return t + + def _own_copy(self, local: str) -> str: + """A fresh temporary owning a new reference to what an OBJ local holds.""" + t = self.tmp("PyObject *") + self.emit(f"{t} = Py_NewRef({local});") + return t + + def _own_move(self, dst: str, t: str) -> None: + """Move temporary `t` into local `dst`, releasing the reference `dst` held.""" + self.emit(f"{{ PyObject *tp_old = {dst}; {dst} = {t}; {t} = NULL; tp_release(&tp_old); }}") + + def _own_move_out(self, t: str) -> None: + """Move temporary `t` into the caller's `*tp_out` (Return of OBJ).""" + self.emit(f"*tp_out = {t}; {t} = NULL;") + + def _own_release(self, *ts: str) -> None: + for t in ts: + self.emit(f"tp_release(&{t});") + + def _own_release_all(self) -> list[str]: + objs = [_ident("l", n) for n, t in self.types.items() if t is Type.OBJ] + objs += [n for ct, n in self.temps if ct == "PyObject *"] + out = [f" tp_release(&{n});" for n in objs] + # local arrays and array temporaries: freed on every exit (ok, error, deopt); idempotent + arrs = [(_ident("l", n), t) for n, t in self.local_arrays.items()] + arrs += [(n, _CT_ARRAY_REV[ct]) for ct, n in self.temps if ct in _CT_ARRAY_REV] + out += [f" {_ARRAY_PFX[t]}_free(&{n});" for n, t in arrs] + return out + + # expressions: return a C expression naming the value (a literal, a scalar local, or a temp); + # OBJ results are always owned temporaries. + + def ev(self, e: ir.Expr) -> str: + m = getattr(self, "ev_" + type(e).__name__, None) + if m is None: + raise CGenError(f"{self.f.name}: no lowering for {type(e).__name__}") + return m(e) + + def ev_Const(self, e: ir.Const) -> str: + if e.type is Type.BOOL: + return "1" if e.value else "0" + if e.type is Type.I64: + return _i64_lit(e.value) + if e.type is Type.F64: + return _f64_lit(e.value) + if e.type is Type.OBJ: + if e.value is None: + return self._own_copy("Py_None") + if isinstance(e.value, bool): + return self._own_copy("Py_True" if e.value else "Py_False") + raise CGenError(f"{self.f.name}: OBJ constant {e.value!r} (use Box of a scalar Const)") + raise CGenError(f"{self.f.name}: constant of type {e.type}") + + def ev_Local(self, e: ir.Local) -> str: + t = self.types.get(e.name) + if t is None: + raise CGenError(f"{self.f.name}: unknown local {e.name}") + if t is not e.type: + raise CGenError(f"{self.f.name}: local {e.name} is {t}, read as {e.type}") + if t is Type.OBJ: + return self._own_copy(_ident("l", e.name)) + return _ident("l", e.name) + + def ev_BinOp(self, e: ir.BinOp) -> str: + lt, rt = e.left.type, e.right.type + if lt is not rt: + raise CGenError(f"{self.f.name}: BinOp operands {lt} and {rt}") + a, b = self.ev(e.left), self.ev(e.right) + if lt is Type.I64: + t = self.tmp("double" if e.op is ir.BinOpKind.TRUEDIV else "int64_t") + if e.proven: + self.check_proven(f"{_I64_OP[e.op]}({a}, {b}, &{t})") + else: + self.check(f"{_I64_OP[e.op]}({a}, {b}, &{t})") + return t + if lt is Type.F64: + t = self.tmp("double") + if e.op in _F64_INLINE: + self.emit(f"{t} = {a} {_F64_INLINE[e.op]} {b};") + else: + self.check(f"{_F64_OP[e.op]}({a}, {b}, &{t})") + return t + if lt is Type.OBJ: + t = self.tmp("PyObject *") + self.emit(f"{t} = tp_binop_obj({a}, {b}, {_OBJ_OP[e.op]});") + self._own_release(a, b) + self.emit(f"if ({t} == NULL) {{ tp_rc = -1; goto tp_exit; }}") + return t + raise CGenError(f"{self.f.name}: BinOp on {lt}") + + def ev_UnaryOp(self, e: ir.UnaryOp) -> str: + ot = e.operand.type + v = self.ev(e.operand) + if e.op is ir.UnaryOpKind.NOT: + if ot is not Type.BOOL: + raise CGenError(f"{self.f.name}: NOT of {ot}") + t = self.tmp("int") + self.emit(f"{t} = !({v});") + return t + if ot is Type.I64 and e.op is ir.UnaryOpKind.NEG: + t = self.tmp("int64_t") + if e.proven: + self.check_proven(f"tp_neg_i64({v}, &{t})") + else: + self.check(f"tp_neg_i64({v}, &{t})") + return t + if ot is Type.F64 and e.op is ir.UnaryOpKind.NEG: + t = self.tmp("double") + self.emit(f"{t} = -({v});") + return t + if ot in (Type.I64, Type.F64) and e.op is ir.UnaryOpKind.POS: + t = self.tmp(_CT[ot]) + self.emit(f"{t} = {v};") + return t + raise CGenError(f"{self.f.name}: {e.op} of {ot}") + + def ev_Compare(self, e: ir.Compare) -> str: + lt, rt = e.left.type, e.right.type + a, b = self.ev(e.left), self.ev(e.right) + t = self.tmp("int") + if lt is rt and lt in ir.SCALARS: + self.emit(f"{t} = ({a}) {e.op.value} ({b});") + elif (lt, rt) == (Type.I64, Type.F64): + self.emit(f"{t} = tp_cmp_i64_f64({a}, {b}, {_PYCMP[e.op]});") + elif (lt, rt) == (Type.F64, Type.I64): + self.emit(f"{t} = tp_cmp_f64_i64({a}, {b}, {_PYCMP[e.op]});") + else: + raise CGenError(f"{self.f.name}: Compare of {lt} and {rt} (OBJ uses CompareObj)") + return t + + def ev_CompareObj(self, e: ir.CompareObj) -> str: + if e.left.type is not Type.OBJ or e.right.type is not Type.OBJ: + raise CGenError(f"{self.f.name}: CompareObj operands must be OBJ (Box scalars)") + a, b = self.ev(e.left), self.ev(e.right) + t = self.tmp("int") + self.emit(f"{t} = tp_compare_bool({a}, {b}, {_PYCMP[e.op]});") + self._own_release(a, b) + self.emit(f"if ({t} < 0) {{ tp_rc = -1; goto tp_exit; }}") + return t + + def _short_circuit(self, e, take_right_if: str) -> str: + if e.left.type is not Type.BOOL or e.right.type is not Type.BOOL: + raise CGenError(f"{self.f.name}: And/Or on non-BOOL operands") + t = self.tmp("int") + a = self.ev(e.left) + self.emit(f"{t} = {a};") + self.emit(f"if ({take_right_if}{t}) {{") + self.depth += 1 + b = self.ev(e.right) + self.emit(f"{t} = {b};") + self.depth -= 1 + self.emit("}") + return t + + def ev_And(self, e: ir.And) -> str: + return self._short_circuit(e, "") + + def ev_Or(self, e: ir.Or) -> str: + return self._short_circuit(e, "!") + + def ev_Truth(self, e: ir.Truth) -> str: + if e.operand.type is not Type.OBJ: + raise CGenError(f"{self.f.name}: Truth of {e.operand.type}") + o = self.ev(e.operand) + t = self.tmp("int") + self.emit(f"{t} = tp_truth({o});") + self._own_release(o) + self.emit(f"if ({t} < 0) {{ tp_rc = -1; goto tp_exit; }}") + return t + + def ev_ToFloat(self, e: ir.ToFloat) -> str: + if e.operand.type is not Type.I64: + raise CGenError(f"{self.f.name}: ToFloat of {e.operand.type}") + v = self.ev(e.operand) + t = self.tmp("double") + self.emit(f"{t} = tp_i64_to_f64({v});") + return t + + def ev_ObjToFloat(self, e: ir.ObjToFloat) -> str: + if e.operand.type is not Type.OBJ: + raise CGenError(f"{self.f.name}: ObjToFloat of {e.operand.type}") + o = self.ev(e.operand) + t = self.tmp("double") + self.emit(f"tp_s = tp_obj_to_f64({o}, &{t});") + self._own_release(o) + self.emit("if (tp_s != 0) { tp_rc = -1; goto tp_exit; }") + return t + + def ev_Box(self, e: ir.Box) -> str: + ot = e.operand.type + if ot not in _BOX: + raise CGenError(f"{self.f.name}: Box of {ot}") + v = self.ev(e.operand) + return self._own_new(f"{_BOX[ot]}({v})") + + def ev_Unbox(self, e: ir.Unbox) -> str: + if e.operand.type is not Type.OBJ or e.type not in _UNBOX: + raise CGenError(f"{self.f.name}: Unbox {e.operand.type} -> {e.type}") + o = self.ev(e.operand) + t = self.tmp(_CT[e.type]) + self.emit(f"tp_s = {_UNBOX[e.type]}({o}, &{t});") + self._own_release(o) + self.emit("if (tp_s != 0) { tp_rc = tp_s; goto tp_exit; }") + return t + + def ev_MathCall(self, e: ir.MathCall) -> str: + if len(e.args) != _MATH[e.func] or any(a.type is not Type.F64 for a in e.args): + raise CGenError(f"{self.f.name}: math.{e.func.value} arguments") + args = [self.ev(a) for a in e.args] + t = self.tmp("double") + self.check(f"tp_math_{e.func.value}({', '.join(args)}, &{t})") + return t + + def ev_Call(self, e: ir.Call) -> str | None: + callee = self.mg.functions[e.function] + if len(e.args) != len(callee.params): + raise CGenError(f"{self.f.name}: Call of {e.function} with {len(e.args)} args") + args = [] + owned = [] + for p, a in zip(callee.params, e.args): + if a.type is not p.type: + raise CGenError(f"{self.f.name}: Call {e.function} arg {p.name}: {a.type} for {p.type}") + v = self.ev(a) + args.append(v) + if a.type is Type.OBJ: + owned.append(v) + values = list(args) # the evaluated arguments, for a redo + t = None + self.emit("tp_s = 0;") + # The callee's entry globals (ir.Function.entry_globals) are its entry guards: read them + # here, at the callee's entry. A mismatch is the callee's deopt (tp_s = 1), handled below + # exactly as a deopt inside the callee. + for g in callee.entry_globals: + args.append(self._read_entry_global(g, "tp_s = 1;")) + if callee.returns is not Type.NONE: + t = self.tmp(_CT[callee.returns]) + args.append(f"&{t}") + self.emit("if (tp_s == 0) {") + self.depth += 1 + # Depth is counted by the callee itself (tp_enter_call at its entry, issue #57). + self.emit(f"tp_s = {_ident('tp_impl', e.function)}(tp_module{''.join(', ' + x for x in args)});") + self.depth -= 1 + self.emit("}") + if e.redo: + self._redo_call(callee, e.args, values, t) + self._own_release(*owned) + self.emit("if (tp_s != 0) { tp_rc = tp_s; goto tp_exit; }") + return t + + def _read_entry_global(self, g: ir.Param, on_mismatch: str, on_error: str = "tp_rc = -1; goto tp_exit;", + guard: str = "tp_s == 0") -> str: + """Emit the entry read of module global `g` (tp_global, exact-type unbox, release) guarded + by `tp_s == 0`; on a wrong type or an unbound name run `on_mismatch` (a deopt: the + interpreted function then sees the same binding and raises CPython's own NameError where + CPython would). Returns the C scalar holding the value.""" + k = self.mg.name_index(g.name) + gv, go = self.tmp(_CT[g.type]), self.tmp("PyObject *") + self.emit(f"if ({guard}) {{") + self.emit(f" {go} = tp_global(tp_dict, tp_st->names[{k}]);") + self.emit(f" if ({go} == NULL) {{") + self.emit(f" if (!PyErr_ExceptionMatches(PyExc_NameError)) {{ {on_error} }}") + self.emit(f" PyErr_Clear(); {on_mismatch}") + self.emit(" } else {") + self.emit(f" tp_s = {_UNBOX[g.type]}({go}, &{gv});") + self.emit(f" tp_release(&{go});") + self.emit(f" if (tp_s < 0) {{ {on_error} }}") + self.emit(f" if (tp_s != 0) {{ {on_mismatch} }}") + self.emit(" }") + self.emit("}") + return gv + + def _redo_call(self, callee: ir.Function, arg_exprs, values: list[str], t: str | None) -> None: + """ir.Call.redo: on the callee's deopt (tp_s == 1) run `__typedpython_interpreted__[callee]` + on the already-evaluated arguments (boxed), count the deopt, convert the result back, and + continue in the caller. The callee is pure, so the redo is unobservable.""" + k = self.mg.name_index(callee.name) + self.emit("if (tp_s == 1) {") + self.depth += 1 + boxes = [] + av = [] + for a, v in zip(arg_exprs, values): + if a.type is Type.OBJ: + av.append(v) # still owned by the caller; released after + else: + b = self._own_new(f"{_BOX[a.type]}({v})") + boxes.append(b) + av.append(b) + rr = self.tmp("PyObject *") + if av: + self.emit(f"{{ PyObject *tp_av[{len(av)}] = {{{', '.join(av)}}};") + self.emit(f" {rr} = tp_cg_redo(tp_module, {k}, tp_av, {len(av)}, NULL); }}") + else: + self.emit(f"{rr} = tp_cg_redo(tp_module, {k}, NULL, 0, NULL);") + self._own_release(*boxes) + self.emit(f"if ({rr} == NULL) {{ tp_rc = -1; goto tp_exit; }}") + rt = callee.returns + if rt is Type.NONE: + self.emit(f"tp_s = ({rr} == Py_None) ? 0 : 1;") + what = "None" + else: + self.emit(f"tp_s = {_UNBOX[rt]}({rr}, &{t});") + what = {Type.F64: "a float", Type.BOOL: "True or False"}[rt] + self._own_release(rr) + msg = c_string(f"typedpython: the interpreted redo of {callee.name} did not return {what}") + self.emit(f"if (tp_s != 0) {{ PyErr_SetString(PyExc_SystemError, {msg}); tp_rc = -1; goto tp_exit; }}") + self.depth -= 1 + self.emit("}") + + def ev_Global(self, e: ir.Global) -> str: + k = self.mg.name_index(e.name) + return self._own_new(f"tp_global(tp_dict, tp_st->names[{k}])") + + def ev_GetAttr(self, e: ir.GetAttr) -> str: + if e.obj.type is not Type.OBJ: + raise CGenError(f"{self.f.name}: GetAttr on {e.obj.type}") + o = self.ev(e.obj) + k = self.mg.name_index(e.name) + t = self.tmp("PyObject *") + self.emit(f"{t} = tp_getattr({o}, tp_st->names[{k}]);") + self._own_release(o) + self.emit(f"if ({t} == NULL) {{ tp_rc = -1; goto tp_exit; }}") + return t + + def ev_CallObject(self, e: ir.CallObject) -> str: + if e.callee.type is not Type.OBJ or any(a.type is not Type.OBJ for a in e.args): + raise CGenError(f"{self.f.name}: CallObject operands must be OBJ") + if len(e.kwnames) > len(e.args) or len(set(e.kwnames)) != len(e.kwnames): + raise CGenError(f"{self.f.name}: CallObject kwnames {e.kwnames}") + callee = self.ev(e.callee) # CPython evaluates the callee first + args = [self.ev(a) for a in e.args] + npos = len(e.args) - len(e.kwnames) + kw = f"tp_st->kwnames[{self.mg.kw_index(tuple(e.kwnames))}]" if e.kwnames else "NULL" + t = self.tmp("PyObject *") + if args: + self.emit(f"{{ PyObject *tp_av[{len(args)}] = {{{', '.join(args)}}};") + self.emit(f" {t} = tp_call({callee}, tp_av, {npos}, {kw}); }}") + else: + self.emit(f"{t} = tp_call({callee}, NULL, 0, NULL);") + self._own_release(callee, *args) + self.emit(f"if ({t} == NULL) {{ tp_rc = -1; goto tp_exit; }}") + return t + + def _array(self, name: str) -> tuple[Type, str]: + """(array type, C pointer expression) of an array parameter (already a pointer) or a local + array (a struct held by value, so its address).""" + a = self.arrays.get(name) + if a is not None: + return a.type, _ident("l", name) + t = self.local_arrays.get(name) + if t is None: + raise CGenError(f"{self.f.name}: {name} is not an array parameter or local array") + return t, f"(&{_ident('l', name)})" + + def ev_Len(self, e: ir.Len) -> str: + _, ptr = self._array(e.array) + t = self.tmp("int64_t") + self.emit(f"{t} = (int64_t){ptr}->len;") + return t + + def _slot(self, at: Type, ptr: str, index: ir.Expr, proven: bool, store: int) -> str: + if index.type is not Type.I64: + raise CGenError(f"{self.f.name}: index of type {index.type}") + i = self.ev(index) + if proven: + return f"(Py_ssize_t)({i})" + slot = self.tmp("Py_ssize_t") + self.check(f"{_ARRAY_PFX[at]}_slot({ptr}, {i}, {store}, &{slot})") + return slot + + def ev_Index(self, e: ir.Index) -> str: + at, ptr = self._array(e.array) + if e.type is not _ARRAY_ELEM[at]: + raise CGenError(f"{self.f.name}: Index of {at} typed {e.type}") + slot = self._slot(at, ptr, e.index, e.proven, 0) + t = self.tmp(_CT[e.type]) + self.emit(f"{t} = {ptr}->data[{slot}];") + return t + + def ev_NewArray(self, e: ir.NewArray) -> str: + raise CGenError(f"{self.f.name}: NewArray is legal only as the value of an Assign to a " + "local array (ir.py)") + + def ev_CopyArray(self, e: ir.CopyArray) -> str: + raise CGenError(f"{self.f.name}: CopyArray is legal only as the value of an Assign to a " + "local array (ir.py)") + + def ev_Tuple(self, e: ir.Tuple) -> str: + if e.type is not Type.OBJ or any(x.type is not Type.OBJ for x in e.elements): + raise CGenError(f"{self.f.name}: Tuple elements must be OBJ (Box scalars)") + items = [self.ev(x) for x in e.elements] # owned temporaries, evaluated in order + t = self.tmp("PyObject *") + if items: + self.emit(f"{{ PyObject *tp_av[{len(items)}] = {{{', '.join(items)}}};") + self.emit(f" {t} = tp_tuple(tp_av, {len(items)}); }}") + else: + self.emit(f"{t} = tp_tuple(NULL, 0);") + self._own_release(*items) # tp_tuple stole nothing + self.emit(f"if ({t} == NULL) {{ tp_rc = -1; goto tp_exit; }}") + return t + + def _assign_array(self, s: ir.Assign, at: Type) -> None: + """`local = NewArray | CopyArray`: build into a zeroed temporary, then (only on success) + free the old array and move the temporary in.""" + v = s.value + pfx = _ARRAY_PFX[at] + if v.type is not at: + raise CGenError(f"{self.f.name}: {s.target} is {at}, assigned {v.type}") + dst = _ident("l", s.target) + new = self.tmp(_ARRAY_CT[at]) + if isinstance(v, ir.NewArray): + if v.length.type is not Type.I64: + raise CGenError(f"{self.f.name}: NewArray length of type {v.length.type}") + if v.iota: + if at is not Type.I64_ARRAY or v.fill is not None: + raise CGenError(f"{self.f.name}: iota NewArray must be I64_ARRAY with no fill") + n = self.ev(v.length) + self.check(f"{pfx}_iota(&{new}, {n})") + else: + if v.fill is None or v.fill.type is not _ARRAY_ELEM[at]: + raise CGenError(f"{self.f.name}: NewArray fill must be a " + f"{_ARRAY_ELEM[at]} (got {None if v.fill is None else v.fill.type})") + fill = self.ev(v.fill) # `[fill] * n`: fill first, then n + n = self.ev(v.length) + self.check(f"{pfx}_new(&{new}, {n}, {fill})") + elif isinstance(v, ir.CopyArray): + st, sptr = self._array(v.src) + if st is not at: + raise CGenError(f"{self.f.name}: CopyArray of {st} into {at}") + self.check(f"{pfx}_copy(&{new}, {sptr})") + else: + raise CGenError(f"{self.f.name}: local array {s.target} may only be assigned a " + f"NewArray or CopyArray, not {type(v).__name__}") + self.emit(f"{pfx}_free(&{dst});") + self.emit(f"{dst} = {new}; {new} = ({_ARRAY_CT[at]}){{0}};") + + # statements + + def stmts(self, body) -> None: + for s in body: + m = getattr(self, "st_" + type(s).__name__, None) + if m is None: + raise CGenError(f"{self.f.name}: no lowering for {type(s).__name__}") + m(s) + + def st_Assign(self, s: ir.Assign) -> None: + if s.target in self.local_arrays: + self._assign_array(s, self.local_arrays[s.target]) + return + t = self.types.get(s.target) + if t is None: + raise CGenError(f"{self.f.name}: assignment to unknown local {s.target}") + if s.value.type is not t: + raise CGenError(f"{self.f.name}: {s.target} is {t}, assigned {s.value.type}") + v = self.ev(s.value) + if t is Type.OBJ: + self._own_move(_ident("l", s.target), v) + else: + self.emit(f"{_ident('l', s.target)} = {v};") + + def st_StoreIndex(self, s: ir.StoreIndex) -> None: + at, ptr = self._array(s.array) + local = s.array in self.local_arrays + if not local and not self.arrays[s.array].stored: + raise CGenError(f"{self.f.name}: store into {s.array}, declared stored=False") + if s.value.type is not _ARRAY_ELEM[at]: + raise CGenError(f"{self.f.name}: store of {s.value.type} into {at}") + v = self.ev(s.value) # CPython: value, then container, then index + slot = self._slot(at, ptr, s.index, s.proven, 1) + self.emit(f"{ptr}->data[{slot}] = {v};") + if not local: # a local array has no list to write back to + self.emit(f"{ptr}->dirty[{slot}] = 1;") + + def st_ExprStmt(self, s: ir.ExprStmt) -> None: + v = self.ev(s.value) + if v is None: + return + if s.value.type is Type.OBJ: + self._own_release(v) + else: + self.emit(f"(void){v};") + + def _cond(self, e: ir.Expr) -> str: + if e.type is not Type.BOOL: + raise CGenError(f"{self.f.name}: condition of type {e.type} (use Truth/CompareObj)") + return self.ev(e) + + def st_If(self, s: ir.If) -> None: + c = self._cond(s.cond) + self.emit(f"if ({c}) {{") + self.depth += 1 + self.stmts(s.then) + self.depth -= 1 + if s.orelse: + self.emit("} else {") + self.depth += 1 + self.stmts(s.orelse) + self.depth -= 1 + self.emit("}") + + def st_While(self, s: ir.While) -> None: + self.emit("for (;;) {") + self.depth += 1 + c = self._cond(s.cond) + self.emit(f"if (!({c})) break;") + self.stmts(s.body) + self.depth -= 1 + self.emit("}") + + def st_ForRange(self, s: ir.ForRange) -> None: + if self.types.get(s.var) is not Type.I64: + raise CGenError(f"{self.f.name}: range variable {s.var} must be an I64 local") + if not isinstance(s.step, ir.Const) or s.step.type is not Type.I64 or s.step.value == 0: + raise CGenError(f"{self.f.name}: range step must be a non-zero I64 Const") + if s.start.type is not Type.I64 or s.stop.type is not Type.I64: + raise CGenError(f"{self.f.name}: range bounds must be I64") + step = s.step.value + _i64_lit(step) + ts, te = self.tmp("int64_t"), self.tmp("int64_t") + a = self.ev(s.start) + self.emit(f"{ts} = {a};") # range() evaluates its arguments once + b = self.ev(s.stop) + self.emit(f"{te} = {b};") + tn, tk = self.tmp("uint64_t"), self.tmp("uint64_t") + mag = f"UINT64_C({abs(step)})" + var = _ident("l", s.var) + # Iteration count computed in unsigned arithmetic, as range's length: no overflow for any + # start/stop/step, and the variable stays between start and stop. + if step > 0: + self.emit(f"if ({ts} < {te}) {{") + self.emit(f" {tn} = ((uint64_t){te} - (uint64_t){ts} - 1u) / {mag} + 1u;") + sign = "+" + else: + self.emit(f"if ({ts} > {te}) {{") + self.emit(f" {tn} = ((uint64_t){ts} - (uint64_t){te} - 1u) / {mag} + 1u;") + sign = "-" + self.depth += 1 + self.emit(f"for ({tk} = 0; {tk} < {tn}; {tk}++) {{") + self.depth += 1 + self.emit(f"{var} = (int64_t)((uint64_t){ts} {sign} {tk} * {mag});") + self.stmts(s.body) + self.depth -= 1 + self.emit("}") + self.depth -= 1 + self.emit("}") + + def st_Return(self, s: ir.Return) -> None: + rt = self.f.returns + if rt is Type.NONE: + if s.value is not None and not (isinstance(s.value, ir.Const) and s.value.value is None): + raise CGenError(f"{self.f.name}: returns None but Return has a value") + self.emit("tp_rc = 0; goto tp_exit;") + return + if s.value is None or s.value.type is not rt: + raise CGenError(f"{self.f.name}: Return type mismatch (function returns {rt})") + v = self.ev(s.value) + if rt is Type.OBJ: + self._own_move_out(v) + else: + self.emit(f"*tp_out = {v};") + self.emit("tp_rc = 0; goto tp_exit;") + + def st_Break(self, s) -> None: + self.emit("break;") + + def st_Continue(self, s) -> None: + self.emit("continue;") + + # whole function + + def generate(self) -> str: + f = self.f + prologue = [] + for p in f.params: + if not isinstance(p, ir.ArrayParam) and p.type is Type.OBJ: + # adopt the borrowed argument: the local owns its own reference (rule 1) + prologue.append(f" {_ident('l', p.name)} = Py_NewRef({_ident('p', p.name)});") + self.stmts(f.body) + if f.returns is Type.NONE: + self.emit("tp_rc = 0;") + else: + self.emit(f'PyErr_SetString(PyExc_SystemError, {c_string("compiled function " + f.name + " ended without a return")});') + self.emit("tp_rc = -1;") + self.emit("goto tp_exit;") + + out = [_impl_signature(f), "{", + " int tp_rc = 0;", + " int tp_s = 0;", + " tp_state *tp_st = (tp_state *)PyModule_GetState(tp_module);", + " PyObject *tp_dict = PyModule_GetDict(tp_module); /* borrowed: owned by the module */"] + params = {p.name for p in f.params} | {g.name for g in f.entry_globals} + for name, t in self.local_arrays.items(): + out.append(f" {_ARRAY_CT[t]} {_ident('l', name)} = {{0}};") + for name, t in self.types.items(): + if name in params and t is not Type.OBJ: + continue + init = "NULL" if t is Type.OBJ else "0" + ct = _CT[t] + out.append(f" {ct}{'' if ct.endswith('*') else ' '}{_ident('l', name)} = {init};") + for ct, n in self.temps: + init = "NULL" if ct == "PyObject *" else "{0}" if ct in _CT_ARRAY_REV else "0" + sep = "" if ct.endswith("*") else " " + out.append(f" {ct}{sep}{n} = {init};") + out.append(" (void)tp_st; (void)tp_dict; (void)tp_s;" + + "".join(f" (void){_ident('l', n)};" for n in self.types if n not in params) + + "".join(f" (void){_ident('l', n)};" for n in self.local_arrays)) + # Depth guard (issue #57): nothing is owned yet, so a refusal returns directly and counts + # nothing; every other exit goes through tp_exit, which leaves exactly once. + out.append(" if (tp_enter_call() != 0) return -1;") + out += prologue + out += self.lines + out.append("tp_exit:") + out += self._own_release_all() + out.append(" tp_leave_call();") + out.append(" return tp_rc;") + out.append("}") + out.append("") + out.append(self.wrapper()) + return "\n".join(out) + + def wrapper(self) -> str: + f = self.f + name_k = self.mg.name_index(f.name) + n = len(f.params) + L = [f"static PyObject *{_ident('tp_wrap', f.name)}(PyObject *tp_module, PyObject *const *tp_args, " + "Py_ssize_t tp_nargs, PyObject *tp_kwnames)", "{", + " int tp_s = 0, tp_x = 0;"] + if f.returns not in (Type.NONE,): + init = "NULL" if f.returns is Type.OBJ else "0" + L.append(f" {_CT[f.returns]} tp_r = {init};") + args = [] + arrays = [] + for i, p in enumerate(f.params): + if isinstance(p, ir.ArrayParam): + L.append(f" {_ARRAY_CT[p.type]} tp_a{i} = {{0}};") + args.append(f"&tp_a{i}") + arrays.append((i, p)) + elif p.type is Type.OBJ: + args.append(f"tp_args[{i}]") # borrowed for the call's duration + else: + L.append(f" {_CT[p.type]} tp_a{i} = 0;") + args.append(f"tp_a{i}") + if f.returns is not Type.NONE: + args.append("&tp_r") + exits = [f" if ({_ARRAY_PFX[p.type]}_exit(&tp_a{i}) < 0) tp_x = -1;" for i, p in arrays] + L.append(" (void)tp_x;") + # arity / keywords -> the interpreted function raises CPython's own TypeError + L.append(f" if (tp_nargs != {n} || (tp_kwnames != NULL && PyTuple_GET_SIZE(tp_kwnames) != 0)) goto tp_deopt;") + # scalar guards: exact type (and i64 range) + for i, p in enumerate(f.params): + if not isinstance(p, ir.ArrayParam) and p.type in _UNBOX: + L.append(f" tp_s = {_UNBOX[p.type]}(tp_args[{i}], &tp_a{i});") + L.append(" if (tp_s < 0) goto tp_error;") + L.append(" if (tp_s != 0) goto tp_deopt;") + # aliasing guard, then copy-in + if len(arrays) > 1: + objs = ", ".join(f"tp_args[{i}]" for i, _ in arrays) + L.append(f" {{ PyObject *const tp_objs[{len(arrays)}] = {{{objs}}};") + L.append(f" if (tp_any_same(tp_objs, {len(arrays)})) goto tp_deopt; }}") + for i, p in arrays: + L.append(f" tp_s = {_ARRAY_PFX[p.type]}_enter(tp_args[{i}], &tp_a{i});") + L.append(" if (tp_s < 0) goto tp_error;") + L.append(" if (tp_s != 0) goto tp_deopt;") + gnames = [] + if f.entry_globals: + L.insert(3, " PyObject *tp_dict = PyModule_GetDict(tp_module); /* borrowed */") + L.insert(3, " tp_state *tp_st = (tp_state *)PyModule_GetState(tp_module);") + for j, g in enumerate(f.entry_globals): + # entry read of a module global: exact type or deopt (still before any effect) + k = self.mg.name_index(g.name) + L.append(f" {_CT[g.type]} tp_g{j} = 0;") + L.append(f" {{ PyObject *tp_go = tp_global(tp_dict, tp_st->names[{k}]);") + L.append(" if (tp_go == NULL) {") + L.append(" if (!PyErr_ExceptionMatches(PyExc_NameError)) goto tp_error;") + L.append(" PyErr_Clear(); goto tp_deopt; }") + L.append(f" tp_s = {_UNBOX[g.type]}(tp_go, &tp_g{j}); Py_DECREF(tp_go); }}") + L.append(" if (tp_s < 0) goto tp_error;") + L.append(" if (tp_s != 0) goto tp_deopt;") + gnames.append(f"tp_g{j}") + if gnames: + at = len(args) - (1 if f.returns is not Type.NONE else 0) + args[at:at] = gnames + L.append(f" tp_s = {_ident('tp_impl', f.name)}(tp_module{''.join(', ' + a for a in args)});") + if f.pure: + L.append(" if (tp_s == 1) goto tp_deopt;") + else: + # cgen proved there is no deopting node in an impure function; never redo after effects + L.append(" if (tp_s == 1) { PyErr_SetString(PyExc_SystemError, " + "\"typedpython: deopt after an effect\"); goto tp_error; }") + L.append(" if (tp_s < 0) goto tp_error;") + # ok: write back, then box + L += exits + if f.returns is Type.OBJ: + L.append(" if (tp_x < 0) { tp_release(&tp_r); return NULL; }") + L.append(" return tp_r;") + else: + L.append(" if (tp_x < 0) return NULL;") + if f.returns is Type.NONE: + L.append(" return Py_NewRef(Py_None);") + else: + L.append(f" return {_BOX[f.returns]}(tp_r);") + L.append("tp_error:") + if arrays: + # keep CPython's exception; write-back still runs (partial updates stay visible) + L.append(" { PyObject *tp_exc = PyErr_GetRaisedException();") + L += [" " + e for e in exits] + L.append(" if (tp_x < 0) { PyObject *tp_exc2 = PyErr_GetRaisedException();") + L.append(" PyException_SetContext(tp_exc2, tp_exc); /* steals tp_exc */") + L.append(" PyErr_SetRaisedException(tp_exc2); }") + L.append(" else PyErr_SetRaisedException(tp_exc); }") + L.append(" return NULL;") + L.append("tp_deopt:") + L += exits + L.append(" if (tp_x < 0) return NULL;") + L.append(f" return tp_cg_redo(tp_module, {name_k}, tp_args, tp_nargs, tp_kwnames);") + L.append("}") + L.append("") + return "\n".join(L) + + +# --- fixed parts of the module ------------------------------------------------------------------- + +_TEMPLATE_REDO = r''' +/* Deopt: count it, then call __typedpython_interpreted__[name] with the original arguments. */ +static PyObject *tp_cg_redo(PyObject *tp_module, int tp_name, PyObject *const *tp_args, + Py_ssize_t tp_nargs, PyObject *tp_kwnames) +{ + tp_state *tp_st = (tp_state *)PyModule_GetState(tp_module); + PyObject *tp_dict = PyModule_GetDict(tp_module); /* borrowed: owned by the module */ + PyObject *tp_count = NULL, *tp_one = NULL, *tp_next = NULL, *tp_table = NULL, *tp_f = NULL; + PyObject *tp_result = NULL; + int tp_k; + tp_k = PyDict_GetItemStringRef(tp_dict, "__typedpython_deopts__", &tp_count); + if (tp_k == 0) PyErr_SetString(PyExc_RuntimeError, "typedpython: __typedpython_deopts__ is missing"); + if (tp_k <= 0) goto tp_done; + tp_one = PyLong_FromLong(1); + if (tp_one == NULL) goto tp_done; + tp_next = PyNumber_Add(tp_count, tp_one); + if (tp_next == NULL || PyDict_SetItemString(tp_dict, "__typedpython_deopts__", tp_next) < 0) goto tp_done; + tp_k = PyDict_GetItemStringRef(tp_dict, "__typedpython_interpreted__", &tp_table); + if (tp_k == 0) PyErr_SetString(PyExc_RuntimeError, "typedpython: __typedpython_interpreted__ is missing"); + if (tp_k <= 0) goto tp_done; + if (!PyDict_Check(tp_table)) { + PyErr_SetString(PyExc_TypeError, "typedpython: __typedpython_interpreted__ is not a dict"); + goto tp_done; + } + tp_k = PyDict_GetItemRef(tp_table, tp_st->names[tp_name], &tp_f); + if (tp_k == 0) PyErr_Format(PyExc_RuntimeError, "typedpython: no interpreted function %R", tp_st->names[tp_name]); + if (tp_k <= 0) goto tp_done; + tp_result = PyObject_Vectorcall(tp_f, tp_args, (size_t)tp_nargs, tp_kwnames); +tp_done: + tp_release(&tp_count); tp_release(&tp_one); tp_release(&tp_next); + tp_release(&tp_table); tp_release(&tp_f); + return tp_result; +} +''' + +_TEMPLATE_STATE = r''' +static int tp_traverse(PyObject *tp_module, visitproc visit, void *arg) +{ + tp_state *tp_st = (tp_state *)PyModule_GetState(tp_module); + size_t i; + if (tp_st == NULL) return 0; + for (i = 0; i < sizeof(tp_st->names) / sizeof(tp_st->names[0]); i++) Py_VISIT(tp_st->names[i]); + for (i = 0; i < sizeof(tp_st->kwnames) / sizeof(tp_st->kwnames[0]); i++) Py_VISIT(tp_st->kwnames[i]); + return 0; +} + +static int tp_clear(PyObject *tp_module) +{ + tp_state *tp_st = (tp_state *)PyModule_GetState(tp_module); + size_t i; + if (tp_st == NULL) return 0; + for (i = 0; i < sizeof(tp_st->names) / sizeof(tp_st->names[0]); i++) tp_release(&tp_st->names[i]); + for (i = 0; i < sizeof(tp_st->kwnames) / sizeof(tp_st->kwnames[0]); i++) tp_release(&tp_st->kwnames[i]); + return 0; +} + +static void tp_free(void *tp_module) { (void)tp_clear((PyObject *)tp_module); } +''' + + +def _exec_function(mg: _ModGen) -> str: + L = [ + "/* Exec slot: run the original source in this module's dict, then swap in the C functions. */", + "static int tp_exec(PyObject *tp_module)", + "{", + " tp_state *tp_st = (tp_state *)PyModule_GetState(tp_module);", + " PyObject *tp_dict = PyModule_GetDict(tp_module); /* borrowed: owned by the module */", + " PyObject *tp_code = NULL, *tp_res = NULL, *tp_table = NULL, *tp_zero = NULL;", + " PyObject *tp_modname = NULL, *tp_f = NULL, *tp_cf = NULL;", + " int tp_rc = -1, tp_k;", + " size_t i;", + " for (i = 0; tp_name_strings[i] != NULL; i++) {", + " tp_st->names[i] = PyUnicode_InternFromString(tp_name_strings[i]);", + " if (tp_st->names[i] == NULL) goto tp_done;", + " }", + ] + for k, kw in enumerate(mg.kwtuples): + items = ", ".join(f"tp_st->names[{mg.name_index(x)}]" for x in kw) + L.append(f" tp_st->kwnames[{k}] = PyTuple_Pack({len(kw)}, {items});") + L.append(f" if (tp_st->kwnames[{k}] == NULL) goto tp_done;") + L += [ + " tp_k = PyDict_ContainsString(tp_dict, \"__builtins__\");", + " if (tp_k < 0) goto tp_done;", + " if (tp_k == 0) {", + " PyObject *tp_b = PyImport_ImportModule(\"builtins\");", + " if (tp_b == NULL) goto tp_done;", + " tp_k = PyDict_SetItemString(tp_dict, \"__builtins__\", PyModule_GetDict(tp_b));", + " tp_release(&tp_b);", + " if (tp_k < 0) goto tp_done;", + " }", + " tp_code = Py_CompileString(tp_source, tp_source_path, Py_file_input);", + " if (tp_code == NULL) goto tp_done;", + " tp_res = PyEval_EvalCode(tp_code, tp_dict, tp_dict);", + " if (tp_res == NULL) goto tp_done;", + " tp_table = PyDict_New();", + " if (tp_table == NULL || PyDict_SetItemString(tp_dict, \"__typedpython_interpreted__\", tp_table) < 0) goto tp_done;", + " tp_zero = PyLong_FromLong(0);", + " if (tp_zero == NULL || PyDict_SetItemString(tp_dict, \"__typedpython_deopts__\", tp_zero) < 0) goto tp_done;", + " tp_modname = PyModule_GetNameObject(tp_module);", + " if (tp_modname == NULL) goto tp_done;", + ] + for j, f in enumerate(mg.module.functions): + k = mg.name_index(f.name) + L += [ + f" tp_k = PyDict_GetItemRef(tp_dict, tp_st->names[{k}], &tp_f);", + f" if (tp_k == 0) PyErr_Format(PyExc_ImportError, \"typedpython: the module source does not define %R\", tp_st->names[{k}]);", + " if (tp_k <= 0) goto tp_done;", + f" if (PyDict_SetItem(tp_table, tp_st->names[{k}], tp_f) < 0) goto tp_done;", + f" tp_cf = PyCFunction_NewEx(&tp_methods[{j}], tp_module, tp_modname);", + f" if (tp_cf == NULL || PyDict_SetItem(tp_dict, tp_st->names[{k}], tp_cf) < 0) goto tp_done;", + " tp_release(&tp_f); tp_release(&tp_cf);", + ] + L += [ + " tp_rc = 0;", + "tp_done:", + " tp_release(&tp_code); tp_release(&tp_res); tp_release(&tp_table); tp_release(&tp_zero);", + " tp_release(&tp_modname); tp_release(&tp_f); tp_release(&tp_cf);", + " return tp_rc;", + "}", + "", + ] + return "\n".join(L) diff --git a/python-multiplatform-ksp/src/main/python/typedpython/diagnostic.py b/python-multiplatform-ksp/src/main/python/typedpython/diagnostic.py new file mode 100644 index 000000000..2ec322edc --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/diagnostic.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass, asdict +from typing import Literal + +Severity = Literal["error", "warning"] + + +@dataclass(frozen=True, order=True) +class Diagnostic: + path: str + line: int + column: int + rule: str + severity: Severity + message: str + + def to_json(self) -> dict[str, object]: + return asdict(self) diff --git a/python-multiplatform-ksp/src/main/python/typedpython/forbidden.py b/python-multiplatform-ksp/src/main/python/typedpython/forbidden.py new file mode 100644 index 000000000..cccc1651f --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/forbidden.py @@ -0,0 +1,110 @@ +"""The forbidden list (docs/design/typedpython.md §3.1), found by walking the AST. + +These are the dynamic features that make a static type unreliable at compile time. The gate +reports them as warnings for `checked` code and as errors for `compiled` code; this module only +finds them. + +Rebinding a name to a different type and containers that mix element types need inferred types +for assignment targets; they live in typedpython.rebinding. +""" +import ast +from dataclasses import dataclass + +DYNAMIC_ATTRIBUTE_CALLS = {"getattr", "setattr", "delattr"} +# Index of the attribute-name argument in each of the calls above. +NAME_ARGUMENT = 1 + + +@dataclass(frozen=True) +class Finding: + rule: str + line: int + column: int + message: str + + +def scan(source: str, path: str) -> list[Finding]: + tree = ast.parse(source, filename=path) + patchable = _module_level_modules_and_classes(tree) + findings: list[Finding] = [] + + for node in ast.walk(tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + findings += _check_call(node, node.func.id, patchable) + elif isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign, ast.Delete)): + for target in _targets(node): + if _is_patch_target(target, patchable): + assert isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) + findings.append(_finding( + "forbidden/monkeypatch", target, + f"`{target.value.id}.{target.attr}` changes a module or class from outside it", + )) + + return sorted(findings, key=lambda f: (f.line, f.column, f.rule)) + + +def _check_call(call: ast.Call, name: str, patchable: set[str]) -> list[Finding]: + if name in ("eval", "exec"): + return [_finding(f"forbidden/{name}", call, f"`{name}` runs code the checker cannot see")] + if name not in DYNAMIC_ATTRIBUTE_CALLS or len(call.args) <= NAME_ARGUMENT: + return [] + + attribute = call.args[NAME_ARGUMENT] + if not (isinstance(attribute, ast.Constant) and isinstance(attribute.value, str)): + return [_finding( + "forbidden/dynamic-attr", call, + f"`{name}` with a computed attribute name hides the attribute from the checker", + )] + + # A literal name is static, but setattr/delattr on a module or class is still a patch. + receiver = call.args[0] + if name != "getattr" and isinstance(receiver, ast.Name) and receiver.id in patchable: + return [_finding( + "forbidden/monkeypatch", call, + f"`{name}` on `{receiver.id}` changes a module or class from outside it", + )] + return [] + + +def _module_level_modules_and_classes(tree: ast.Module) -> set[str]: + """Names bound at module scope to an imported module/name or to a class definition.""" + names: set[str] = set() + for node in tree.body: + if isinstance(node, ast.Import): + names |= {(alias.asname or alias.name).split(".")[0] for alias in node.names} + elif isinstance(node, ast.ImportFrom): + names |= {alias.asname or alias.name for alias in node.names if alias.name != "*"} + elif isinstance(node, ast.ClassDef): + names.add(node.name) + return names + + +def _targets(node: ast.stmt) -> list[ast.expr]: + if isinstance(node, ast.Assign): + return [t for target in node.targets for t in _flatten(target)] + if isinstance(node, ast.Delete): + return [t for target in node.targets for t in _flatten(target)] + if isinstance(node, (ast.AugAssign, ast.AnnAssign)): + return [node.target] + return [] + + +def _flatten(target: ast.expr) -> list[ast.expr]: + if isinstance(target, (ast.Tuple, ast.List)): + return [t for element in target.elts for t in _flatten(element)] + if isinstance(target, ast.Starred): + return _flatten(target.value) + return [target] + + +def _is_patch_target(target: ast.expr, patchable: set[str]) -> bool: + return ( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id in patchable + ) + + +def _finding(rule: str, node: ast.expr, message: str) -> Finding: + # ast columns are 0-based; Pyrefly's (and every editor's) are 1-based. + return Finding(rule, node.lineno, node.col_offset + 1, message) diff --git a/python-multiplatform-ksp/src/main/python/typedpython/frontend.py b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py new file mode 100644 index 000000000..8f78ce128 --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/frontend.py @@ -0,0 +1,1423 @@ +"""Front end: Python AST + Pyrefly-inferred types -> the typed IR (`ir.py`, #41, SPEC N-8). + +`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 + `# 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. +- Types: parameters and returns from their annotations (`int`/`float`/`bool`, `list[float]` and + `list[int]` parameters as native arrays, anything else an opaque object); locals from their + annotation, a `for ... in range()` target, or else the Pyrefly-inferred type of their + assignments (read back through the same probe the gate uses, `rebinding.probe`). + +Purity and deopt (ir.Function). Each function is lowered first as if pure; if the result is impure +(it stores into an array, calls an object, reads an attribute, or calls an impure function), it is +lowered again in impure mode, where nothing may deopt: an int whose i64 arithmetic is not proved +free of overflow is a CPython int object (OBJ, `PyNumber_*`), a call to a compiled function that +may deopt goes through the module global (`CallObject(Global)`), and only `for ... in range()` +targets whose every binding is such a loop stay I64. The proof is a small interval analysis: +constants, range targets (between their bounds), `len()` and the i64 range of a guarded parameter. +It is recorded in the IR: an I64 ADD/SUB/MUL/NEG it proves cannot overflow is `proven=True` (the +verifier re-proves it; a proven op does not deopt). Division and modulo of ints cannot be recorded, +so they count as deopting. An impure caller of a pure callee that may deopt and returns F64, BOOL +or NONE emits `Call(redo=True)`: only the callee is redone on a deopt. + +Entry globals (ir.Function.entry_globals). A module-level name assigned once at top level, of type +float/int/bool (annotated, or literals and arithmetic of such names), never rebound or deleted in +the module, and read in a *closed* function (one that runs no user code, decided after lowering) +is read once at entry: a `Param` of the function and `Local(name)` at every read. Elsewhere it is a +`Global` (OBJ). +""" +from __future__ import annotations + +import ast +import dataclasses +import tempfile +from collections.abc import Iterable +from dataclasses import dataclass, field +from pathlib import Path + +from typedpython import gate, ir, pyrefly, rebinding +from typedpython.ir import Type + +MARKER = "# typedpython: compiled" + +I64_MIN, I64_MAX = -2**63, 2**63 - 1 +TOP = (I64_MIN, I64_MAX) +EXACT_INT_FLOAT = 2**53 # |n| <= this: int/int true division is exact-rounded in C (ir.BinOp) + +Interval = tuple[int, int] | None # None: no value at all (not yet known to be reached) + +TYPE_NAME = { + Type.I64: "int", Type.F64: "float", Type.BOOL: "bool", Type.NONE: "None", Type.OBJ: "object", + Type.F64_ARRAY: "list[float]", Type.I64_ARRAY: "list[int]", +} +SCALAR_KIND = {"int": Type.I64, "float": Type.F64, "bool": Type.BOOL} +ARRAY_KIND = {"list[float]": Type.F64_ARRAY, "list[int]": Type.I64_ARRAY} + +BINOP = { + ast.Add: ir.BinOpKind.ADD, ast.Sub: ir.BinOpKind.SUB, ast.Mult: ir.BinOpKind.MUL, + ast.Div: ir.BinOpKind.TRUEDIV, ast.FloorDiv: ir.BinOpKind.FLOORDIV, ast.Mod: ir.BinOpKind.MOD, +} +COMPARE = { + ast.Eq: ir.CompareKind.EQ, ast.NotEq: ir.CompareKind.NE, ast.Lt: ir.CompareKind.LT, + ast.LtE: ir.CompareKind.LE, ast.Gt: ir.CompareKind.GT, ast.GtE: ir.CompareKind.GE, +} +MATH_ARITY = { + ir.MathFunc.SQRT: 1, ir.MathFunc.EXP: 1, ir.MathFunc.LOG: 1, ir.MathFunc.SIN: 1, + ir.MathFunc.COS: 1, ir.MathFunc.TAN: 1, ir.MathFunc.FABS: 1, ir.MathFunc.ATAN2: 2, + ir.MathFunc.HYPOT: 2, +} + +UNSUPPORTED = { + "Try": "try statement", "TryStar": "try statement", "With": "with statement", + "AsyncWith": "async with statement", "Match": "match statement", "Raise": "raise statement", + "Assert": "assert statement", "Global": "global declaration", + "Nonlocal": "nonlocal declaration", "FunctionDef": "nested def", + "AsyncFunctionDef": "nested async def", "ClassDef": "nested class", + "Import": "import inside a function", "ImportFrom": "import inside a function", + "Delete": "del statement", "AsyncFor": "async for", "TypeAlias": "type alias", + "Lambda": "lambda", "ListComp": "list comprehension", "SetComp": "set comprehension", + "DictComp": "dict comprehension", "GeneratorExp": "generator expression", + "List": "list display", "Tuple": "tuple display", "Dict": "dict display", + "Set": "set display", "Yield": "yield (generator)", "YieldFrom": "yield from (generator)", + "Await": "await", "IfExp": "conditional expression", "JoinedStr": "f-string", + "NamedExpr": "assignment expression", "Starred": "starred expression", "Slice": "slicing", +} + + +class Skip(Exception): + """This function cannot be expressed in the IR; it stays interpreted.""" + + def __init__(self, line: int, message: str) -> None: + super().__init__(f"line {line}: {message}") + self.reason = f"line {line}: {message}" + + +def _unsupported(node: ast.AST) -> Skip: + what = UNSUPPORTED.get(type(node).__name__, f"`{type(node).__name__}`") + return Skip(getattr(node, "lineno", 0), f"{what} is not supported") + + +def _src(node: ast.AST) -> str: + return ast.unparse(node) + + +# --- the module ---------------------------------------------------------------------------------- + +@dataclass +class _ModuleInfo: + bindings: dict[str, list[tuple[int, str]]] # module-level name -> [(line, how)] + stdlib_math: bool + scalar_globals: dict[str, Type] = field(default_factory=dict) # entry-global candidates + + def bound(self, name: str) -> bool: + return name in self.bindings + + +@dataclass +class _Signature: + node: ast.FunctionDef + params: tuple[ir.Param | ir.ArrayParam, ...] + returns: Type + # `-> tuple[a, b]`: the element kinds (a scalar type, else OBJ); None when not a tuple annotation. + tuple_returns: tuple[Type, ...] | None = None + tuple_ellipsis: bool = False # `tuple[int, ...]`: a variable length + return_annotation: str = "" + + +def lower(path: Path) -> ir.Module: + path = Path(path).resolve() + source = path.read_text() + tree = ast.parse(source, filename=str(path)) + info = _module_info(tree) + + skipped: dict[str, str] = {} + signatures: dict[str, _Signature] = {} + for node, reason in _targets(tree, source, info): + if reason is not None: + skipped[node.name] = reason + continue + assert isinstance(node, ast.FunctionDef) + try: + signatures[node.name] = _signature(node) + except Skip as e: + skipped[node.name] = e.reason + + kinds = _inferred_kinds(path, source, tree, [s.node for s in signatures.values()]) + + # Optimistic start: every candidate lowered, pure, not deopting; then iterate to a fixpoint + # (purity is the greatest fixpoint through calls, may_deopt the least). + env: dict[str, ir.Function] = { + name: ir.Function(name=name, params=s.params, returns=s.returns, locals={}, body=(), pure=True, + may_deopt=False, source_line=s.node.lineno) + for name, s in signatures.items() + } + results: dict[str, ir.Function | str] = {} + for _ in range(25): + new = {name: _lower_function(s, env, info, kinds) for name, s in signatures.items()} + if new == results: + break + results = new + env = {n: r for n, r in results.items() if isinstance(r, ir.Function)} + else: + results = {n: f"line {s.node.lineno}: purity/deopt analysis did not converge" + for n, s in signatures.items()} + + functions = [] + for name, result in results.items(): + if isinstance(result, ir.Function): + functions.append(result) + else: + skipped[name] = result + functions.sort(key=lambda f: f.source_line) + return ir.Module(path.stem, tuple(functions), skipped) + + +def _module_info(tree: ast.Module) -> _ModuleInfo: + bindings: dict[str, list[tuple[int, str]]] = {} + + def add(name: str, line: int, how: str) -> None: + bindings.setdefault(name, []).append((line, how)) + + for stmt in rebinding._own_statements(tree): + if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + add(stmt.name, stmt.lineno, "def") + elif isinstance(stmt, ast.Import): + for alias in stmt.names: + stdlib = alias.name == "math" and alias.asname is None + add(alias.asname or alias.name.split(".")[0], stmt.lineno, + "import math" if stdlib else "import") + elif isinstance(stmt, ast.ImportFrom): + for alias in stmt.names: + add(alias.asname or alias.name, stmt.lineno, "import") + else: + for target in _binding_targets(stmt): + for n in ast.walk(target): + if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store): + add(n.id, stmt.lineno, "assign") + for node in ast.walk(tree): + if isinstance(node, ast.Global): + for name in node.names: + add(name, node.lineno, "global") + + math = bindings.get("math", []) + return _ModuleInfo(bindings, len(math) == 1 and math[0][1] == "import math", + _scalar_globals(tree, bindings)) + + +def _scalar_globals(tree: ast.Module, bindings: dict[str, list[tuple[int, str]]]) -> dict[str, Type]: + """Module-level names that are assigned exactly once, at top level, with a float/int/bool + value, and are never rebound (`global`, `del`, `:=`, a loop target ...) anywhere in the module.""" + spoiled = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name) + and isinstance(n.ctx, ast.Del)} + spoiled |= {n.target.id for n in ast.walk(tree) + if isinstance(n, ast.NamedExpr) and isinstance(n.target, ast.Name)} + found: dict[str, Type] = {} + for stmt in tree.body: + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) \ + and stmt.value is not None: + name, annotated = stmt.target.id, SCALAR_KIND.get(_annotation_kind(stmt.annotation)) + t = annotated + elif isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 \ + and isinstance(stmt.targets[0], ast.Name): + name, t = stmt.targets[0].id, _literal_type(stmt.value, found) + else: + continue + if t is not None and len(bindings.get(name, [])) == 1 and name not in spoiled: + found[name] = t + return found + + +def _literal_type(node: ast.expr, known: dict[str, Type]) -> Type | None: + """The type of an expression made only of literals, arithmetic and `known` globals.""" + if isinstance(node, ast.Constant): + v = node.value + if isinstance(v, bool): + return Type.BOOL + if isinstance(v, int): + return Type.I64 if I64_MIN <= v <= I64_MAX else None + return Type.F64 if isinstance(v, float) else None + if isinstance(node, ast.Name): + return known.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)): + t = _literal_type(node.operand, known) + return t if t in (Type.I64, Type.F64) else None + if isinstance(node, ast.BinOp) and type(node.op) in BINOP: + a, b = _literal_type(node.left, known), _literal_type(node.right, known) + if a not in (Type.I64, Type.F64) or b not in (Type.I64, Type.F64): + return None + if a == b == Type.I64 and not isinstance(node.op, ast.Div): + return Type.I64 + return Type.F64 + return None + + +def _binding_targets(stmt: ast.stmt) -> list[ast.expr]: + if isinstance(stmt, ast.Assign): + return list(stmt.targets) + if isinstance(stmt, (ast.AugAssign, ast.AnnAssign, ast.For, ast.AsyncFor)): + return [stmt.target] + if isinstance(stmt, (ast.With, ast.AsyncWith)): + return [i.optional_vars for i in stmt.items if i.optional_vars is not None] + return [] + + +def _targets( + tree: ast.Module, source: str, info: _ModuleInfo, +) -> list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, str | None]]: + first = next((line.strip() for line in source.splitlines() if line.strip()), "") + whole_module = first == MARKER + found: list[tuple[ast.FunctionDef | ast.AsyncFunctionDef, str | None]] = [] + for node in tree.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + marked = [d for d in node.decorator_list if isinstance(d, ast.Name) and d.id == "compiled"] + if not (whole_module or marked): + continue + reason = None + if marked and info.bound("compiled"): + line = info.bindings["compiled"][0][0] + reason = (f"line {node.lineno}: `compiled` is bound in this module (line {line}), " + "so `@compiled` is not the builtin") + elif other := [d for d in node.decorator_list if d not in marked]: + reason = (f"line {other[0].lineno}: decorator `@{_src(other[0])}` is not supported " + "(only `@compiled`)") + elif isinstance(node, ast.AsyncFunctionDef): + reason = f"line {node.lineno}: async function is not supported" + elif len(info.bindings.get(node.name, [])) > 1: + lines = ", ".join(str(line) for line, _ in info.bindings[node.name]) + reason = f"line {node.lineno}: `{node.name}` is bound more than once (lines {lines})" + found.append((node, reason)) + return found + + +def _signature(fn: ast.FunctionDef) -> _Signature: + args = fn.args + line = fn.lineno + if args.posonlyargs: + raise Skip(line, "positional-only parameters are not supported") + if args.kwonlyargs: + raise Skip(line, "keyword-only parameters are not supported") + if args.vararg is not None: + raise Skip(line, f"`*{args.vararg.arg}` is not supported") + if args.kwarg is not None: + raise Skip(line, f"`**{args.kwarg.arg}` is not supported") + if args.defaults: + raise Skip(line, "default parameter values are not supported") + params: list[ir.Param | ir.ArrayParam] = [] + for a in args.args: + if a.annotation is None: + raise Skip(a.lineno, f"parameter `{a.arg}` has no annotation") + kind = _annotation_kind(a.annotation) + if kind in ARRAY_KIND: + params.append(ir.ArrayParam(a.arg, ARRAY_KIND[kind], False)) # `stored` set later + else: + params.append(ir.Param(a.arg, SCALAR_KIND.get(kind, Type.OBJ))) + if fn.returns is None: + raise Skip(line, "no return annotation") + if isinstance(fn.returns, ast.Constant) and fn.returns.value is None: + returns = Type.NONE + else: + returns = SCALAR_KIND.get(_annotation_kind(fn.returns), Type.OBJ) + elements, ellipsis = _tuple_annotation(fn.returns) + return _Signature(fn, tuple(params), returns, elements, ellipsis, _src(fn.returns)) + + +def _tuple_annotation(annotation: ast.expr) -> tuple[tuple[Type, ...] | None, bool]: + """`tuple[int, float]` -> ((I64, F64), False); `tuple[int, ...]` -> (None, True).""" + if not (isinstance(annotation, ast.Subscript) and isinstance(annotation.value, ast.Name) + and annotation.value.id == "tuple"): + return None, False + inner = annotation.slice + items = list(inner.elts) if isinstance(inner, ast.Tuple) else [inner] + if any(isinstance(i, ast.Constant) and i.value is Ellipsis for i in items): + return None, True + return tuple(SCALAR_KIND.get(_annotation_kind(i), Type.OBJ) for i in items), False + + +def _annotation_kind(annotation: ast.expr) -> str: + if isinstance(annotation, ast.Name) and annotation.id in SCALAR_KIND: + return annotation.id + return _src(annotation).replace(" ", "") + + +# --- local types from Pyrefly -------------------------------------------------------------------- + +def _inferred_kinds( + path: Path, source: str, tree: ast.Module, functions: list[ast.FunctionDef], +) -> dict[str, dict[str, list[tuple[int, str]]]]: + """function -> local -> [(line, base type)] for every unannotated assignment (Pyrefly).""" + scopes = rebinding._scopes(tree) + wanted_scopes = {scopes.index(fn) for fn in functions} + wanted = [a for a in rebinding.assignments(tree) if a.scope in wanted_scopes] + if not wanted: + return {} + probed, probes = rebinding.probe(source, wanted) + with tempfile.TemporaryDirectory(prefix="typedpython-frontend-") as work: + copies = gate._mirror(Path(work), {str(path): probed}) + [copy] = copies + roots = pyrefly.import_roots([str(path)]) + report = pyrefly.run([Path(copy)], [Path(work) / "0", Path(copy).parent, *roots]) + types = report.expression_types.get(copy, {}) + + found: dict[str, dict[str, list[tuple[int, str]]]] = {} + for p in probes: + a = p.assignment + if a is None: + continue + spelled = types.get((p.line, p.column), "Unknown") + function = scopes[a.scope] + assert isinstance(function, ast.FunctionDef) + found.setdefault(function.name, {}).setdefault(p.name, []).append( + (a.line, rebinding._base(spelled))) + return found + + +# --- one function -------------------------------------------------------------------------------- + +def _lower_function( + sig: _Signature, env: dict[str, ir.Function], info: _ModuleInfo, + kinds: dict[str, dict[str, list[tuple[int, str]]]], +) -> ir.Function | str: + inferred = kinds.get(sig.node.name, {}) + pure_error: Skip | None = None + try: + fn = _lower_in_mode(sig, env, info, inferred, impure=False) + if fn.pure: + return fn + except Skip as e: + pure_error = e + try: + return _lower_in_mode(sig, env, info, inferred, impure=True) + except Skip as e: + return (pure_error or e).reason + + +def _lower_in_mode(sig, env, info, inferred, impure: bool) -> ir.Function: + fn = _lower_with_bounds(sig, env, info, inferred, impure, info.scalar_globals) + if fn.entry_globals and not _closed(fn, env): + # A global read once at entry is only the same as a read at each use if nothing between + # can run user code: here something can, so the reads stay `Global`. + fn = _lower_with_bounds(sig, env, info, inferred, impure, {}) + return fn + + +def _lower_with_bounds(sig, env, info, inferred, impure: bool, + candidates: dict[str, Type]) -> ir.Function: + # Range-target intervals: least fixpoint by iteration, widened to TOP if it does not settle. + bounds: dict[str, Interval] = {} + for _ in range(8): + lowerer = _Lowerer(sig, env, info, inferred, impure, bounds, candidates) + fn = lowerer.run() + joined = {**bounds} + for var, iv in lowerer.found_bounds.items(): + joined[var] = _join(joined.get(var), iv) + if joined == bounds: + return fn + bounds = joined + widened = {var: TOP for var in bounds} + return _Lowerer(sig, env, info, inferred, impure, widened, candidates).run() + + +def _join(a: Interval, b: Interval) -> Interval: + if a is None: + return b + if b is None: + return a + return (min(a[0], b[0]), max(a[1], b[1])) + + +@dataclass +class _Binding: + line: int + how: str # "range" (a for-over-range target), "annotation" (no value), "assign", "for" + + +class _Lowerer: + def __init__(self, sig: _Signature, env: dict[str, ir.Function], info: _ModuleInfo, + inferred: dict[str, list[tuple[int, str]]], impure: bool, + bounds: dict[str, Interval], candidates: dict[str, Type]) -> None: + self.sig = sig + self.candidates = candidates + self.entry_used: dict[str, Type] = {} + self.fn = sig.node + self.env = env + self.info = info + self.inferred = inferred + self.impure_mode = impure + self.bounds = bounds + self.found_bounds: dict[str, Interval] = {} + + self.params = {p.name: p for p in sig.params} + self.arrays = {p.name: p.type for p in sig.params if isinstance(p, ir.ArrayParam)} + self.bindings: dict[str, list[_Binding]] = {} + self.annotations: dict[str, list[tuple[int, str]]] = {} + self._collect_bindings() + # Local arrays (ir.NewArray / CopyArray): name -> array type; also in `self.arrays`. + self.local_arrays = self._find_local_arrays() + self.arrays.update(self.local_arrays) + self.range_vars = { + n for n, bs in self.bindings.items() + if n not in self.params and any(b.how == "range" for b in bs) + and all(b.how in ("range", "annotation") for b in bs) + } + self.all_names = {n.id for n in ast.walk(self.fn) if isinstance(n, ast.Name)} \ + | set(self.params) + + self.types: dict[str, Type] = {} + self.temps: dict[str, Type] = {} + self.pre: list[ir.Stmt] = [] + self.hoist_ok = True + self.impure = False # an effect outside the locals + self.deopts = False # a node that can deopt + self.stored: set[str] = set() + self.current: ast.stmt | None = None # the statement being lowered (for messages) + + # --- entry --- + + def run(self) -> ir.Function: + body = self.block(self.fn.body) + if self.sig.returns != Type.NONE and _falls_through(body): + last = self.fn.body[-1] + raise Skip(getattr(last, "end_lineno", None) or last.lineno, + f"can reach the end without returning a {TYPE_NAME[self.sig.returns]} " + "(CPython would return None)") + locals_ = {} + for name in self.bindings: + if name not in self.params: + locals_[name] = self.local_type(name, self.bindings[name][0].line) + locals_.update(self.temps) + params = tuple( + ir.ArrayParam(p.name, p.type, p.name in self.stored) if isinstance(p, ir.ArrayParam) + else p for p in self.sig.params + ) + pure = not self.impure + assert pure or not self.deopts or not self.impure_mode, \ + "impure mode emitted a deopting node" + return ir.Function( + name=self.fn.name, params=params, returns=self.sig.returns, locals=locals_, body=body, + pure=pure, may_deopt=self.deopts, source_line=self.fn.lineno, + entry_globals=tuple(ir.Param(n, t) for n, t in self.entry_used.items())) + + # --- names and types --- + + def _collect_bindings(self) -> None: + def add(name: str, line: int, how: str) -> None: + self.bindings.setdefault(name, []).append(_Binding(line, how)) + + for stmt in rebinding._own_statements(self.fn): + if stmt is self.fn: + continue + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + add(stmt.target.id, stmt.lineno, "assign" if stmt.value is not None else "annotation") + self.annotations.setdefault(stmt.target.id, []).append( + (stmt.lineno, _annotation_kind(stmt.annotation))) + elif isinstance(stmt, (ast.For, ast.AsyncFor)): + how = "range" if _is_range_call(stmt.iter) else "for" + for n in ast.walk(stmt.target): + if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store): + add(n.id, stmt.lineno, how) + else: + for target in _binding_targets(stmt): + for n in ast.walk(target): + if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store): + add(n.id, stmt.lineno, "assign") + + def _find_local_arrays(self) -> dict[str, Type]: + """Locals created by `list(range(n))`, `[v] * n` or `src[:]` (src an array). Every binding + of such a name must be one of those creations: a local array is never rebound to anything + else, so it has one element type for its whole life.""" + creations: dict[str, list[tuple[ast.stmt, str, ast.expr]]] = {} + for stmt in rebinding._own_statements(self.fn): + if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 \ + and isinstance(stmt.targets[0], ast.Name): + name, value = stmt.targets[0].id, stmt.value + elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) \ + and stmt.value is not None: + name, value = stmt.target.id, stmt.value + else: + continue + kind = _creation_kind(value) + if kind is not None and name not in self.params: + creations.setdefault(name, []).append((stmt, kind, value)) + known = set(self.arrays) | {n for n, cs in creations.items() + if any(k != "copy" for _, k, _ in cs)} + while True: + more = {n for n, cs in creations.items() if n not in known and any( + k == "copy" and v.value.id in known for _, k, v in cs)} # type: ignore[attr-defined] + if not more: + break + known |= more + found: dict[str, Type] = {} + for name, cs in creations.items(): + if name not in known: + continue + cs = [c for c in cs if c[1] != "copy" or c[2].value.id in known] # type: ignore[attr-defined] + lines = {c[0].lineno for c in cs} + other = [b for b in self.bindings[name] if b.how != "annotation" and b.line not in lines] + if other: + raise Skip(other[0].line, f"local `{name}` is a native array (created at line " + f"{cs[0][0].lineno}) but is also bound by something " + "that does not create an array") + found[name] = self._array_type(name, cs, found) + return found + + def _array_type(self, name: str, cs, found: dict[str, Type]) -> Type: + line = cs[0][0].lineno + kinds: list[tuple[int, str]] = list(self.annotations.get(name, [])) + if name not in self.annotations: + kinds += self.inferred.get(name, []) + distinct = sorted({k for _, k in kinds}) + if len(distinct) > 1: + raise Skip(line, f"local `{name}` is given values of types " + + " and ".join(f"`{k}`" for k in distinct)) + if distinct: + if distinct[0] not in ARRAY_KIND: + raise Skip(line, f"local `{name}: {distinct[0]}`: a native array's element type " + "must be exactly int or float") + return ARRAY_KIND[distinct[0]] + for _, kind, value in cs: # unknown to Pyrefly: a copy takes its source's type + if kind == "copy": + src = value.value.id + if src in self.arrays: + return self.arrays[src] + if src in found: + return found[src] + raise Skip(line, f"no type is known for local `{name}`") + + def is_local(self, name: str) -> bool: + return name in self.params or name in self.bindings or name in self.temps + + def local_type(self, name: str, line: int) -> Type: + if name in self.temps: + return self.temps[name] + if name in self.params: + return self.params[name].type + if name in self.local_arrays: + return self.local_arrays[name] + if name in self.types: + return self.types[name] + kinds: list[tuple[int, str]] = list(self.annotations.get(name, [])) + kinds += [(b.line, "int") for b in self.bindings.get(name, []) if b.how == "range"] + if name not in self.annotations: + kinds += self.inferred.get(name, []) + distinct = sorted({k for _, k in kinds}) + if not distinct: + raise Skip(self.bindings[name][0].line, f"no type is known for local `{name}`") + if len(distinct) > 1: + where = next(line for line, k in kinds if k != kinds[0][1]) + raise Skip(where, f"local `{name}` is given values of types " + + " and ".join(f"`{k}`" for k in distinct)) + kind = distinct[0] + if kind in ARRAY_KIND or kind.startswith(("list[float]", "list[int]")): + raise Skip(kinds[0][0], f"local `{name}: {kind}`: a list local is a native array only " + "when created by `list(range(n))`, `[v] * n` or `src[:]`") + if kind == "int": + t = Type.OBJ if self.impure_mode and name not in self.range_vars else Type.I64 + else: + t = SCALAR_KIND.get(kind, Type.OBJ) + self.types[name] = t + return t + + def temp(self, value: ir.Expr) -> ir.Local: + n = len(self.temps) + while f"_tp{n}" in self.all_names or f"_tp{n}" in self.temps: + n += 1 + name = f"_tp{n}" + self.temps[name] = value.type + self.pre.append(ir.Assign(name, value)) + return ir.Local(value.type, name) + + # --- statements --- + + def block(self, stmts: Iterable[ast.stmt]) -> tuple[ir.Stmt, ...]: + out: list[ir.Stmt] = [] + for s in stmts: + out += self.stmt(s) + return tuple(out) + + def start(self) -> None: + self.pre = [] + self.hoist_ok = True + + def take(self) -> list[ir.Stmt]: + pre, self.pre = self.pre, [] + return pre + + def stmt(self, node: ast.stmt) -> list[ir.Stmt]: + self.start() + self.current = node + line = node.lineno + if isinstance(node, ast.Pass): + return [] + if isinstance(node, ast.Break): + return [ir.Break()] + if isinstance(node, ast.Continue): + return [ir.Continue()] + if isinstance(node, ast.Expr): + if isinstance(node.value, ast.Constant): + return [] # a docstring or a bare literal: no effect + value = self.sub(node.value) + return self.take() + [ir.ExprStmt(value)] + if isinstance(node, ast.Assign): + if len(node.targets) != 1: + raise Skip(line, "chained assignment `a = b = ...` is not supported") + return self.assign(node.targets[0], node.value, node) + if isinstance(node, ast.AnnAssign): + if not isinstance(node.target, ast.Name): + raise Skip(line, f"annotated assignment to `{_src(node.target)}` is not supported") + if node.value is None: + return [] + return self.assign(node.target, node.value, node) + if isinstance(node, ast.AugAssign): + return self.aug_assign(node) + if isinstance(node, ast.If): + cond = self.condition(node.test) + pre = self.take() + return pre + [ir.If(cond, self.block(node.body), self.block(node.orelse))] + if isinstance(node, ast.While): + if node.orelse: + raise Skip(line, "`while ... else` is not supported") + cond = self.condition(node.test) + pre = self.take() + body = self.block(node.body) + if not pre: + return [ir.While(cond, body)] + # The condition needs statements (a chain's temporary): run them on every iteration. + exit_ = ir.If(cond, (), (ir.Break(),)) # cond stays directly a condition + return [ir.While(ir.Const(Type.BOOL, True), (*pre, exit_, *body))] + if isinstance(node, ast.For): + return self.for_range(node) + if isinstance(node, ast.Return): + return self.return_(node) + raise _unsupported(node) + + def assign(self, target: ast.expr, value_node: ast.expr, node: ast.stmt) -> list[ir.Stmt]: + line = node.lineno + if isinstance(target, ast.Name): + name = target.id + if name in self.local_arrays: + return self.new_array(name, value_node, node) + if name in self.arrays: + raise Skip(line, f"array parameter `{name}` is rebound") + value = self.value(value_node) + t = self.local_type(name, line) + return self.take() + [ir.Assign(name, self.coerce(value, t, value_node, + f"local `{name}`"))] + if isinstance(target, ast.Subscript): + array, elem = self.array_of(target) + # CPython evaluates the value first, then the index. + value = self.value(value_node) + value = self.coerce(value, elem, value_node, f"`{array}: {TYPE_NAME[self.arrays[array]]}`") + if not _simple(target.slice) and not _trivial(value): + value = self.temp(value) + index = self.index(target.slice) + self.store_effect(array) + return self.take() + [ir.StoreIndex(array, index, value)] + if isinstance(target, ast.Attribute): + raise Skip(line, f"assignment to attribute `{_src(target)}` is not supported") + if isinstance(target, (ast.Tuple, ast.List)): + raise Skip(line, "tuple unpacking is not supported") + raise _unsupported(target) + + def array_word(self, name: str) -> str: + return "local array" if name in self.local_arrays else "array parameter" + + def store_effect(self, array: str) -> None: + """A store into a parameter list is an effect (write-back); into a local array it is not.""" + if array not in self.local_arrays: + self.stored.add(array) + self.impure = True + + def new_array(self, name: str, value_node: ast.expr, node: ast.stmt) -> list[ir.Stmt]: + line = node.lineno + t = self.local_arrays[name] + elem = Type.F64 if t == Type.F64_ARRAY else Type.I64 + what = f"local array `{name}: {TYPE_NAME[t]}`" + kind = _creation_kind(value_node) + if kind == "iota": + call = value_node.args[0] # type: ignore[attr-defined] + if not (self.builtin("list") and self.builtin("range")): + raise Skip(line, f"`{_src(value_node)}` with `list` or `range` rebound") + if elem != Type.I64: + raise Skip(line, f"`{_src(value_node)}` is a list of int, but {what} is " + f"{TYPE_NAME[t]}") + length = self.range_bound(call.args[0]) + return self.take() + [ir.Assign(name, ir.NewArray(t, length, None, True))] + if kind == "fill": + assert isinstance(value_node, ast.BinOp) and isinstance(value_node.left, ast.List) + fill = self.coerce(self.value(value_node.left.elts[0]), elem, + value_node.left.elts[0], what) + if not _trivial(fill): + fill = self.temp(fill) # `[v] * n` evaluates v before n + length = self.value(value_node.right) + if length.type != Type.I64: + why = (" (it is a CPython int here: its arithmetic is not proved free of i64 " + "overflow in an impure function)" if length.type == Type.OBJ + and self.impure_mode else "") + raise Skip(line, f"array length `{_src(value_node.right)}` is " + f"{TYPE_NAME[length.type]}, not a native int{why}") + return self.take() + [ir.Assign(name, ir.NewArray(t, length, fill))] + if kind == "copy": + assert isinstance(value_node, ast.Subscript) and isinstance(value_node.value, ast.Name) + src = value_node.value.id + if self.arrays[src] != t: + raise Skip(line, f"`{_src(value_node)}` is {TYPE_NAME[self.arrays[src]]}, but " + f"{what} is {TYPE_NAME[t]}") + return [ir.Assign(name, ir.CopyArray(t, src))] + raise Skip(line, f"{what} is rebound to `{_src(value_node)}`, which does not create an array") + + def aug_assign(self, node: ast.AugAssign) -> list[ir.Stmt]: + line = node.lineno + op = self.binop_kind(node.op, node) + target = node.target + if isinstance(target, ast.Name): + name = target.id + if name in self.arrays: + raise Skip(line, f"{self.array_word(name)} `{name}` is rebound") + if not self.is_local(name): + raise _unsupported(node) + t = self.local_type(name, line) + current = ir.Local(t, name) + result = self.arith(op, current, self.sub(node.value), node) + return self.take() + [ir.Assign(name, self.coerce(result, t, node, f"local `{name}`"))] + if isinstance(target, ast.Subscript): + array, elem = self.array_of(target) + index = self.index(target.slice) + if not isinstance(index, (ir.Local, ir.Const)): + index = self.temp(index) # `a[i] += e` evaluates `i` once + load = ir.Index(elem, array, index) + self.hoist_ok = False + result = self.arith(op, load, self.sub(node.value), node) + result = self.coerce(result, elem, node, f"`{array}: {TYPE_NAME[self.arrays[array]]}`") + self.store_effect(array) + return self.take() + [ir.StoreIndex(array, index, result)] + if isinstance(target, ast.Attribute): + raise Skip(line, f"assignment to attribute `{_src(target)}` is not supported") + raise _unsupported(target) + + def for_range(self, node: ast.For) -> list[ir.Stmt]: + line = node.lineno + if node.orelse: + raise Skip(line, "`for ... else` is not supported") + if not isinstance(node.target, ast.Name): + raise Skip(line, f"loop target `{_src(node.target)}` is not supported (only a name)") + if isinstance(node.iter, ast.Name) and node.iter.id in self.arrays: + raise self.array_misuse(node.iter.id, node.iter) + if not (_is_range_call(node.iter) and self.builtin("range")): + raise Skip(line, f"`for` over `{_src(node.iter)}` is not supported (only range())") + call = node.iter + assert isinstance(call, ast.Call) + var = node.target.id + if var in self.params: + raise Skip(line, f"loop variable `{var}` is a parameter") + for inner in ast.walk(ast.Module(body=node.body, type_ignores=[])): + for target in _binding_targets(inner) if isinstance(inner, ast.stmt) else []: + if any(isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store) and n.id == var + for n in ast.walk(target)): + raise Skip(inner.lineno, f"loop variable `{var}` is assigned in the loop body") + if self.local_type(var, line) != Type.I64: + other = next(b.line for b in self.bindings[var] if b.how != "range") + raise Skip(line, f"loop variable `{var}` is also assigned at line {other}; in an " + "impure function that makes it a CPython int, not a range index") + args = call.args + if len(args) == 3: + step = _int_literal(args[2]) + if step is None or step == 0: + raise Skip(line, f"range step `{_src(args[2])}` must be a non-zero int literal") + else: + step = 1 + if len(args) == 1: + start: ir.Expr = ir.Const(Type.I64, 0) + stop = self.range_bound(args[0]) + else: + start = self.range_bound(args[0]) + stop = self.range_bound(args[1]) + pre = self.take() + + s, e = self.interval(start), self.interval(stop) + if s is not None and e is not None: + if step > 0: + iv = (s[0], max(s[0], e[1] - 1)) + else: + iv = (min(s[1], e[0] + 1), s[1]) + self.found_bounds[var] = _join(self.found_bounds.get(var), iv) + + body = self.block(node.body) + return pre + [ir.ForRange(var, start, stop, ir.Const(Type.I64, step), body)] + + def range_bound(self, node: ast.expr) -> ir.Expr: + e = self.value(node) + if e.type != Type.I64: + why = (" (it is a CPython int here: its arithmetic is not proved free of i64 overflow " + "in an impure function)" if e.type == Type.OBJ and self.impure_mode else "") + raise Skip(node.lineno, f"range bound `{_src(node)}` is not a native int{why}") + return e + + def return_(self, node: ast.Return) -> list[ir.Stmt]: + returns = self.sig.returns + if node.value is None or (isinstance(node.value, ast.Constant) and node.value.value is None): + if returns != Type.NONE: + raise Skip(node.lineno, f"returns None from a function declared to return " + f"{TYPE_NAME[returns]}") + return [ir.Return(None)] + if returns == Type.NONE: + raise Skip(node.lineno, "returns a value from a function declared to return None") + if isinstance(node.value, ast.Tuple): + return self.return_tuple(node.value) + value = self.value(node.value) + value = self.coerce(value, returns, node.value, "the return value") + return self.take() + [ir.Return(value)] + + def return_tuple(self, node: ast.Tuple) -> list[ir.Stmt]: + sig = self.sig + if any(isinstance(e, ast.Starred) for e in node.elts): + raise Skip(node.lineno, f"`return {_src(node)}` with a starred element is not supported") + if sig.tuple_returns is None: + why = ("a variable-length `tuple[..., ...]`" if sig.tuple_ellipsis + else f"`{sig.return_annotation}`") + raise Skip(node.lineno, f"returns `{_src(node)}`, but the declared return type is " + f"{why}, not a fixed `tuple[...]`") + kinds = sig.tuple_returns + if len(kinds) != len(node.elts): + raise Skip(node.lineno, f"returns {len(node.elts)} values, but the return type " + f"`{sig.return_annotation}` declares {len(kinds)}") + elements = [] + for kind, e_node in zip(kinds, node.elts): + e = self.value(e_node) + if e.type in ir.SCALARS and kind in ir.SCALARS and e.type != kind: + raise Skip(e_node.lineno, f"`{_src(e_node)}` is {TYPE_NAME[e.type]}, but the " + f"return type `{sig.return_annotation}` declares " + f"{TYPE_NAME[kind]} there") + elements.append(self.boxed(e, e_node)) + return self.take() + [ir.Return(ir.Tuple(Type.OBJ, tuple(elements)))] + + # --- expressions --- + + def sub(self, node: ast.expr, cond: int = 0) -> ir.Expr: + """Lower a subexpression in evaluation order; after a non-trivial one, nothing later in + the statement may be hoisted in front of it.""" + e = self.expr(node, cond) + if not _trivial(e): + self.hoist_ok = False + return e + + def value(self, node: ast.expr) -> ir.Expr: + e = self.sub(node) + if e.type == Type.NONE: + raise Skip(node.lineno, f"uses the result of `{_src(node)}`, which returns None") + return e + + def condition(self, node: ast.expr) -> ir.Expr: + return self.truth(self.sub(node, cond=2), node) + + def truth(self, e: ir.Expr, node: ast.expr) -> ir.Expr: + if e.type == Type.BOOL: + return e + if e.type == Type.I64: + return ir.Compare(Type.BOOL, ir.CompareKind.NE, e, ir.Const(Type.I64, 0)) + if e.type == Type.F64: + return ir.Compare(Type.BOOL, ir.CompareKind.NE, e, ir.Const(Type.F64, 0.0)) + if e.type == Type.OBJ: + self.impure = True + return ir.Truth(Type.BOOL, e) + raise Skip(node.lineno, f"truth value of `{_src(node)}` ({TYPE_NAME[e.type]})") + + def expr(self, node: ast.expr, cond: int = 0) -> ir.Expr: + """`cond`: 2 when `node` is itself an If/While condition, 1 when it is an operand of an + And/Or that is the condition, 0 elsewhere (ir.CompareObj is allowed only at 2 and 1).""" + line = node.lineno + if isinstance(node, ast.Constant): + v = node.value + if isinstance(v, bool): + return ir.Const(Type.BOOL, v) + if isinstance(v, int): + if not I64_MIN <= v <= I64_MAX: + raise Skip(line, f"int literal {v} is outside i64") + return ir.Const(Type.I64, v) + if isinstance(v, float): + return ir.Const(Type.F64, v) + raise Skip(line, f"{type(v).__name__} constant `{_src(node)}` is not supported") + if isinstance(node, ast.Name): + return self.name(node) + if isinstance(node, ast.BinOp): + op = self.binop_kind(node.op, node) + left = self.sub(node.left) + right = self.sub(node.right) + return self.arith(op, left, right, node) + if isinstance(node, ast.UnaryOp): + return self.unary(node) + if isinstance(node, ast.Compare): + return self.compare(node, cond) + if isinstance(node, ast.BoolOp): + return self.boolop(node, cond) + if isinstance(node, ast.Call): + return self.call(node) + if isinstance(node, ast.Attribute): + if isinstance(node.value, ast.Name) and node.value.id in self.arrays: + raise self.array_misuse(node.value.id, node) + obj = self.boxed(self.sub(node.value), node.value) + self.impure = True + return ir.GetAttr(Type.OBJ, obj, node.attr) + if isinstance(node, ast.Subscript): + if isinstance(node.value, ast.Name) and node.value.id in self.arrays: + if isinstance(node.slice, ast.Slice): + raise Skip(line, f"`{_src(node)}` slices {self.array_word(node.value.id)} " + f"`{node.value.id}`: only `y = {node.value.id}[:]` as the whole " + "right-hand side of an assignment is a native copy") + array, elem = self.array_of(node) + return ir.Index(elem, array, self.index(node.slice)) + raise Skip(line, f"subscript `{_src(node)}` of an object is not supported") + raise _unsupported(node) + + def name(self, node: ast.Name) -> ir.Expr: + name = node.id + if name in self.arrays: + raise self.array_misuse(name, node) + if self.is_local(name): + if name not in self.params and name not in self.temps and all( + b.how == "annotation" for b in self.bindings[name]): + raise Skip(node.lineno, f"`{name}` is read but never assigned") + return ir.Local(self.local_type(name, node.lineno), name) + if name in self.candidates: + self.entry_used.setdefault(name, self.candidates[name]) + return ir.Local(self.candidates[name], name) + return ir.Global(Type.OBJ, name) + + def array_misuse(self, name: str, node: ast.AST) -> Skip: + where = self.current if isinstance(self.current, ( + ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Return, ast.Expr)) else node + if name in self.local_arrays: + return Skip(node.lineno, f"local array `{name}` is used other than by indexing, len() " + f"or as a `[:]` source (in `{_src(where)}`)") + return Skip(node.lineno, f"array parameter `{name}` is used other than by indexing or " + f"len() (in `{_src(where)}`)") + + def array_of(self, node: ast.Subscript) -> tuple[str, Type]: + if not (isinstance(node.value, ast.Name) and node.value.id in self.arrays): + raise Skip(node.lineno, f"subscript `{_src(node)}` of an object is not supported") + name = node.value.id + return name, Type.F64 if self.arrays[name] == Type.F64_ARRAY else Type.I64 + + def index(self, node: ast.expr) -> ir.Expr: + if isinstance(node, ast.Slice): + raise _unsupported(node) + e = self.value(node) + if e.type != Type.I64: + why = (" (it is a CPython int here: its arithmetic is not proved free of i64 overflow " + "in an impure function)" if e.type == Type.OBJ and self.impure_mode else "") + raise Skip(node.lineno, f"index `{_src(node)}` is {TYPE_NAME[e.type]}, not a native " + f"int{why}") + return e + + def binop_kind(self, op: ast.operator, node: ast.AST) -> ir.BinOpKind: + kind = BINOP.get(type(op)) + if kind is None: + raise Skip(node.lineno, f"operator `{_src(node)}` is not supported") + return kind + + def arith(self, op: ir.BinOpKind, left: ir.Expr, right: ir.Expr, node: ast.AST) -> ir.Expr: + lt, rt = left.type, right.type + if Type.BOOL in (lt, rt) or Type.NONE in (lt, rt): + raise Skip(node.lineno, f"arithmetic on {TYPE_NAME[Type.BOOL if Type.BOOL in (lt, rt) else Type.NONE]} " + f"in `{_src(node)}` is not supported") + if Type.OBJ in (lt, rt): + return self.obj_binop(op, left, right) + if lt == Type.I64 and rt == Type.I64: + result = Type.F64 if op == ir.BinOpKind.TRUEDIV else Type.I64 + e = ir.BinOp(result, op, left, right) + if self.recordable(e) and self.proved(e): + return dataclasses.replace(e, proven=True) + if self.impure_mode: + return self.obj_binop(op, left, right) + self.deopts = True + return e + if lt == Type.I64: + left = ir.ToFloat(Type.F64, left) + if rt == Type.I64: + right = ir.ToFloat(Type.F64, right) + return ir.BinOp(Type.F64, op, left, right) + + def obj_binop(self, op: ir.BinOpKind, left: ir.Expr, right: ir.Expr) -> ir.Expr: + self.impure = True + return ir.BinOp(Type.OBJ, op, _box(left), _box(right)) + + def unary(self, node: ast.UnaryOp) -> ir.Expr: + if isinstance(node.op, ast.Not): + operand = self.truth(self.sub(node.operand), node.operand) + return ir.UnaryOp(Type.BOOL, ir.UnaryOpKind.NOT, operand) + if not isinstance(node.op, (ast.USub, ast.UAdd)): + raise Skip(node.lineno, f"operator `{_src(node)}` is not supported") + operand = self.sub(node.operand) + kind = ir.UnaryOpKind.NEG if isinstance(node.op, ast.USub) else ir.UnaryOpKind.POS + if operand.type == Type.F64: + return ir.UnaryOp(Type.F64, kind, operand) + if operand.type == Type.I64: + e = ir.UnaryOp(Type.I64, kind, operand) + if kind == ir.UnaryOpKind.POS: + return e + if self.proved(e): + return dataclasses.replace(e, proven=True) + if self.impure_mode: # -x of a CPython int is 0 - x + return self.obj_binop(ir.BinOpKind.SUB, ir.Const(Type.I64, 0), operand) + self.deopts = True + return e + raise Skip(node.lineno, f"unary `{_src(node)}` on {TYPE_NAME[operand.type]} is not " + "supported") + + def compare(self, node: ast.Compare, cond: int) -> ir.Expr: + # A chain is an And of its links: as a condition (2) that And is the condition; as an + # operand of the condition's And/Or (1) it would be nested, so only a single link there. + obj_ok = cond == 2 or (cond == 1 and len(node.ops) == 1) + left = self.sub(node.left) + links: list[ir.Expr] = [] + last = len(node.ops) - 1 + for i, (op, comparator) in enumerate(zip(node.ops, node.comparators)): + if i > 0: + self.hoist_ok = False # later links run only if the earlier ones held + hoist_ok = self.hoist_ok + right = self.expr(comparator) + if right.type == Type.NONE: + raise Skip(comparator.lineno, f"uses the result of `{_src(comparator)}`, which " + "returns None") + if i < last and not isinstance(right, (ir.Local, ir.Const)): + # The middle operand of a chain is evaluated once: bind it to a temporary. + if hoist_ok: + right = self.temp(right) + elif not _trivial(right): + raise Skip(comparator.lineno, f"chained comparison: `{_src(comparator)}` " + "cannot be evaluated once here") + if not _trivial(right): + self.hoist_ok = False + links.append(self.link(op, left, right, obj_ok, node)) + left = right + result = links[0] + for link in links[1:]: + result = ir.And(Type.BOOL, result, link) + return result + + def link(self, op: ast.cmpop, left: ir.Expr, right: ir.Expr, obj_ok: bool, + node: ast.Compare) -> ir.Expr: + kind = COMPARE.get(type(op)) + if kind is None: + raise Skip(node.lineno, f"comparison `{_src(node)}` is not supported " + "(only == != < <= > >=)") + lt, rt = left.type, right.type + if Type.OBJ in (lt, rt): + if not obj_ok: + raise Skip(node.lineno, f"comparison `{_src(node)}` with an object is not " + "directly a condition (its result need not be a bool)") + self.impure = True + return ir.CompareObj(Type.BOOL, kind, _box(left), _box(right)) + if (lt == Type.BOOL) != (rt == Type.BOOL): + raise Skip(node.lineno, f"comparison `{_src(node)}` of a bool with a number is not " + "supported") + return ir.Compare(Type.BOOL, kind, left, right) + + def boolop(self, node: ast.BoolOp, cond: int) -> ir.Expr: + word = "and" if isinstance(node.op, ast.And) else "or" + operands = [] + for i, v in enumerate(node.values): + if i > 0: + self.hoist_ok = False # evaluated only if the earlier operands did not decide + e = self.sub(v, 1 if cond == 2 else 0) + if cond == 2: + e = self.truth(e, v) + elif e.type != Type.BOOL: + raise Skip(node.lineno, f"`{word}` on a non-bool operand `{_src(v)}` outside a " + "condition (it returns the operand)") + operands.append(e) + result = operands[0] + for e in operands[1:]: + result = (ir.And if word == "and" else ir.Or)(Type.BOOL, result, e) + return result + + def builtin(self, name: str) -> bool: + return not self.info.bound(name) and not self.is_local(name) + + def call(self, node: ast.Call) -> ir.Expr: + line = node.lineno + if any(isinstance(a, ast.Starred) for a in node.args) or any( + k.arg is None for k in node.keywords): + raise Skip(line, f"`*`/`**` arguments in `{_src(node)}` are not supported") + func = node.func + plain = not node.keywords + + if isinstance(func, ast.Name): + name = func.id + if name in self.arrays: + raise self.array_misuse(name, node) + if plain and len(node.args) == 1 and name == "len" and self.builtin("len") \ + and isinstance(node.args[0], ast.Name) and node.args[0].id in self.arrays: + return ir.Len(Type.I64, node.args[0].id) + if plain and len(node.args) == 1 and name == "float" and self.builtin("float"): + arg = self.value(node.args[0]) + if arg.type == Type.I64: + return ir.ToFloat(Type.F64, arg) + if arg.type == Type.F64: + return arg # float(x) of an exact float is x itself + self.impure = True + return ir.ObjToFloat(Type.F64, _box(arg)) + if not self.is_local(name) and name in self.env: + direct = self.direct_call(name, node) + if direct is not None: + return direct + + if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name) \ + and func.value.id == "math" and self.info.stdlib_math \ + and not self.is_local("math") and plain: + math = self.math_call(func.attr, node) + if math is not None: + return math + + if isinstance(func, ast.Name) and func.id in self.arrays: + raise self.array_misuse(func.id, node) + callee = self.boxed(self.sub(func), func) + self.hoist_ok = False + args = [self.boxed(self.value(a), a) for a in node.args] + args += [self.boxed(self.value(k.value), k.value) for k in node.keywords] + self.impure = True + return ir.CallObject(Type.OBJ, callee, tuple(args), + tuple(k.arg for k in node.keywords if k.arg is not None)) + + def direct_call(self, name: str, node: ast.Call) -> ir.Expr | None: + """`Call` of another compiled function, else `CallObject` of its module global; None when + the generic path should lower the call (decided before any argument is lowered).""" + callee = self.env[name] + if node.keywords or len(node.args) != len(callee.params): + return None # CPython's own TypeError, through the interpreted path + if any(isinstance(p, ir.ArrayParam) for p in callee.params): + return None + self.hoist_ok = False + args = [self.value(a) for a in node.args] + redo = callee.may_deopt and self.impure_mode and callee.pure \ + and callee.returns in (Type.F64, Type.BOOL, Type.NONE) + # else a deopt could not be redone (an I64 result of a redo could be a big int) + direct = redo or not (callee.may_deopt and self.impure_mode) + for p, e in zip(callee.params, args): + if p.type != Type.OBJ and e.type != p.type: + direct = False # the callee's guard would deopt; the global keeps CPython's result + if not direct: + self.impure = True + return ir.CallObject(Type.OBJ, ir.Global(Type.OBJ, name), + tuple(self.boxed(e, a) for e, a in zip(args, node.args))) + if not callee.pure: + self.impure = True + if callee.may_deopt and not redo: + self.deopts = True + typed = tuple(self.boxed(e, a) if p.type == Type.OBJ else e + for p, e, a in zip(callee.params, args, node.args)) + return ir.Call(callee.returns, name, typed, redo=redo) + + def math_call(self, attr: str, node: ast.Call) -> ir.Expr | None: + try: + func = ir.MathFunc(attr) + except ValueError: + return None + if len(node.args) != MATH_ARITY[func]: + return None + self.hoist_ok = False + args = [self.value(a) for a in node.args] + if all(e.type in (Type.I64, Type.F64) for e in args): + return ir.MathCall(Type.F64, func, tuple( + ir.ToFloat(Type.F64, e) if e.type == Type.I64 else e for e in args)) + # math's own conversion of an object (or a bool): call `math.` as an object. + self.impure = True + callee = ir.GetAttr(Type.OBJ, ir.Global(Type.OBJ, "math"), attr) + return ir.CallObject(Type.OBJ, callee, tuple(self.boxed(e, a) + for e, a in zip(args, node.args))) + + def boxed(self, e: ir.Expr, node: ast.AST) -> ir.Expr: + if e.type == Type.NONE: + raise Skip(node.lineno, f"uses the result of `{_src(node)}`, which returns None") + return _box(e) + + def coerce(self, value: ir.Expr, target: Type, node: ast.AST, what: str) -> ir.Expr: + if value.type == target: + return value + if target == Type.OBJ and value.type in ir.SCALARS: + return ir.Box(Type.OBJ, value) + extra = "" + if value.type == Type.OBJ: + extra = " (CPython would keep the object as it is; there is no conversion to make)" + elif value.type == Type.I64 and target == Type.F64: + extra = " (CPython keeps an int there, it does not convert it to float)" + raise Skip(getattr(node, "lineno", 0), + f"`{_src(node)}` is {TYPE_NAME[value.type]}, but {what} is " + f"{TYPE_NAME[target]}{extra}") + + # --- intervals --- + + def interval(self, e: ir.Expr) -> Interval: + if isinstance(e, ir.Const) and e.type == Type.I64: + return (e.value, e.value) # type: ignore[return-value] + if isinstance(e, ir.Local) and e.type == Type.I64 and e.name in self.range_vars: + return self.bounds.get(e.name) + if isinstance(e, ir.Len): + return (0, I64_MAX) + if isinstance(e, (ir.BinOp, ir.UnaryOp)) and e.type == Type.I64 and self.proved(e): + return self._result_interval(e) + return TOP + + @staticmethod + def recordable(e: ir.BinOp) -> bool: + """`proven` exists for ADD/SUB/MUL only; the verifier cannot re-prove the others.""" + return e.op in (ir.BinOpKind.ADD, ir.BinOpKind.SUB, ir.BinOpKind.MUL) + + def proved(self, e: ir.BinOp | ir.UnaryOp) -> bool: + """The i64 operation provably does not deopt (no overflow, exact division).""" + if isinstance(e, ir.UnaryOp): + if e.op == ir.UnaryOpKind.POS: + return True + a = self.interval(e.operand) + return a is None or a[0] > I64_MIN + a, b = self.interval(e.left), self.interval(e.right) + if a is None or b is None: + return True # no value reaches here (yet) + if e.op == ir.BinOpKind.TRUEDIV: + return all(-EXACT_INT_FLOAT <= v <= EXACT_INT_FLOAT for v in (*a, *b)) + if e.op == ir.BinOpKind.MOD: + return True # the runtime computes -2**63 % -1 == 0 (ir.BinOp); only a raise remains + if e.op == ir.BinOpKind.FLOORDIV: + return not (a[0] == I64_MIN and b[0] <= -1 <= b[1]) + r = self._result_interval(e) + return r is None or (r[0] >= I64_MIN and r[1] <= I64_MAX) + + def _result_interval(self, e: ir.BinOp | ir.UnaryOp) -> Interval: + if isinstance(e, ir.UnaryOp): + a = self.interval(e.operand) + if a is None: + return None + return a if e.op == ir.UnaryOpKind.POS else (-a[1], -a[0]) + a, b = self.interval(e.left), self.interval(e.right) + if a is None or b is None: + return None + if e.op == ir.BinOpKind.ADD: + return (a[0] + b[0], a[1] + b[1]) + if e.op == ir.BinOpKind.SUB: + return (a[0] - b[1], a[1] - b[0]) + if e.op == ir.BinOpKind.MUL: + products = [x * y for x in a for y in b] + return (min(products), max(products)) + if e.op == ir.BinOpKind.FLOORDIV: + m = max(abs(a[0]), abs(a[1])) + return (max(-m, I64_MIN), min(m, I64_MAX)) + if e.op == ir.BinOpKind.MOD: + d = max(abs(b[0]), abs(b[1]), 1) + return (-(d - 1), d - 1) + return TOP + + +# --- helpers ------------------------------------------------------------------------------------- + +def _walk(x): + if isinstance(x, (tuple, list)): + for y in x: + yield from _walk(y) + elif dataclasses.is_dataclass(x) and not isinstance(x, type): + yield x + for f in dataclasses.fields(x): + yield from _walk(getattr(x, f.name)) + + +def _closed(fn: ir.Function, env: dict[str, ir.Function], seen: frozenset[str] = frozenset()) -> bool: + """`fn` runs no user code: no GetAttr, CallObject, Truth, CompareObj, ObjToFloat or OBJ + BinOp, and calls only closed functions.""" + for n in _walk(fn.body): + if isinstance(n, (ir.GetAttr, ir.CallObject, ir.Truth, ir.CompareObj, ir.ObjToFloat)): + return False + if isinstance(n, ir.BinOp) and n.type == Type.OBJ: + return False + if isinstance(n, ir.Call): + callee = env.get(n.function) + if callee is None or n.function in seen or not _closed(callee, env, seen | {fn.name}): + return False + return True + + +def _box(e: ir.Expr) -> ir.Expr: + return ir.Box(Type.OBJ, e) if e.type in ir.SCALARS else e + + +def _trivial(e: ir.Expr) -> bool: + """No effect, cannot raise, cannot deopt: may be evaluated in any order, or twice.""" + if isinstance(e, (ir.Const, ir.Local, ir.Len)): + return True + if isinstance(e, ir.ToFloat): + return _trivial(e.operand) + if isinstance(e, ir.UnaryOp): + return (e.op == ir.UnaryOpKind.NOT or e.type == Type.F64) and _trivial(e.operand) + if isinstance(e, ir.BinOp): + return e.type == Type.F64 and e.op in (ir.BinOpKind.ADD, ir.BinOpKind.SUB, + ir.BinOpKind.MUL) \ + and _trivial(e.left) and _trivial(e.right) + if isinstance(e, (ir.Compare, ir.And, ir.Or)): + return _trivial(e.left) and _trivial(e.right) + return False + + +def _creation_kind(node: ast.expr) -> str | None: + """How a statement's value creates a native array: "iota" `list(range(n))`, "fill" `[v] * n`, + "copy" `name[:]` (whether `name` is an array is decided by the caller), else None.""" + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "list" \ + and not node.keywords and len(node.args) == 1 and _is_range_call(node.args[0]) \ + and len(node.args[0].args) == 1: + return "iota" + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mult) \ + and isinstance(node.left, ast.List) and len(node.left.elts) == 1 \ + and not isinstance(node.left.elts[0], ast.Starred): + return "fill" + if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name) \ + and isinstance(node.slice, ast.Slice) and node.slice.lower is None \ + and node.slice.upper is None and node.slice.step is None: + return "copy" + return None + + +def _simple(node: ast.expr) -> bool: + return isinstance(node, (ast.Name, ast.Constant)) + + +def _is_range_call(node: ast.expr) -> bool: + return isinstance(node, ast.Call) and isinstance(node.func, ast.Name) \ + and node.func.id == "range" and not node.keywords and 1 <= len(node.args) <= 3 \ + and not any(isinstance(a, ast.Starred) for a in node.args) + + +def _int_literal(node: ast.expr) -> int | None: + if isinstance(node, ast.Constant) and type(node.value) is int: + return node.value + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub) \ + and isinstance(node.operand, ast.Constant) and type(node.operand.value) is int: + return -node.operand.value + return None + + +def _falls_through(body: tuple[ir.Stmt, ...]) -> bool: + if not body: + return True + last = body[-1] + if isinstance(last, ir.Return): + return False + if isinstance(last, ir.If): + return _falls_through(last.then) or _falls_through(last.orelse) + if isinstance(last, ir.While): + forever = isinstance(last.cond, ir.Const) and last.cond.value is True + return not forever or _breaks(last.body) + return True + + +def _breaks(body: tuple[ir.Stmt, ...]) -> bool: + """A `break` of this loop (not of a nested one).""" + for s in body: + if isinstance(s, ir.Break): + return True + if isinstance(s, ir.If) and (_breaks(s.then) or _breaks(s.orelse)): + return True + return False + + +__all__ = ["lower", "MARKER"] diff --git a/python-multiplatform-ksp/src/main/python/typedpython/gate.py b/python-multiplatform-ksp/src/main/python/typedpython/gate.py new file mode 100644 index 000000000..1f34e2303 --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/gate.py @@ -0,0 +1,258 @@ +"""The soundness gate (docs/design/typedpython.md §4.2). + +A type checker may fall back to `Any` when it is unsure; a compiler cannot. The gate closes that +gap for the files it is given: + + Pyrefly errors, Any-leak kinds included -> error + an expression of type Any in user code -> error (`any-flow`), unless inside cast() + the §3.1 forbidden list -> warning (`checked`) / error (`compiled`) + +Only the given files are user code. Their dependencies are type-checked by Pyrefly only as far +as their stubs or sources say; nothing inside them is reported. +""" +import ast +import shutil +import tempfile +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from typedpython import forbidden, pyrefly, rebinding +from typedpython.diagnostic import Diagnostic, Severity + +Mode = Literal["checked", "compiled"] + +Position = tuple[int, int] + + +@dataclass(frozen=True) +class _Run: + """One user file as Pyrefly sees it: the text it was given and where that text differs.""" + + original: str + text: str # the source Pyrefly reads: the original, or the probed copy of it + probes: list[rebinding.Probe] + remap: "_Remap" + + +class _Remap: + """Maps positions in probed text back to the original source. + + A probe inserts `; name` on the line of its assignment, so a column to the right of an + insertion is `len("; name")` further right than in the original; line numbers never change. + """ + + def __init__(self, probes: Sequence[rebinding.Probe]) -> None: + self._inserted: dict[int, list[tuple[int, int]]] = {} + for p in probes: + self._inserted.setdefault(p.line, []).append((p.column - 2, len(p.name) + 2)) + + def to_original(self, line: int, column: int) -> Position | None: + """The original position, or None when it lies inside an inserted probe load.""" + shift = 0 + for start, length in self._inserted.get(line, ()): + if column >= start + length: + shift += length + elif column >= start: + return None + return line, column - shift + + +def check( + paths: Sequence[Path], + mode: Mode = "checked", + search_paths: Sequence[Path] = (), +) -> list[Diagnostic]: + runs = _plan([str(Path(p).resolve()) for p in paths]) + forbidden_severity: Severity = "error" if mode == "compiled" else "warning" + diagnostics: list[Diagnostic] = [] + + # One Pyrefly run for everything. When any file has something to probe, it runs on a mirrored + # package tree holding the probed copies (and the other files unchanged); otherwise on the + # files themselves. + with tempfile.TemporaryDirectory(prefix="typedpython-probe-") as work: + probing = any(r.probes for r in runs.values()) + if probing: + copies = _mirror(Path(work), {o: r.text for o, r in runs.items()}) + roots = pyrefly.import_roots(list(runs)) + mirror = _Mirror(copies, [((Path(work) / str(i)).resolve(), r) + for i, r in enumerate(roots)]) + report = pyrefly.run( + [Path(c) for c in copies], + [*[Path(work) / str(i) for i in range(len(roots))], + *sorted({Path(c).parent for c in copies}), + *search_paths, *roots], + ) + else: + mirror = _Mirror({o: o for o in runs}, []) + report = pyrefly.run([Path(p) for p in runs], search_paths) + original_of = mirror.copies + run_of = {c: runs[o] for c, o in original_of.items()} + + reported: set[tuple[str, int, int]] = set() + for e in report.errors: + run = run_of.get(e.path) + if run is None: + diagnostics.append(Diagnostic( + mirror.original(e.path), e.line, e.column, f"pyrefly/{e.kind}", "error", e.message, + )) + continue + position = run.remap.to_original(e.line, e.column) + if position is None: + # An error inside an inserted `; name` means the probe itself is wrong. + diagnostics.append(Diagnostic( + run.original, e.line, e.column, "internal/probe-error", "error", + f"pyrefly/{e.kind} inside a probe load (`{e.message}`); this is a gate bug", + )) + continue + diagnostics.append(Diagnostic( + run.original, *position, f"pyrefly/{e.kind}", "error", e.message, + )) + reported.add((run.original, *position)) + + for copy, run in run_of.items(): + path = run.original + try: + tree = ast.parse(run.text, filename=path) + except SyntaxError: + continue # Pyrefly has already reported the parse error + + # Judged on the probed text: the probe loads are expression statements, so they are + # discarded values and never become any-flow findings. + cast_spans = _cast_argument_spans(tree) + discarded = _discarded_values(tree) + for expr in report.any_expressions: + if expr.path != copy: + continue + start = run.remap.to_original(expr.line, expr.column) + if start is None or (path, *start) in reported: + continue + if _is_signature(expr.type): + continue # a callable whose signature mentions Any; its result is judged on its own + if ((expr.line, expr.column), (expr.stop_line, expr.stop_column)) in discarded: + continue # an expression statement's value goes nowhere + if any(_inside((expr.line, expr.column), span) for span in cast_spans): + continue + diagnostics.append(Diagnostic( + path, *start, "any-flow", "error", + f"this expression has type `{expr.type}`; give it a type, or cast() it " + "where it leaves untyped code", + )) + reported.add((path, *start)) + + for finding in forbidden.scan(Path(path).read_text(), path): + diagnostics.append(Diagnostic( + path, finding.line, finding.column, finding.rule, forbidden_severity, + finding.message, + )) + + types = report.expression_types.get(copy, {}) + for f in rebinding.judge(run.probes, types): + diagnostics.append(Diagnostic(path, f.line, f.column, f.rule, forbidden_severity, + f.message)) + + return sorted(diagnostics) + + +def _plan(paths: Sequence[str]) -> dict[str, _Run]: + """Each file's text for Pyrefly: probed when it has rebinding/mixed-container candidates.""" + runs: dict[str, _Run] = {} + for path in paths: + source = Path(path).read_text() + probed, probes = source, [] + try: + tree = ast.parse(source, filename=path) + except SyntaxError: + tree = None + if tree is not None and (wanted := rebinding.candidates(rebinding.assignments(tree))): + probed, probes = rebinding.probe(source, wanted) + runs[path] = _Run(path, probed, probes, _Remap(probes)) + return runs + + +@dataclass(frozen=True) +class _Mirror: + copies: dict[str, str] # copy -> original + bases: list[tuple[Path, Path]] # (mirrored root, original root) + + def original(self, path: str) -> str: + """The original path of a file in the mirrored tree (a dependency's, for instance).""" + for base, root in self.bases: + try: + return str(root / Path(path).relative_to(base)) + except ValueError: + continue + return path + + +def _mirror(work: Path, probed: dict[str, str]) -> dict[str, str]: + """Copy each file's package tree under `work`, with the probed text in place of the file. + + The copy keeps the file's path relative to its import root, so relative imports and imports + of sibling modules inside the package resolve to the copied tree. Returns copy -> original. + """ + roots = pyrefly.import_roots(list(probed)) + copies: dict[str, str] = {} + copied: set[Path] = set() + for original, text in probed.items(): + root = pyrefly.import_roots([original])[0] + base = work / str(roots.index(root)) + relative = Path(original).relative_to(root) + if len(relative.parts) > 1: + package = root / relative.parts[0] + if package not in copied: + shutil.copytree( + package, base / relative.parts[0], dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__", ".*"), + ) + copied.add(package) + target = base / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text) + copies[str(target.resolve())] = original + return copies + + +def _cast_argument_spans(tree: ast.Module) -> list[tuple[Position, Position]]: + """Source spans of the value argument of every `cast(T, value)` / `typing.cast(T, value)`.""" + spans = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or len(node.args) != 2: + continue + func = node.func + is_cast = (isinstance(func, ast.Name) and func.id == "cast") or ( + isinstance(func, ast.Attribute) and func.attr == "cast" + and isinstance(func.value, ast.Name) and func.value.id == "typing" + ) + value = node.args[1] + if is_cast and value.end_lineno is not None and value.end_col_offset is not None: + spans.append(( + (value.lineno, value.col_offset + 1), + (value.end_lineno, value.end_col_offset + 1), + )) + return spans + + +def _discarded_values(tree: ast.Module) -> set[tuple[Position, Position]]: + """Spans of values computed and thrown away by an expression statement (`eval(s)` alone).""" + spans = set() + for node in ast.walk(tree): + if isinstance(node, ast.Expr): + value = node.value + if value.end_lineno is not None and value.end_col_offset is not None: + spans.add(( + (value.lineno, value.col_offset + 1), + (value.end_lineno, value.end_col_offset + 1), + )) + return spans + + +def _is_signature(spelled: str) -> bool: + # Pyrefly spells callables as `(x: int) -> str` and overload sets as `Overload[...]`. + return "->" in spelled or spelled.startswith("Overload[") + + +def _inside(position: Position, span: tuple[Position, Position]) -> bool: + start, stop = span + return start <= position <= stop diff --git a/python-multiplatform-ksp/src/main/python/typedpython/incremental.py b/python-multiplatform-ksp/src/main/python/typedpython/incremental.py new file mode 100644 index 000000000..fad342e05 --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/incremental.py @@ -0,0 +1,330 @@ +"""Incremental compilation (SPEC N-10, #41): a per-module build cache. + +The unit is a module. Its cache key covers the source, the *interface* hashes of the project modules +it imports, the compiler version, the target platform and the build flags. A body-only change in a +dependency leaves that dependency's interface hash unchanged, so dependents stay cached. + +The front end, the C back end and the C compiler are passed in as callables, so this file depends on +none of them (only on `ir`, the contract). + +Key encoding (v1): sha256 of the compact, key-sorted JSON of + {"v": 1, "source": sha256-hex(source bytes), + "deps": [[, ], ...] sorted, + "compiler_version": str, "platform": str, "flags": [str, ...] in the given order} +Interface hash (v1): sha256 of the compact, key-sorted JSON of + {"v": 1, "functions": [[name, [[param name, type value, "array" | "scalar"], ...], return type value], ...] + sorted by function name, "skipped": sorted skipped names} +Only signatures enter it: no bodies, locals, purity flags, line numbers or skip reasons. + +Cache layout: /entries//{meta.json, } and /index.json (the last +key components per module, used only to explain a rebuild). An entry is written to a temporary +directory inside cache_dir and renamed into place, so a crash never leaves a half entry under a key. +An entry whose metadata is unreadable or whose artefact is missing or has a different sha256 is a +miss and is rebuilt. +""" +from __future__ import annotations + +import ast +import hashlib +import json +import os +import shutil +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from typedpython import ir +from typedpython.pyrefly import import_roots + +_V = 1 + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _canon(obj) -> bytes: + return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode() + + +def interface_hash(module: ir.Module) -> str: + """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"] + for p in f.params] + functions.append([f.name, params, f.returns.value]) + return _sha(_canon({"v": _V, "functions": functions, "skipped": sorted(module.skipped)})) + + +# --- dependencies -------------------------------------------------------------------------------- + +def _abs(p: Path) -> Path: + return Path(os.path.abspath(p)) + + +def _within(p: Path, root: Path) -> bool: + try: + p.relative_to(root) + return True + except ValueError: + return False + + +def _find(base: Path, parts: list[str]) -> list[Path]: + """Files executed by importing dotted `parts` under `base`: each package's __init__ on the way, + then the module itself (module file or package __init__). Stops at the first missing part.""" + found: list[Path] = [] + cur = base + for part in parts: + pkg = cur / part / "__init__.py" + mod = cur / f"{part}.py" + if pkg.is_file(): + found.append(pkg) + cur = cur / part + elif mod.is_file(): + found.append(mod) + break + else: + break + return found + + +def dependencies(path: Path, project_root: Path) -> list[Path]: + """Project-local modules `path` imports (stdlib and third-party are ignored), in first-seen order.""" + path, project_root = _abs(Path(path)), _abs(Path(project_root)) + try: + tree = ast.parse(path.read_bytes(), filename=str(path)) + except (SyntaxError, ValueError, OSError): + return [] + roots = [_abs(r) for r in import_roots([str(path)])] + if project_root not in roots: + roots.append(project_root) + + out: list[Path] = [] + + def add(found: list[Path]) -> None: + for p in found: + p = _abs(p) + if p != path and _within(p, project_root) and p not in out: + out.append(p) + + def resolve(parts: list[str], bases: list[Path]) -> list[Path]: + best: list[Path] = [] + for b in bases: + got = _find(b, parts) + if len(got) > len(best): + best = got + return best + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + add(resolve(alias.name.split("."), roots)) + elif isinstance(node, ast.ImportFrom): + parts = node.module.split(".") if node.module else [] + if node.level: + base = path.parent + for _ in range(node.level - 1): + base = base.parent + bases = [base] + if not parts: + add([base / "__init__.py"] if (base / "__init__.py").is_file() else []) + else: + bases = roots + got = resolve(parts, bases) if parts else [] + add(got) + # `from pkg import mod`: the imported name may itself be a submodule + for alias in node.names: + if alias.name == "*": + continue + for b in bases: + if parts: + sub_base = b + for part in parts: + sub_base = sub_base / part + else: + sub_base = b + add(_find(sub_base, [alias.name]) if sub_base.is_dir() else []) + return out + + +# --- report -------------------------------------------------------------------------------------- + +@dataclass +class ModuleReport: + path: Path + name: str + status: str # "rebuilt" | "reused" + reasons: list[str] = field(default_factory=list) + artifact: Path | None = None + interface: str = "" + key: str = "" + + +@dataclass +class BuildReport: + modules: list[ModuleReport] = field(default_factory=list) + + @property + def rebuilt(self) -> list[ModuleReport]: + return [m for m in self.modules if m.status == "rebuilt"] + + @property + def reused(self) -> list[ModuleReport]: + return [m for m in self.modules if m.status == "reused"] + + +# --- cache --------------------------------------------------------------------------------------- + +class BuildCache: + def __init__(self, cache_dir: Path): + self.dir = Path(cache_dir) + self.entries = self.dir / "entries" + self.entries.mkdir(parents=True, exist_ok=True) + + # entries + + def _load(self, key: str) -> dict | None: + """The entry's metadata plus its artefact path, or None if it is absent or damaged.""" + d = self.entries / key + try: + meta = json.loads((d / "meta.json").read_text()) + art = d / meta["artifact"] + if meta["key"] != key or _sha(art.read_bytes()) != meta["artifact_sha256"]: + return None + meta["path"] = art + return meta + except (OSError, ValueError, KeyError, TypeError): + return None + + def _store(self, key: str, name: str, interface: str, c_source: str, + compile_c: Callable[[str, str, Path], Path]) -> Path: + tmp = self.dir / f"tmp-{uuid.uuid4().hex}" + tmp.mkdir() + try: + art = Path(compile_c(c_source, name, tmp)) + if art.parent != tmp: # the artefact must live in the entry + shutil.copy2(art, tmp / art.name) + art = tmp / art.name + meta = {"key": key, "name": name, "interface": interface, "artifact": art.name, + "artifact_sha256": _sha(art.read_bytes())} + (tmp / "meta.json").write_text(json.dumps(meta)) + final = self.entries / key + if final.exists(): + shutil.rmtree(final, ignore_errors=True) # a damaged entry under this key + try: + os.rename(tmp, final) + except OSError: + if self._load(key) is None: # lost a race to a valid entry? else fail + raise + shutil.rmtree(tmp, ignore_errors=True) + return final / art.name + except BaseException: + shutil.rmtree(tmp, ignore_errors=True) + raise + + # index (explains rebuilds only; never decides a hit) + + def _read_index(self) -> dict: + try: + data = json.loads((self.dir / "index.json").read_text()) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + def _write_index(self, index: dict) -> None: + tmp = self.dir / f"index-{uuid.uuid4().hex}.tmp" + tmp.write_text(json.dumps(index, sort_keys=True)) + os.replace(tmp, self.dir / "index.json") + + # build + + def build_project(self, sources: list[Path], project_root: Path, *, + lower: Callable[[Path], ir.Module], + generate: Callable[[ir.Module, Path], str], + compile_c: Callable[[str, str, Path], Path], + compiler_version: str, platform: str, flags: list[str]) -> BuildReport: + root = _abs(Path(project_root)) + paths = [_abs(Path(s)) for s in sources] + known = set(paths) + deps = {p: dependencies(p, root) for p in paths} + order = _topological(paths, deps) + rel = lambda p: p.relative_to(root).as_posix() + + index = self._read_index() + interfaces: dict[Path, str] = {} + by_path: dict[Path, ModuleReport] = {} + + for p in order: + source_hash = _sha(p.read_bytes()) + dep_hashes: dict[str, str] = {} + for d in deps[p]: + if d in interfaces: + dep_hashes[rel(d)] = interfaces[d] + else: # not compiled here, or in a cycle: its whole source is its interface + dep_hashes[rel(d)] = "src:" + _sha(d.read_bytes()) + comps = {"source": source_hash, "deps": dep_hashes, "compiler_version": compiler_version, + "platform": platform, "flags": list(flags)} + key = _sha(_canon({"v": _V, "source": source_hash, "deps": sorted(dep_hashes.items()), + "compiler_version": compiler_version, "platform": platform, + "flags": list(flags)})) + name = _module_name(p, root) + hit = self._load(key) + if hit is not None: + interfaces[p] = hit["interface"] + by_path[p] = ModuleReport(p, name, "reused", [], hit["path"], hit["interface"], key) + else: + reasons = _why(index.get(rel(p)), comps, (self.entries / key).exists()) + module = lower(p) + c_source = generate(module, p) + iface = interface_hash(module) + art = self._store(key, name, iface, c_source, compile_c) + interfaces[p] = iface + by_path[p] = ModuleReport(p, name, "rebuilt", reasons, art, iface, key) + index[rel(p)] = comps + + self._write_index(index) + return BuildReport([by_path[p] for p in paths]) + + +def _module_name(p: Path, root: Path) -> str: + parts = list(p.relative_to(root).with_suffix("").parts) + if parts[-1] == "__init__" and len(parts) > 1: + parts.pop() + return ".".join(parts) + + +def _why(prev: dict | None, now: dict, entry_dir_exists: bool) -> list[str]: + if not prev: + return ["new"] + reasons: list[str] = [] + if prev.get("source") != now["source"]: + reasons.append("source") + old_deps, new_deps = prev.get("deps", {}), now["deps"] + for d in sorted(set(old_deps) | set(new_deps)): + if old_deps.get(d) != new_deps.get(d): + reasons.append(f"dependency interface: {d}") + for k in ("compiler_version", "platform", "flags"): + if prev.get(k) != now[k]: + reasons.append(k) + return reasons or ["corrupt" if entry_dir_exists else "missing"] + + +def _topological(paths: list[Path], deps: dict[Path, list[Path]]) -> list[Path]: + """Dependencies first, input order otherwise; a cycle is broken where it is first re-entered.""" + known, order, state = set(paths), [], {} + + def visit(p: Path) -> None: + if state.get(p): + return + state[p] = 1 + for d in deps[p]: + if d in known: + visit(d) + order.append(p) + + for p in paths: + visit(p) + return order diff --git a/python-multiplatform-ksp/src/main/python/typedpython/ir.py b/python-multiplatform-ksp/src/main/python/typedpython/ir.py new file mode 100644 index 000000000..7d0b891fa --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/ir.py @@ -0,0 +1,523 @@ +"""TypedPython typed IR (design §4.3 stage 2, #41). + +The IR sits between the front end (AST + Pyrefly types → IR, `frontend.py`) and the back end +(IR → C, `cgen.py`). Both sides are written against this file; it is the contract. + +The governing rule is INTENT §1.4: **compiled code computes what CPython computes.** The IR +encodes that rule, so a back end that implements each node as specified below cannot change a +result: + +- Every node says what it does in CPython terms. Where a C operation differs from CPython (integer + overflow, floor division and modulo of negatives, true division of large ints, int/float + comparison, list indexing), the node is defined by the CPython behaviour and the back end must + implement that behaviour, not the C one. +- When the C path cannot produce CPython's result, the IR says **deopt**: the compiled call gives + up and the whole call is redone by the interpreted function. A deopt is allowed only where + redoing is unobservable: before any effect (`Function.guards`, evaluated at entry), or anywhere + inside a `pure` function. The front end guarantees that an impure function contains no node + that can deopt (`Function.may_deopt` is False for impure functions); the back end may assert it. +- Exceptions are CPython's exceptions, with CPython's messages (`ZeroDivisionError("division by + zero")`, `IndexError("list index out of range")`, `ValueError("math domain error")`, ...). + The traceback differs (there is no Python frame for compiled code); that is the one accepted + difference, as for every C extension. + +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 +reported as a diagnostic. What it proves: + +- definite assignment: no local is read before it is assigned on every path; +- types: every node's operands have the types the node requires; no implicit conversion; +- deopt placement: no node that can deopt in an impure function (`may_deopt` consistent); +- arrays: every `Index`/`StoreIndex` is either proved in bounds (and marked `proven`) or keeps its + runtime check; array params are used only by `Index`/`StoreIndex`/`Len`; +- ownership: OBJ values are created only by nodes that return a new reference (Box, Global, + GetAttr, CallObject, OBJ BinOp) and the back end releases them by one rule (every OBJ local owns its reference; assigning + releases the old one; every exit releases all); the IR has no node that frees, aliases a + borrowed reference, or reads a possibly-NULL object, so use-after-free and NULL dereference have + no IR form. + +The generated C touches memory and `PyObject`s only through the runtime helpers (`tp_runtime.h`), +which are tested separately; AddressSanitizer/UBSan runs, differential tests against the +interpreter, and fuzzing are the safety net outside the proof. + +Module shape (back end). The extension module executes the module's original source in its own +namespace first, so every name, class and global is the interpreted one and lives in one +namespace. Then each compiled function `f` replaces the global `f` with a C function, and the +original stays reachable as `__typedpython_interpreted__[f]` for deopt. Because there is one +namespace, interpreted code that calls `f` gets the compiled `f`, and a deopted call sees the same +globals and classes as compiled code. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum + + +# --- types --------------------------------------------------------------------------------------- + +class Type(Enum): + """The C representation of a value. Python semantics are attached per type below.""" + + I64 = "i64" # a Python int known to fit in [-2**63, 2**63-1]; arithmetic is checked + F64 = "f64" # a Python float (IEEE double, exactly as CPython) + BOOL = "bool" # a Python bool + NONE = "none" # the value None (only as a return type) + OBJ = "obj" # any Python object, held as a strong reference (PyObject*) + F64_ARRAY = "f64[]" # a list[float] parameter (ArrayParam) or a local array (NewArray/CopyArray) + I64_ARRAY = "i64[]" # a list[int] parameter (ArrayParam) or a local array (NewArray/CopyArray) + + +SCALARS = (Type.I64, Type.F64, Type.BOOL) +ARRAYS = (Type.F64_ARRAY, Type.I64_ARRAY) + + +# --- expressions --------------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Expr: + type: Type + + +@dataclass(frozen=True) +class Const(Expr): + """A literal. `value` is a Python int/float/bool/None matching `type`.""" + + value: int | float | bool | None + + +@dataclass(frozen=True) +class Local(Expr): + """Reading a local variable or parameter (always definitely assigned: the front end checks).""" + + name: str + + +class BinOpKind(Enum): + ADD = "+" + SUB = "-" + MUL = "*" + TRUEDIV = "/" + FLOORDIV = "//" + MOD = "%" + + +@dataclass(frozen=True) +class BinOp(Expr): + """Arithmetic with CPython's result. Both operands have the same scalar type (the front end + 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 + 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 + 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 + 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), + divisor 0.0 → ZeroDivisionError("float floor division by zero") / ("float modulo by + zero"). + OBJ: CPython's generic operation on objects (PyNumber_*), never deopts. + """ + + op: BinOpKind + left: Expr + right: Expr + # I64 ADD/SUB/MUL only: set by the front end when an interval proof shows the result cannot + # leave the i64 range; the verifier re-proves it (never trusts it) and a proven op cannot + # deopt, so it is allowed in an impure function. The back end still uses the checked helper + # and treats an overflow there as an internal error (SystemError), never as a deopt. + proven: bool = False + + +class UnaryOpKind(Enum): + NEG = "-" + POS = "+" + NOT = "not" + + +@dataclass(frozen=True) +class UnaryOp(Expr): + """NEG of I64 is checked (-(-2**63) → deopt). NOT takes a BOOL and gives a BOOL.""" + + op: UnaryOpKind + operand: Expr + proven: bool = False # NEG of I64: as BinOp.proven + + +class CompareKind(Enum): + EQ = "==" + NE = "!=" + LT = "<" + LE = "<=" + GT = ">" + GE = ">=" + + +@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 — + not by converting the int to double (2**53+1 != float(2**53)). NaN compares as in IEEE.""" + + op: CompareKind + left: Expr + right: Expr + + +@dataclass(frozen=True) +class And(Expr): + """`a and b` on BOOL operands only (result BOOL), short-circuit. The front end does not lower + `and`/`or` on non-bool operands (they return an operand, not a bool).""" + + left: Expr + right: Expr + + +@dataclass(frozen=True) +class Or(Expr): + left: Expr + right: Expr + + +@dataclass(frozen=True) +class ToFloat(Expr): + """`float(x)` of an I64 (round-to-nearest-even, as CPython's int→float), or the implicit + int→float conversion of mixed arithmetic. Type F64.""" + + operand: Expr + + +@dataclass(frozen=True) +class Box(Expr): + """A scalar or array element converted to a Python object (type OBJ): I64 → int, F64 → float, + BOOL → True/False. Used when a typed value flows into OBJ code.""" + + operand: Expr + + +@dataclass(frozen=True) +class Unbox(Expr): + """An OBJ converted to a scalar. The runtime checks the exact type (`tp_unbox_*`) and the i64 + range; anything else → deopt. Because it can deopt, it is legal only in a pure function (the + verifier enforces this); impure code converts objects with `ObjToFloat` instead.""" + + operand: Expr + + +class MathFunc(Enum): + SQRT = "sqrt" + EXP = "exp" + LOG = "log" + SIN = "sin" + COS = "cos" + TAN = "tan" + ATAN2 = "atan2" + FABS = "fabs" + HYPOT = "hypot" + + +@dataclass(frozen=True) +class MathCall(Expr): + """`math.(x...)` on F64, type F64 (ATAN2 and HYPOT take exactly two arguments; other arities + stay interpreted), with the stdlib `math` module's errors: + a domain error → ValueError("math domain error"), a range overflow → + OverflowError("math range error") (CPython's math_1/math_2 rules on errno/inf/nan).""" + + func: MathFunc + args: tuple[Expr, ...] + + +@dataclass(frozen=True) +class Call(Expr): + """A direct call of another compiled function in the same module (C to C). If the callee may + deopt, the caller must be pure (the deopt propagates and the outermost compiled call is + redone); otherwise the front end calls it as an object (`CallObject(Global(name), ...)`). + A call cycle (direct or mutual recursion) is legal: every compiled function counts its frame at + entry (runtime `tp_enter_call`), so a recursion that CPython stops with RecursionError is stopped + with the same error, and the C stack cannot overflow.""" + + 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 + # 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. + redo: bool = False + + +@dataclass(frozen=True) +class Global(Expr): + """Reading a module-level name (then builtins) as a new reference, looked up **at the time of the read** in the + module namespace, exactly as CPython's LOAD_GLOBAL: rebinding the global later is seen. + Unbound → NameError("name 'x' is not defined"). Type OBJ. This is how compiled code reaches + functions and classes imported from Kotlin packages through the binder (they are ordinary + module globals). A read alone has no effect, so it does not make a function impure.""" + + name: str + + +@dataclass(frozen=True) +class GetAttr(Expr): + """`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.""" + + obj: Expr + name: str + + +@dataclass(frozen=True) +class CallObject(Expr): + """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 + compiled code: whatever the binder accepts from interpreted code, it accepts from here, and + whatever it returns (a proxy, a str, a list) flows on as an opaque OBJ.""" + + callee: Expr + args: tuple[Expr, ...] + kwnames: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Truth(Expr): + """`bool(obj)` as a condition (PyObject_IsTrue; may raise as CPython does). Type BOOL.""" + + operand: Expr + + +@dataclass(frozen=True) +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 + 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.""" + + op: CompareKind + left: Expr + right: Expr + + +@dataclass(frozen=True) +class ObjToFloat(Expr): + """`float(obj)` (PyNumber_Float: __float__/__index__ run as in CPython), result as F64. Raises as + CPython raises; never deopts, so it is allowed in impure functions. Type F64.""" + + operand: Expr + + +@dataclass(frozen=True) +class NewArray(Expr): + """A fresh **local** array (type F64_ARRAY / I64_ARRAY), owned by the function: + `[fill] * length` (`iota=False`), or `list(range(length))` (`iota=True`, I64 only, elements + 0..length-1). A negative length gives an empty array, as in CPython. Allocation failure → + MemoryError. Only legal as the value of an `Assign` to a local of the same array type.""" + + length: Expr # I64 + fill: Expr | None = None # element-typed; None when iota + iota: bool = False + + +@dataclass(frozen=True) +class CopyArray(Expr): + """`src[:]` of a local or parameter array: a fresh local array with the same elements. + Only legal as the value of an `Assign` to a local of the same array type.""" + + src: str + + +@dataclass(frozen=True) +class Tuple(Expr): + """`(a, b, ...)` of scalars/OBJs — a new tuple (type OBJ). Scalar elements arrive through Box.""" + + elements: tuple[Expr, ...] + + +@dataclass(frozen=True) +class Len(Expr): + """`len(array)` of an array parameter. Type I64.""" + + array: str + + +@dataclass(frozen=True) +class Index(Expr): + """`array[i]` of an array parameter, with list semantics: a negative index counts from the + end; out of range → IndexError("list index out of range"). Element type F64 or I64.""" + + array: str + index: Expr + # Set only by `verify` when it has proved 0 <= index < len for the non-negative case (e.g. a + # `ForRange` index over range(len(array))); then the back end may omit the bounds check. + # Never set by the front end. Unproved accesses keep the full check. + proven: bool = False + + +# --- statements ---------------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Stmt: + pass + + +@dataclass(frozen=True) +class Assign(Stmt): + target: str + value: Expr + + +@dataclass(frozen=True) +class StoreIndex(Stmt): + """`array[i] = value` with list semantics (negative index; out of range → + IndexError("list assignment index out of range")). Marks the element dirty for write-back.""" + + array: str + index: Expr + value: Expr + proven: bool = False # as in Index + + +@dataclass(frozen=True) +class ExprStmt(Stmt): + value: Expr + + +@dataclass(frozen=True) +class If(Stmt): + cond: Expr # BOOL + then: tuple[Stmt, ...] + orelse: tuple[Stmt, ...] = () + + +@dataclass(frozen=True) +class While(Stmt): + cond: Expr # BOOL + body: tuple[Stmt, ...] + + +@dataclass(frozen=True) +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 + front end rejects reading it after the loop unless it was definitely assigned before.""" + + var: str + start: Expr + stop: Expr + step: Const + body: tuple[Stmt, ...] + + +@dataclass(frozen=True) +class Return(Stmt): + value: Expr | None = None + + +@dataclass(frozen=True) +class Break(Stmt): + pass + + +@dataclass(frozen=True) +class Continue(Stmt): + pass + + +# --- functions and modules ----------------------------------------------------------------------- + +@dataclass(frozen=True) +class Param: + name: str + type: Type + + +@dataclass(frozen=True) +class ArrayParam: + """How a `list[float]`/`list[int]` parameter becomes a native array, keeping list semantics: + + At entry (guards, before any effect): the argument is exactly a `list`, every element is exactly + `float` (F64_ARRAY) or exactly `int` within i64 (I64_ARRAY), and no two array parameters are + 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 + 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 + stores is impure and therefore can never deopt after entry (`Function.may_deopt` is False). + """ + + name: str + type: Type # F64_ARRAY or I64_ARRAY + stored: bool # whether the body stores into it (write-back needed) + + +@dataclass(frozen=True) +class Function: + """One compiled function. `params` lists every positional parameter in order (scalar Param, + ArrayParam, or Param of type OBJ for anything else). Keyword-only, *args, **kwargs, defaults + and decorators other than `compiled` are not compiled in this stage (the function stays + 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 + 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, + 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 — + 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 + 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). + """ + + name: str + params: tuple[Param | ArrayParam, ...] + returns: Type + locals: dict[str, Type] # non-parameter locals only (temporaries included) + body: tuple[Stmt, ...] + pure: bool + may_deopt: bool + 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 + # rebind the global between entry and use, and reading it once equals reading it at each use. + entry_globals: tuple[Param, ...] = () + + +@dataclass(frozen=True) +class Module: + name: str + functions: tuple[Function, ...] + # Functions the front end left interpreted, with the reason (for `demo` and the coverage table). + skipped: dict[str, str] = field(default_factory=dict) diff --git a/python-multiplatform-ksp/src/main/python/typedpython/paths.py b/python-multiplatform-ksp/src/main/python/typedpython/paths.py new file mode 100644 index 000000000..34f342238 --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/paths.py @@ -0,0 +1,21 @@ +"""Turning the paths a caller gives into the Python files to check.""" +from collections.abc import Sequence +from pathlib import Path + +SKIPPED_DIRS = {"__pycache__", "build"} + + +def python_files(paths: Sequence[Path]) -> list[Path]: + """Files as given; directories expanded to their `.py` files, skipping hidden and cache dirs.""" + found: list[Path] = [] + for path in paths: + path = Path(path) + if not path.is_dir(): + found.append(path) + continue + for file in sorted(path.rglob("*.py")): + parts = file.relative_to(path).parts[:-1] + if any(p.startswith(".") or p in SKIPPED_DIRS for p in parts): + continue + found.append(file) + return found diff --git a/python-multiplatform-ksp/src/main/python/typedpython/pipeline.py b/python-multiplatform-ksp/src/main/python/typedpython/pipeline.py new file mode 100644 index 000000000..db3e2ffe1 --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/pipeline.py @@ -0,0 +1,176 @@ +"""Source → gate → frontend → verify → cgen → cbuild (design §4.3, SPEC N-8/N-9, #41). + +`compile_module` is the one entry point the CLI, the demo and the build slot use: + +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 — + 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. +5. `cgen.generate` + `cbuild.build`, against the host's CPython or a target's headers. +""" +import hashlib +import json +import sysconfig +from dataclasses import dataclass, field +from pathlib import Path +from types import ModuleType + +from typedpython import cbuild, cgen, frontend, gate, incremental, ir, verify + + +class CompileError(RuntimeError): + """The module cannot be compiled at all (it fails the gate).""" + + +@dataclass +class Result: + source: Path + module: ir.Module # what was compiled (verified functions only) + extension: Path | None # None: nothing to compile, ship the .py as is + skipped: dict[str, str] = field(default_factory=dict) # function -> why it stays interpreted + diagnostics: list[verify.Diagnostic] = field(default_factory=list) + + +def _gate_or_raise(path: Path) -> None: + errors = [d for d in gate.check([path], mode="compiled") if d.severity == "error"] + if errors: + listing = "\n".join(f"{d.path}:{d.line}:{d.column}: {d.message} [{d.rule}]" for d in errors) + raise CompileError(f"{path.name} does not pass the gate in compiled mode:\n{listing}") + + +def compile_module( + path: Path, + out_dir: Path, + *, + python_include: Path | None = None, + ext_suffix: str | None = None, + runtime_dir: Path | None = None, +) -> Result: + path = Path(path).resolve() + _gate_or_raise(path) + + lowered = frontend.lower(path) + proved, diagnostics = verify.verify(lowered) + skipped = dict(proved.skipped) + if not proved.functions: + return Result(path, proved, None, skipped, diagnostics) + + c_source = cgen.generate(proved, path) + extension = cbuild.build( + c_source, path.stem, Path(out_dir), + runtime_dir=runtime_dir, python_include=python_include, ext_suffix=ext_suffix, + ) + return Result(path, proved, extension, skipped, diagnostics) + + +def load(result: Result) -> ModuleType: + """Import the built extension (or, with nothing compiled, the source) from its path.""" + if result.extension is None: + import importlib.util + spec = importlib.util.spec_from_file_location(result.source.stem, result.source) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + return cbuild.load(result.source.stem, result.extension) + + +# --- incremental project build (SPEC N-10) ---------------------------------------------------------- + +def _tree_hash(*dirs: Path) -> str: + h = hashlib.sha256() + for d in dirs: + for f in sorted(p for p in Path(d).rglob("*") if p.is_file() and "__pycache__" not in p.parts): + h.update(f.relative_to(d).as_posix().encode() + b"\0" + f.read_bytes() + b"\0") + return h.hexdigest() + + +_PACKAGE = Path(__file__).resolve().parent + +# What the compiler is: every file of this package (front end, verifier, C back end, runtime header, +# builtin stubs). Any change to any of them can change the C produced for an unchanged source, so it +# invalidates the whole cache. Content-based, not a release number: it needs no manual bump and is +# right for a working tree; the cost is a rebuild after any edit to the compiler, which is correct. +COMPILER_VERSION = _tree_hash(_PACKAGE) + +_NO_EXTENSION = ".noext" # cache artefact of a module with nothing compiled + + +@dataclass +class ModuleResult: + path: Path + name: str + extension: Path | None # None: nothing compiled, ship the .py as is + status: str # "rebuilt" | "reused" + reasons: list[str] = field(default_factory=list) # why it was rebuilt (empty if reused) + skipped: dict[str, str] = field(default_factory=dict) # function -> why it stays interpreted + interface: str = "" + + +@dataclass +class ProjectResult: + modules: list[ModuleResult] + + @property + def rebuilt(self) -> list[ModuleResult]: + return [m for m in self.modules if m.status == "rebuilt"] + + +def compile_project( + sources: list[Path], + project_root: Path, + cache_dir: Path, + *, + python_include: Path | None = None, + ext_suffix: str | None = None, + runtime_dir: Path | None = None, +) -> ProjectResult: + sources = [Path(s).resolve() for s in sources] + cache_dir = Path(cache_dir) + verified: dict[Path, ir.Module] = {} + + def lower(path: Path) -> ir.Module: + _gate_or_raise(path) + proved, _ = verify.verify(frontend.lower(path)) + verified[path] = proved # the VERIFIED module: its signatures are what gets compiled + return proved + + def generate(module: ir.Module, path: Path) -> str: + return cgen.generate(module, path) if module.functions else "" + + def compile_c(c_source: str, name: str, out_dir: Path) -> Path: + if not c_source: + marker = Path(out_dir) / (name.rpartition(".")[2] + _NO_EXTENSION) + marker.write_text("nothing compiled\n") + return marker + return cbuild.build(c_source, name, out_dir, runtime_dir=runtime_dir, flags=cbuild.DEFAULT_FLAGS, + python_include=python_include, ext_suffix=ext_suffix) + + version = COMPILER_VERSION + if runtime_dir is not None: + version += ":" + _tree_hash(Path(runtime_dir)) + suffix = ext_suffix if ext_suffix is not None else sysconfig.get_config_var("EXT_SUFFIX") + include = cbuild._python_include(python_include) + flags = [*cbuild.DEFAULT_FLAGS, f"-I{include}"] + + cache = incremental.BuildCache(cache_dir) + report = cache.build_project( + sources, project_root, lower=lower, generate=generate, compile_c=compile_c, + compiler_version=version, platform=f"{sysconfig.get_platform()} {suffix}", flags=flags) + + side = cache_dir / "skipped" + side.mkdir(exist_ok=True) + out: list[ModuleResult] = [] + for m in report.modules: + record = side / f"{m.key}.json" + if m.status == "rebuilt": + record.write_text(json.dumps(dict(verified[m.path].skipped))) + try: + skipped = json.loads(record.read_text()) + except (OSError, ValueError): + skipped = {} + ext = None if m.artifact is None or m.artifact.suffix == _NO_EXTENSION else m.artifact + out.append(ModuleResult(m.path, m.name, ext, m.status, list(m.reasons), skipped, m.interface)) + return ProjectResult(out) diff --git a/python-multiplatform-ksp/src/main/python/typedpython/pyrefly.py b/python-multiplatform-ksp/src/main/python/typedpython/pyrefly.py new file mode 100644 index 000000000..016c7b6bb --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/pyrefly.py @@ -0,0 +1,181 @@ +"""Running Pyrefly and reading what it reports (docs/design/typedpython.md §4.1). + +Pyrefly is used as a CLI, not embedded. Two outputs are read: + +- its JSON error list, with the Any-leak error kinds raised to `error` by a generated config; +- its per-expression type report (`--report-pysa`), which says which expressions in user code + have type `Any`. That report is a Pyrefly-versioned format, which is why pyproject.toml pins + Pyrefly exactly. +""" +import json +import os +import re +import subprocess +import sys +import tempfile +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +# Pyrefly error kinds that mean "an Any got in". Pyrefly leaves several of them off by default. +ANY_LEAK_KINDS = ( + "implicit-any-parameter", + "unannotated-return", + "unknown-variable-type", + "no-any-return-implicit", + "no-any-return-explicit", + "explicit-any", +) + +# How Pyrefly spells an Any in the type report: `typing.Any` for an explicit one and `Unknown` +# for an implicit one, alone or inside a larger type such as `list[typing.Any]`. +ANY_IN_TYPE = re.compile(r"(? (line, column) -> type, for every expression the type report records. + expression_types: dict[str, dict[tuple[int, int], str]] + + +# Directory holding `__builtins__.pyi`: names the interpreter provides without an import (`compiled`). +# Pyrefly 1.3.2 reads that file from any search-path entry as extra builtins. +BUILTIN_STUBS = Path(__file__).parent / "builtin_stubs" + + +def command() -> list[str]: + """The Pyrefly command line: `TYPEDPYTHON_PYREFLY`, or the copy installed next to this Python.""" + override = os.environ.get("TYPEDPYTHON_PYREFLY") + return [override] if override else [sys.executable, "-m", "pyrefly"] + + +def run(paths: Sequence[Path], search_paths: Sequence[Path] = ()) -> Report: + files = [str(Path(p).resolve()) for p in paths] + with tempfile.TemporaryDirectory(prefix="typedpython-") as work: + config = Path(work) / "pyrefly.toml" + config.write_text(_config([*search_paths, *import_roots(files), BUILTIN_STUBS])) + report_dir = Path(work) / "pysa" + argv = [ + *command(), "check", *files, + "--config", str(config), + "--output-format", "json", + "--summary=none", + "--report-pysa", str(report_dir), + "--report-pysa-format", "json", + ] + try: + result = subprocess.run(argv, capture_output=True, text=True, check=False) + except OSError as e: + raise PyreflyError(f"cannot run {argv[0]}: {e}") from e + + # Exit code 1 only means "type errors found"; anything else is a failure of the tool. + if result.returncode not in (0, 1): + raise PyreflyError(f"pyrefly exited {result.returncode}: {result.stderr.strip()}") + try: + raw_errors = json.loads(result.stdout)["errors"] + except (json.JSONDecodeError, KeyError) as e: + raise PyreflyError(f"unreadable pyrefly output: {result.stdout[:200]!r}") from e + + errors = [ + Error(_resolve(e["path"]), e["line"], e["column"], e["name"], e["description"]) + for e in raw_errors + ] + types = _expression_types(report_dir, set(files)) + return Report(errors, _any_expressions(types), _types_by_start(types)) + + +def import_roots(files: Sequence[str]) -> list[Path]: + """The directory each file is imported from: above its outermost package, else its own. + + The config lives in a temporary directory, and Pyrefly would otherwise take that as the import + root, so a package's own imports (relative or absolute) would not resolve. + """ + roots: list[Path] = [] + for file in files: + root = Path(file).parent + while (root / "__init__.py").exists(): + root = root.parent + if root not in roots: + roots.append(root) + return roots + + +def _config(search_paths: Sequence[Path]) -> str: + lines = [] + if search_paths: + quoted = ", ".join(json.dumps(str(Path(p).resolve())) for p in search_paths) + lines.append(f"search-path = [{quoted}]") + lines.append("[errors]") + lines += [f'{kind} = "error"' for kind in ANY_LEAK_KINDS] + return "\n".join(lines) + "\n" + + +def _expression_types(report_dir: Path, files: set[str]) -> list[AnyExpression]: + """Every typed expression the report records in the given files (typeshed and deps skipped).""" + try: + index = json.loads((report_dir / "pyrefly.pysa.json").read_text()) + except (OSError, json.JSONDecodeError) as e: + raise PyreflyError(f"pyrefly wrote no readable type report: {e}") from e + + found: list[AnyExpression] = [] + for module in index["modules"].values(): + source = module.get("source_path", {}).get("FileSystem") + if source is None or _resolve(source) not in files: + continue # typeshed and dependencies: not user code + table_file = report_dir / "type_of_expressions" / module["info_filename"] + if not table_file.exists(): + continue + table = json.loads(table_file.read_text()) + for function in table["functions"].values(): + types = function["type_table"] + for location, type_index in function["locations"].items(): + found.append(_expression(_resolve(source), location, types[type_index]["string"])) + return found + + +def _any_expressions(expressions: list[AnyExpression]) -> list[AnyExpression]: + return [e for e in expressions if ANY_IN_TYPE.search(e.type)] + + +def _types_by_start(expressions: list[AnyExpression]) -> dict[str, dict[tuple[int, int], str]]: + table: dict[str, dict[tuple[int, int], str]] = {} + for e in expressions: + table.setdefault(e.path, {})[(e.line, e.column)] = e.type + return table + + +def _expression(path: str, location: str, spelled: str) -> AnyExpression: + # "6:9-6:15" -> start line/column, stop line/column (1-based, as in Pyrefly's errors). + start, stop = location.split("-") + line, column = map(int, start.split(":")) + stop_line, stop_column = map(int, stop.split(":")) + return AnyExpression(path, line, column, stop_line, stop_column, spelled) + + +def _resolve(path: str) -> str: + return str(Path(path).resolve()) diff --git a/python-multiplatform-ksp/src/main/python/typedpython/rebinding.py b/python-multiplatform-ksp/src/main/python/typedpython/rebinding.py new file mode 100644 index 000000000..8f26cef8e --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/rebinding.py @@ -0,0 +1,258 @@ +"""Rebinding a name to a different type, and mixed-element containers (design §3.1). + +Pyrefly 1.3.2 reports neither, and its per-expression type report has no entries for assignment +targets. It does record every *load* of a name, with the name's type at that point. So the gate +writes a probed copy of the file in which each assignment of interest is followed, on the same +line, by a bare load of each target (`x = 1` becomes `x = 1; x`), runs Pyrefly on the copy, and +reads the target types back at the probe positions. Line numbers do not change, and every column +before the insertion point stays where it was. +""" +import ast +import re +from collections.abc import Iterable +from dataclasses import dataclass, field + +# Container types whose element type should be one type, not a union. Tuples are excluded: a +# fixed-length tuple is typed per slot, so `tuple[int, str]` is not "mixed". +MIXABLE = re.compile(r"^(list|set|frozenset|dict)\[(.*)\]$", re.DOTALL) +LITERAL = re.compile(r"^Literal\[(.*)\]$", re.DOTALL) + + +@dataclass(frozen=True) +class Assignment: + """One assignment to a plain name, in the original source.""" + + scope: int + name: str + line: int + column: int + end_line: int + end_byte: int # ast end_col_offset of the whole statement (UTF-8 bytes) + augmented: bool + display: bool # the value is a list/set/dict display with more than one element + + +@dataclass(frozen=True) +class Probe: + name: str + line: int + column: int + assignment: Assignment | None = field(default=None, compare=False) + + +@dataclass(frozen=True) +class Finding: + rule: str + line: int + column: int + message: str + + +def assignments(tree: ast.Module) -> list[Assignment]: + """Assignments to unannotated names, scope by scope (module, class bodies, functions).""" + found: list[Assignment] = [] + for scope_id, scope in enumerate(_scopes(tree)): + declared = _declared_names(scope) + for stmt in _own_statements(scope): + found += [a for a in _assignments_in(stmt, scope_id) if a.name not in declared] + return found + + +def candidates(found: Iterable[Assignment]) -> list[Assignment]: + """The assignments worth probing: names assigned more than once, and container displays.""" + found = list(found) + counts: dict[tuple[int, str], int] = {} + for a in found: + counts[(a.scope, a.name)] = counts.get((a.scope, a.name), 0) + 1 + return [a for a in found if counts[(a.scope, a.name)] > 1 or a.display] + + +def probe(source: str, wanted: Iterable[Assignment] | None = None) -> tuple[str, list[Probe]]: + """Insert a load of each target after its statement; `wanted=None` probes every assignment.""" + if wanted is None: + wanted = assignments(ast.parse(source)) + lines = source.splitlines(keepends=True) + # Insert right-to-left within a line so earlier offsets stay valid. + by_position: dict[tuple[int, int], list[Assignment]] = {} + for a in wanted: + by_position.setdefault((a.end_line, a.end_byte), []).append(a) + + probes: list[Probe] = [] + for (line_no, end_byte) in sorted(by_position, reverse=True): + encoded = lines[line_no - 1].encode() + head, tail = encoded[:end_byte].decode(), encoded[end_byte:].decode() + inserted = "" + added: list[Probe] = [] + for a in by_position[(line_no, end_byte)]: + inserted += "; " + added.append(Probe(a.name, line_no, len(head) + len(inserted) + 1, a)) + inserted += a.name + lines[line_no - 1] = head + inserted + tail + # Probes already placed further right on this line moved by what was just inserted. + probes = [ + Probe(q.name, q.line, q.column + len(inserted), q.assignment) + if q.line == line_no and q.column > len(head) else q + for q in probes + ] + probes += added + + return "".join(lines), probes + + +def judge(probes: list[Probe], types: dict[tuple[int, int], str]) -> list[Finding]: + """Turn probed types into findings.""" + findings: list[Finding] = [] + history: dict[tuple[int, str], tuple[str, Assignment]] = {} + + for p in sorted(probes, key=lambda p: (p.line, p.column)): + a = p.assignment + spelled = types.get((p.line, p.column)) + if a is None or spelled is None or _is_unknown(spelled): + continue + + if a.display and (mixed := _mixed_element(spelled)): + findings.append(Finding( + "forbidden/mixed-container", a.line, a.column, + f"`{a.name}` is `{spelled}`: its elements mix `{mixed}`; annotate the union " + "on purpose or keep one element type", + )) + + key = (a.scope, a.name) + current = _base(spelled) + if key in history and history[key][0] != current: + before = history[key][0] + findings.append(Finding( + "forbidden/rebinding", a.line, a.column, + f"`{a.name}` was `{before}` and is rebound to `{current}`; annotate it " + f"(`{a.name}: {before} | {current}`) or use another name", + )) + history[key] = (current, a) + + return findings + + +# --- AST helpers ------------------------------------------------------------------------------- + +Scope = ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef + + +def _scopes(tree: ast.Module) -> list[Scope]: + return [tree] + [ + n for n in ast.walk(tree) + if isinstance(n, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) + ] + + +def _own_statements(scope: Scope) -> Iterable[ast.stmt]: + """Statements of this scope, descending into blocks but not into nested scopes.""" + stack = list(reversed(scope.body)) + while stack: + stmt = stack.pop() + yield stmt + if isinstance(stmt, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for name in ("body", "orelse", "finalbody"): + stack.extend(reversed(getattr(stmt, name, []))) + for handler in getattr(stmt, "handlers", []): + stack.extend(reversed(handler.body)) + for case in getattr(stmt, "cases", []): + stack.extend(reversed(case.body)) + + +def _declared_names(scope: Scope) -> set[str]: + """Names whose type is declared (annotated, parameters) or owned elsewhere (global).""" + names: set[str] = set() + if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)): + args = scope.args + for arg in [*args.posonlyargs, *args.args, *args.kwonlyargs]: + names.add(arg.arg) + names |= {a.arg for a in (args.vararg, args.kwarg) if a is not None} + for stmt in _own_statements(scope): + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + names.add(stmt.target.id) + elif isinstance(stmt, (ast.Global, ast.Nonlocal)): + names |= set(stmt.names) + return names + + +def _assignments_in(stmt: ast.stmt, scope_id: int) -> list[Assignment]: + if isinstance(stmt, ast.Assign): + targets = [t for target in stmt.targets for t in _names(target)] + display = len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name) \ + and _is_display(stmt.value) + augmented = False + elif isinstance(stmt, ast.AugAssign) and isinstance(stmt.target, ast.Name): + targets, display, augmented = [stmt.target], False, True + else: + return [] + assert stmt.end_lineno is not None and stmt.end_col_offset is not None + return [ + Assignment(scope_id, t.id, t.lineno, t.col_offset + 1, + stmt.end_lineno, stmt.end_col_offset, augmented, display) + for t in targets + ] + + +def _names(target: ast.expr) -> list[ast.Name]: + if isinstance(target, ast.Name): + return [target] + if isinstance(target, (ast.Tuple, ast.List)): + return [n for element in target.elts for n in _names(element)] + if isinstance(target, ast.Starred): + return _names(target.value) + return [] + + +def _is_display(value: ast.expr) -> bool: + if isinstance(value, (ast.List, ast.Set)): + return len(value.elts) > 1 + if isinstance(value, ast.Dict): + return len(value.keys) > 1 + return False + + +# --- type spelling ----------------------------------------------------------------------------- + +def _is_unknown(spelled: str) -> bool: + return spelled in ("Unknown", "typing.Any", "Any") + + +def _base(spelled: str) -> str: + """`Literal[1]` -> `int`, `Literal['a']` -> `str`, `Literal[True]` -> `bool`.""" + m = LITERAL.match(spelled) + if not m: + return spelled + value = m.group(1).strip() + if value in ("True", "False"): + return "bool" + if value[:1] in ("'", '"'): + return "str" + if value[:1] in ("b",) and value[1:2] in ("'", '"'): + return "bytes" + return "int" + + +def _mixed_element(spelled: str) -> str | None: + m = MIXABLE.match(spelled) + if not m: + return None + for argument in _split_top_level(m.group(2)): + if len(_split_top_level(argument, "|")) > 1: + return argument.strip() + return None + + +def _split_top_level(text: str, separator: str = ",") -> list[str]: + parts, depth, current = [], 0, "" + for ch in text: + if ch in "[(": + depth += 1 + elif ch in "])": + depth -= 1 + if ch == separator and depth == 0: + parts.append(current) + current = "" + else: + current += ch + parts.append(current) + return parts diff --git a/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md new file mode 100644 index 000000000..77bd67469 --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md @@ -0,0 +1,99 @@ +# 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). + +## Integers (int64_t) + int tp_add_i64(int64_t a, int64_t b, int64_t *out); /* 0 / 1 on overflow */ + int tp_sub_i64(int64_t a, int64_t b, int64_t *out); + int tp_mul_i64(int64_t a, int64_t b, int64_t *out); + int tp_neg_i64(int64_t a, int64_t *out); /* 1 for INT64_MIN */ + int tp_floordiv_i64(int64_t a, int64_t b, int64_t *out); /* -1 ZeroDivisionError; 1 for MIN // -1 */ + int tp_mod_i64(int64_t a, int64_t b, int64_t *out); /* -1 ZeroDivisionError; sign of b */ + int tp_truediv_i64(int64_t a, int64_t b, double *out); /* -1 ZeroDivisionError; 1 if |a| or |b| > 2**53 */ + double tp_i64_to_f64(int64_t a); /* as float(int): round half to even */ + +## Floats (double) + int tp_truediv_f64(double a, double b, double *out); /* 0 / -1 ZeroDivisionError("float division by zero") */ + int tp_floordiv_f64(double a, double b, double *out); /* CPython float_floor_div semantics */ + int tp_mod_f64(double a, double b, double *out); /* CPython float_rem semantics */ + +## Comparisons + int tp_cmp_i64_f64(int64_t a, double b, int op); /* exact, op is Py_LT..Py_GE; returns 0/1 */ + int tp_cmp_f64_i64(double a, int64_t b, int op); + +## math (stdlib math module semantics: math_1 / math_2 errno and inf/nan rules) + int tp_math_sqrt(double x, double *out); /* -1 ValueError("math domain error") */ + int tp_math_exp(double x, double *out); /* -1 OverflowError("math range error") */ + int tp_math_log(double x, double *out); + int tp_math_sin(double x, double *out); + int tp_math_cos(double x, double *out); + int tp_math_tan(double x, double *out); + int tp_math_fabs(double x, double *out); + int tp_math_atan2(double y, double x, double *out); + int tp_math_hypot(double x, double y, double *out); + +## Boxing (new references; NULL with MemoryError set on failure) + PyObject *tp_box_i64(int64_t v); + PyObject *tp_box_f64(double v); + PyObject *tp_box_bool(int v); + /* exact-type unboxing for guards: 0 ok, 1 wrong exact type or out of i64 range (deopt) */ + int tp_unbox_i64(PyObject *o, int64_t *out); /* type(o) is int and fits */ + int tp_unbox_f64(PyObject *o, double *out); /* type(o) is float */ + int tp_unbox_bool(PyObject *o, int *out); /* o is Py_True / Py_False */ + +## Native arrays for list[float] / list[int] parameters (ir.ArrayParam) + typedef struct { PyObject *list; Py_ssize_t len; double *data; unsigned char *dirty; } tp_f64_array; + typedef struct { PyObject *list; Py_ssize_t len; int64_t *data; unsigned char *dirty; } tp_i64_array; + + int tp_f64_array_enter(PyObject *obj, tp_f64_array *a); /* 1 unless exactly list of exactly float; -1 MemoryError */ + int tp_i64_array_enter(PyObject *obj, tp_i64_array *a); /* 1 unless exactly list of exactly int in i64 range */ + /* resolve a Python index (negative counts from the end) to a slot; -1 IndexError with + "list index out of range" (load) or "list assignment index out of range" (store) */ + int tp_f64_array_slot(const tp_f64_array *a, int64_t i, int store, Py_ssize_t *slot); + int tp_i64_array_slot(const tp_i64_array *a, int64_t i, int store, Py_ssize_t *slot); + /* write dirty elements back as new float/int objects and free the buffers. Always frees, even + when it fails (then -1 with MemoryError). Safe to call on a zeroed struct. */ + int tp_f64_array_exit(tp_f64_array *a); + int tp_i64_array_exit(tp_i64_array *a); + /* aliasing guard: 1 if any two of the n objects are the same object */ + int tp_any_same(PyObject *const *objs, Py_ssize_t n); + +## Opaque objects (Kotlin interop and any Python object; ir.Global/GetAttr/CallObject/Truth/CompareObj/ObjToFloat) +All return new references or NULL with CPython's exception set; none deopts. + PyObject *tp_global(PyObject *module_dict, PyObject *name); /* globals, then builtins; NameError("name 'x' is not defined") */ + PyObject *tp_getattr(PyObject *obj, PyObject *name); /* PyObject_GetAttr */ + PyObject *tp_call(PyObject *callee, PyObject *const *args, size_t nargs, PyObject *kwnames); /* PyObject_Vectorcall: nargs = positional count; keyword values follow at args[nargs..] */ + /* tp_binop_obj op codes: TP_BINOP_ADD..TP_BINOP_MOD = 0..5 in ir.BinOpKind declaration order */ + int tp_truth(PyObject *obj); /* 0/1, -1 error (PyObject_IsTrue) */ + int tp_compare_bool(PyObject *a, PyObject *b, int op); /* 0/1, -1 error: PyObject_RichCompare then PyObject_IsTrue (no identity shortcut, as `if a == b`) */ + int tp_obj_to_f64(PyObject *obj, double *out); /* 0 / -1 (PyNumber_Float, then exact double) */ + PyObject *tp_binop_obj(PyObject *a, PyObject *b, int op); /* PyNumber_Add/... for ir.BinOpKind on OBJ */ + void tp_release(PyObject **slot); /* Py_CLEAR: the only way generated code drops a reference */ + +## Local arrays (ir.NewArray / CopyArray; owned by the function, no list object) +Same structs as above with `list == NULL` and `dirty == NULL`. 0 ok, -1 MemoryError. + int tp_i64_array_new(tp_i64_array *a, int64_t n, int64_t fill); /* n < 0 → empty */ + int tp_i64_array_iota(tp_i64_array *a, int64_t n); /* 0..n-1; n < 0 → empty */ + int tp_f64_array_new(tp_f64_array *a, int64_t n, double fill); + int tp_i64_array_copy(tp_i64_array *dst, const tp_i64_array *src); + int tp_f64_array_copy(tp_f64_array *dst, const tp_f64_array *src); + void tp_i64_array_free(tp_i64_array *a); /* no write-back; zeroes; idempotent */ + void tp_f64_array_free(tp_f64_array *a); + /* tp_*_array_slot works unchanged on local arrays (same IndexError messages as list). */ + PyObject *tp_tuple(PyObject *const *items, Py_ssize_t n); /* new tuple; steals nothing */ + +## Call depth (every compiled impl function: enter at entry, leave at every exit; issue #57) + int tp_enter_call(void); /* 0 ok; -1 RecursionError set, nothing counted (the caller must NOT call leave) */ + void tp_leave_call(void); /* undoes one successful tp_enter_call; exactly once per success, on ok, error and deopt exits alike */ +`tp_enter_call` makes compiled recursion fail where interpreted recursion fails. It counts, per thread, +the compiled frames in flight (they have no Python frame), adds the current Python frame depth +(`PyThreadState_GetFrame` / `PyFrame_GetBack`, public API only; cached while the top frame is the same +object, so a pure compiled recursion pays O(1) per call) and compares the total with +`Py_GetRecursionLimit()`. Over it: `RecursionError("maximum recursion depth exceeded")`, the text +CPython's own Python-frame check uses. It then calls `Py_EnterRecursiveCall(" in compiled code")`, the +C-stack guard: a raised recursion limit can never let compiled C overflow the stack. Counters are +per module (the header is `static`): frames of a *different* compiled module that sit between two +frames of this one are not counted. diff --git a/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h b/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h new file mode 100644 index 000000000..2f64563d8 --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/runtime/tp_runtime.h @@ -0,0 +1,1079 @@ +/* tp_runtime.h -- the C runtime API generated TypedPython code may call (SPEC N-8, API.md). + * + * Header-only, `static inline`, C99 + the CPython C API (3.12+; exception messages follow CPython + * 3.13 and 3.14, which differ -- see TP_MSG_* below). Every helper implements the CPython + * behaviour named in ir.py; the differential tests in src/test/python/test_runtime.py compare each + * helper with CPython itself, bit for bit and message for message. + * + * Return convention unless stated: 0 ok, 1 deopt (no Python error set, nothing to undo), + * -1 Python error set. + */ +#ifndef TP_RUNTIME_H +#define TP_RUNTIME_H + +#include +#include +#include +#include +#include +#include + +#if PY_VERSION_HEX < 0x030C0000 +#error "tp_runtime.h needs CPython 3.12 or newer (PyErr_GetRaisedException)" +#endif + +/* ------------------------------------------------------------------------------------------ + * Exception messages that CPython changed between 3.13 and 3.14. + * 3.13: "integer division or modulo by zero" (int //), "integer modulo by zero" (int %), + * "float division by zero" / "float floor division by zero" / "float modulo by zero", + * "math domain error" for every math-domain ValueError. + * 3.14: "division by zero" for all of them; math_1 domain errors name the argument + * ("expected a nonnegative input, got -1.0", "expected a finite input, got inf", + * "expected a positive input, got 0.0"). + * ------------------------------------------------------------------------------------------ */ +#if PY_VERSION_HEX >= 0x030E0000 +#define TP_MSG_INT_FLOORDIV_ZERO "division by zero" +#define TP_MSG_INT_MOD_ZERO "division by zero" +#define TP_MSG_FLOAT_DIV_ZERO "division by zero" +#define TP_MSG_FLOAT_FLOORDIV_ZERO "division by zero" +#define TP_MSG_FLOAT_MOD_ZERO "division by zero" +#else +#define TP_MSG_INT_FLOORDIV_ZERO "integer division or modulo by zero" +#define TP_MSG_INT_MOD_ZERO "integer modulo by zero" +#define TP_MSG_FLOAT_DIV_ZERO "float division by zero" +#define TP_MSG_FLOAT_FLOORDIV_ZERO "float floor division by zero" +#define TP_MSG_FLOAT_MOD_ZERO "float modulo by zero" +#endif +#define TP_MSG_INT_TRUEDIV_ZERO "division by zero" /* long_true_divide: same in 3.13 and 3.14 */ + +#define TP_MATH_MSG_NONNEG "expected a nonnegative input, got %s" +#define TP_MATH_MSG_FINITE "expected a finite input, got %s" +#define TP_MATH_MSG_POSITIVE "expected a positive input, got %s" + +/* ir.BinOpKind order, for tp_binop_obj */ +#define TP_BINOP_ADD 0 +#define TP_BINOP_SUB 1 +#define TP_BINOP_MUL 2 +#define TP_BINOP_TRUEDIV 3 +#define TP_BINOP_FLOORDIV 4 +#define TP_BINOP_MOD 5 + +/* ------------------------------------------------------------------------------------------ + * Integers (int64_t) + * ------------------------------------------------------------------------------------------ */ +#if (defined(__GNUC__) || defined(__clang__)) && !defined(TP_RUNTIME_FORCE_PORTABLE_OVERFLOW) +#define TP_RUNTIME_PORTABLE_OVERFLOW 0 +#else +#define TP_RUNTIME_PORTABLE_OVERFLOW 1 +#endif + +static inline int tp_add_i64(int64_t a, int64_t b, int64_t *out) +{ +#if !TP_RUNTIME_PORTABLE_OVERFLOW + return __builtin_add_overflow(a, b, out) ? 1 : 0; +#else + if ((b > 0 && a > INT64_MAX - b) || (b < 0 && a < INT64_MIN - b)) { + return 1; + } + *out = a + b; + return 0; +#endif +} + +static inline int tp_sub_i64(int64_t a, int64_t b, int64_t *out) +{ +#if !TP_RUNTIME_PORTABLE_OVERFLOW + return __builtin_sub_overflow(a, b, out) ? 1 : 0; +#else + if ((b < 0 && a > INT64_MAX + b) || (b > 0 && a < INT64_MIN + b)) { + return 1; + } + *out = a - b; + return 0; +#endif +} + +static inline int tp_mul_i64(int64_t a, int64_t b, int64_t *out) +{ +#if !TP_RUNTIME_PORTABLE_OVERFLOW + return __builtin_mul_overflow(a, b, out) ? 1 : 0; +#else + if (a == 0 || b == 0) { + *out = 0; + return 0; + } + if (a == -1) { + if (b == INT64_MIN) return 1; + *out = -b; + return 0; + } + if (b == -1) { + if (a == INT64_MIN) return 1; + *out = -a; + return 0; + } + if (a > 0) { + if (b > 0 ? a > INT64_MAX / b : b < INT64_MIN / a) return 1; + } + else { + if (b > 0 ? a < INT64_MIN / b : a < INT64_MAX / b) return 1; + } + *out = a * b; + return 0; +#endif +} + +static inline int tp_neg_i64(int64_t a, int64_t *out) +{ + if (a == INT64_MIN) { + return 1; + } + *out = -a; + return 0; +} + +/* Python floor division: the quotient rounds toward negative infinity. */ +static inline int tp_floordiv_i64(int64_t a, int64_t b, int64_t *out) +{ + if (b == 0) { + PyErr_SetString(PyExc_ZeroDivisionError, TP_MSG_INT_FLOORDIV_ZERO); + return -1; + } + if (b == -1) { + if (a == INT64_MIN) { + return 1; /* 2**63 does not fit */ + } + *out = -a; + return 0; + } + int64_t q = a / b; + int64_t r = a % b; + if (r != 0 && ((r < 0) != (b < 0))) { + q -= 1; + } + *out = q; + return 0; +} + +/* Python modulo: the result has the sign of the divisor. */ +static inline int tp_mod_i64(int64_t a, int64_t b, int64_t *out) +{ + if (b == 0) { + PyErr_SetString(PyExc_ZeroDivisionError, TP_MSG_INT_MOD_ZERO); + return -1; + } + if (b == -1) { + *out = 0; /* also INT64_MIN % -1, which traps in C */ + return 0; + } + int64_t r = a % b; + if (r != 0 && ((r < 0) != (b < 0))) { + r += b; + } + *out = r; + return 0; +} + +/* CPython rounds int/int correctly; the C double division is the correctly rounded quotient when + * both operands are exactly representable, i.e. |x| <= 2**53. Otherwise: deopt. A zero divisor is + * an error whatever the dividend. */ +static inline int tp_truediv_i64(int64_t a, int64_t b, double *out) +{ + const int64_t lim = (int64_t)1 << 53; + if (b == 0) { + PyErr_SetString(PyExc_ZeroDivisionError, TP_MSG_INT_TRUEDIV_ZERO); + return -1; + } + if (a > lim || a < -lim || b > lim || b < -lim) { + return 1; + } + *out = (double)a / (double)b; + return 0; +} + +/* float(int): the C conversion rounds to nearest, ties to even in the default rounding mode. */ +static inline double tp_i64_to_f64(int64_t a) +{ + return (double)a; +} + +/* ------------------------------------------------------------------------------------------ + * Floats (double) + * ------------------------------------------------------------------------------------------ */ +static inline int tp_truediv_f64(double a, double b, double *out) +{ + if (b == 0.0) { + PyErr_SetString(PyExc_ZeroDivisionError, TP_MSG_FLOAT_DIV_ZERO); + return -1; + } + *out = a / b; + return 0; +} + +/* floatobject.c: _float_div_mod */ +static inline void tp__float_div_mod(double vx, double wx, double *floordiv, double *mod) +{ + double div; + *mod = fmod(vx, wx); + div = (vx - *mod) / wx; + if (*mod) { + if ((wx < 0) != (*mod < 0)) { + *mod += wx; + div -= 1.0; + } + } + else { + *mod = copysign(0.0, wx); + } + if (div) { + *floordiv = floor(div); + if (div - *floordiv > 0.5) { + *floordiv += 1.0; + } + } + else { + *floordiv = copysign(0.0, vx / wx); + } +} + +static inline int tp_floordiv_f64(double a, double b, double *out) +{ + double mod, floordiv; + if (b == 0.0) { + PyErr_SetString(PyExc_ZeroDivisionError, TP_MSG_FLOAT_FLOORDIV_ZERO); + return -1; + } + tp__float_div_mod(a, b, &floordiv, &mod); + *out = floordiv; + return 0; +} + +/* floatobject.c: float_rem */ +static inline int tp_mod_f64(double a, double b, double *out) +{ + double mod; + if (b == 0.0) { + PyErr_SetString(PyExc_ZeroDivisionError, TP_MSG_FLOAT_MOD_ZERO); + return -1; + } + mod = fmod(a, b); + if (mod) { + if ((b < 0) != (mod < 0)) { + mod += b; + } + } + else { + mod = copysign(0.0, b); + } + *out = mod; + return 0; +} + +/* ------------------------------------------------------------------------------------------ + * Comparisons: exact int/float comparison (float_richcompare), never via (double)int. + * ------------------------------------------------------------------------------------------ */ +static inline int tp__apply_cmp(int c, int op) +{ + switch (op) { + case Py_LT: return c < 0; + case Py_LE: return c <= 0; + case Py_EQ: return c == 0; + case Py_NE: return c != 0; + case Py_GT: return c > 0; + case Py_GE: return c >= 0; + } + return 0; +} + +/* three-way compare of an int64 with a non-NaN double: -1, 0, 1 */ +static inline int tp__cmp3_i64_f64(int64_t a, double b) +{ + if (b >= 9223372036854775808.0) return -1; /* b >= 2**63 > any int64 (also +inf) */ + if (b < -9223372036854775808.0) return 1; /* b < -2**63 <= any int64 (also -inf) */ + int64_t bi = (int64_t)b; /* truncation toward zero, b in range */ + if (a < bi) return -1; + if (a > bi) return 1; + double frac = b - (double)bi; /* exact: bi is b with the fraction dropped */ + if (frac > 0.0) return -1; /* b = a + frac */ + if (frac < 0.0) return 1; + return 0; +} + +static inline int tp_cmp_i64_f64(int64_t a, double b, int op) +{ + if (b != b) { + return op == Py_NE; + } + return tp__apply_cmp(tp__cmp3_i64_f64(a, b), op); +} + +static inline int tp_cmp_f64_i64(double a, int64_t b, int op) +{ + if (a != a) { + return op == Py_NE; + } + return tp__apply_cmp(-tp__cmp3_i64_f64(b, a), op); +} + +/* ------------------------------------------------------------------------------------------ + * math (mathmodule.c: math_1 / math_2 / is_error rules) + * ------------------------------------------------------------------------------------------ */ +static inline void tp__math_domain_error(double x, const char *msg314) +{ +#if PY_VERSION_HEX >= 0x030E0000 + if (msg314) { + char *buf = PyOS_double_to_string(x, 'r', 0, Py_DTSF_ADD_DOT_0, NULL); + if (buf) { + PyErr_Format(PyExc_ValueError, msg314, buf); + PyMem_Free(buf); + } + return; + } +#else + (void)x; + (void)msg314; +#endif + PyErr_SetString(PyExc_ValueError, "math domain error"); +} + +/* mathmodule.c is_error: 1 if an exception was set, 0 if the errno is to be ignored */ +static inline int tp__math_is_error(double x) +{ + int result = 1; + if (errno == EDOM) { + PyErr_SetString(PyExc_ValueError, "math domain error"); + } + else if (errno == ERANGE) { + if (fabs(x) < 1.5) { + result = 0; + } + else { + PyErr_SetString(PyExc_OverflowError, "math range error"); + } + } + else { + PyErr_SetFromErrno(PyExc_ValueError); + } + return result; +} + +/* math_1: x is the argument, r the libm result computed with errno cleared just before */ +static inline int tp__math_1_finish(double x, double r, int can_overflow, const char *msg314, + double *out) +{ + if (isnan(r) && !isnan(x)) { + tp__math_domain_error(x, msg314); + return -1; + } + if (isinf(r) && isfinite(x)) { + if (can_overflow) { + PyErr_SetString(PyExc_OverflowError, "math range error"); + } + else { + tp__math_domain_error(x, msg314); + } + return -1; + } + if (isfinite(r) && errno && tp__math_is_error(r)) { + return -1; + } + *out = r; + return 0; +} + +/* mathmodule.c m_log */ +static inline double tp__m_log(double x) +{ + if (isfinite(x)) { + if (x > 0.0) return log(x); + errno = EDOM; + if (x == 0.0) return -HUGE_VAL; + return NAN; + } + else if (isnan(x)) { + return x; + } + else if (x > 0.0) { + return x; + } + errno = EDOM; + return NAN; +} + +#define TP__DEF_MATH1(NAME, CALL, CAN_OVERFLOW, MSG) \ + static inline int tp_math_##NAME(double x, double *out) \ + { \ + double r; \ + errno = 0; \ + r = CALL; \ + return tp__math_1_finish(x, r, CAN_OVERFLOW, MSG, out); \ + } +TP__DEF_MATH1(sqrt, sqrt(x), 0, TP_MATH_MSG_NONNEG) +TP__DEF_MATH1(exp, exp(x), 1, NULL) +TP__DEF_MATH1(log, tp__m_log(x), 0, TP_MATH_MSG_POSITIVE) +TP__DEF_MATH1(sin, sin(x), 0, TP_MATH_MSG_FINITE) +TP__DEF_MATH1(cos, cos(x), 0, TP_MATH_MSG_FINITE) +TP__DEF_MATH1(tan, tan(x), 0, TP_MATH_MSG_FINITE) +TP__DEF_MATH1(fabs, fabs(x), 0, NULL) +#undef TP__DEF_MATH1 + +/* mathmodule.c m_atan2: the special values are defined here, the rest is libm */ +static inline double tp__m_atan2(double y, double x) +{ + const double pi = 3.14159265358979323846; + if (isnan(x) || isnan(y)) return NAN; + if (isinf(y)) { + if (isinf(x)) { + if (copysign(1., x) == 1.) return copysign(0.25 * pi, y); + return copysign(0.75 * pi, y); + } + return copysign(0.5 * pi, y); + } + if (isinf(x) || y == 0.) { + if (copysign(1., x) == 1.) return copysign(0., y); + return copysign(pi, y); + } + return atan2(y, x); +} + +/* math_2 rules for atan2 */ +static inline int tp_math_atan2(double y, double x, double *out) +{ + double r; + errno = 0; + r = tp__m_atan2(y, x); + if (isnan(r)) { + errno = (!isnan(x) && !isnan(y)) ? EDOM : 0; + } + else if (isinf(r)) { + errno = (isfinite(x) && isfinite(y)) ? ERANGE : 0; + } + if (errno && tp__math_is_error(r)) { + return -1; + } + *out = r; + return 0; +} + +#define TP__DBL_MIN 2.2250738585072014e-308 /* DBL_MIN, without */ + +/* mathmodule.c vector_norm (3.13 and 3.14 are identical): the correctly rounded hypot */ +typedef struct { double hi; double lo; } tp__dl; + +static inline tp__dl tp__dl_fast_sum(double a, double b) +{ + double x = a + b; + double y = (a - x) + b; + tp__dl r = {x, y}; + return r; +} + +static inline tp__dl tp__dl_mul(double x, double y) +{ + double z = x * y; + double zz = fma(x, y, -z); + tp__dl r = {z, zz}; + return r; +} + +static inline double tp__vector_norm(Py_ssize_t n, double *vec, double max, int found_nan) +{ + double x, h, scale, csum = 1.0, frac1 = 0.0, frac2 = 0.0; + tp__dl pr, sm; + int max_e; + Py_ssize_t i; + + if (isinf(max)) return max; + if (found_nan) return NAN; + if (max == 0.0 || n <= 1) return max; + frexp(max, &max_e); + if (max_e < -1023) { + for (i = 0; i < n; i++) { + vec[i] /= TP__DBL_MIN; + } + return TP__DBL_MIN * tp__vector_norm(n, vec, max / TP__DBL_MIN, found_nan); + } + scale = ldexp(1.0, -max_e); + for (i = 0; i < n; i++) { + x = vec[i]; + x *= scale; + pr = tp__dl_mul(x, x); + sm = tp__dl_fast_sum(csum, pr.hi); + csum = sm.hi; + frac1 += pr.lo; + frac2 += sm.lo; + } + h = sqrt(csum - 1.0 + (frac1 + frac2)); + pr = tp__dl_mul(-h, h); + sm = tp__dl_fast_sum(csum, pr.hi); + csum = sm.hi; + frac1 += pr.lo; + frac2 += sm.lo; + x = csum - 1.0 + (frac1 + frac2); + h += x / (2.0 * h); + return h / scale; +} + +static inline int tp_math_hypot(double x, double y, double *out) +{ + double vec[2]; + double max = 0.0; + int found_nan; + vec[0] = fabs(x); + vec[1] = fabs(y); + found_nan = isnan(vec[0]) | isnan(vec[1]); + if (vec[0] > max) max = vec[0]; + if (vec[1] > max) max = vec[1]; + *out = tp__vector_norm(2, vec, max, found_nan); + return 0; +} + +/* ------------------------------------------------------------------------------------------ + * Boxing + * ------------------------------------------------------------------------------------------ */ +static inline PyObject *tp_box_i64(int64_t v) { return PyLong_FromLongLong((long long)v); } +static inline PyObject *tp_box_f64(double v) { return PyFloat_FromDouble(v); } +static inline PyObject *tp_box_bool(int v) { return PyBool_FromLong(v ? 1 : 0); } + +static inline int tp_unbox_i64(PyObject *o, int64_t *out) +{ + int overflow = 0; + long long v; + if (!PyLong_CheckExact(o)) { + return 1; + } + v = PyLong_AsLongLongAndOverflow(o, &overflow); + if (overflow) { + return 1; + } + if (v == -1 && PyErr_Occurred()) { + PyErr_Clear(); /* cannot happen for an exact int; deopt rather than leak an error */ + return 1; + } + *out = (int64_t)v; + return 0; +} + +static inline int tp_unbox_f64(PyObject *o, double *out) +{ + if (!PyFloat_CheckExact(o)) { + return 1; + } + *out = PyFloat_AS_DOUBLE(o); + return 0; +} + +static inline int tp_unbox_bool(PyObject *o, int *out) +{ + if (o == Py_True) { + *out = 1; + return 0; + } + if (o == Py_False) { + *out = 0; + return 0; + } + return 1; +} + +/* ------------------------------------------------------------------------------------------ + * Native arrays for list[float] / list[int] parameters (ir.ArrayParam) + * + * enter: rc 0 -> the struct holds the copy; rc 1 or -1 -> the struct is zeroed (so exit is a safe + * no-op) and nothing was changed. `list` is borrowed: the caller keeps the argument alive. + * exit: writes the dirty slots back as new objects (PyList_SetItem steals the new reference and, on + * failure, drops it), always frees, and zeroes the struct. On a write-back failure it stops, + * frees, and returns -1 with that error set (MemoryError, or IndexError if the list shrank). + * ------------------------------------------------------------------------------------------ */ +typedef struct { PyObject *list; Py_ssize_t len; double *data; unsigned char *dirty; } tp_f64_array; +typedef struct { PyObject *list; Py_ssize_t len; int64_t *data; unsigned char *dirty; } tp_i64_array; + +static inline int tp_f64_array_enter(PyObject *obj, tp_f64_array *a) +{ + Py_ssize_t n, i; + a->list = NULL; + a->len = 0; + a->data = NULL; + a->dirty = NULL; + if (!PyList_CheckExact(obj)) { + return 1; + } + n = PyList_GET_SIZE(obj); + for (i = 0; i < n; i++) { + if (!PyFloat_CheckExact(PyList_GET_ITEM(obj, i))) { + return 1; + } + } + a->data = (double *)malloc(((size_t)n ? (size_t)n : 1) * sizeof(double)); + a->dirty = (unsigned char *)calloc((size_t)n ? (size_t)n : 1, 1); + if (a->data == NULL || a->dirty == NULL) { + free(a->data); + free(a->dirty); + a->data = NULL; + a->dirty = NULL; + PyErr_NoMemory(); + return -1; + } + for (i = 0; i < n; i++) { + a->data[i] = PyFloat_AS_DOUBLE(PyList_GET_ITEM(obj, i)); + } + a->list = obj; + a->len = n; + return 0; +} + +static inline int tp_i64_array_enter(PyObject *obj, tp_i64_array *a) +{ + Py_ssize_t n, i; + a->list = NULL; + a->len = 0; + a->data = NULL; + a->dirty = NULL; + if (!PyList_CheckExact(obj)) { + return 1; + } + n = PyList_GET_SIZE(obj); + a->data = (int64_t *)malloc(((size_t)n ? (size_t)n : 1) * sizeof(int64_t)); + a->dirty = (unsigned char *)calloc((size_t)n ? (size_t)n : 1, 1); + if (a->data == NULL || a->dirty == NULL) { + free(a->data); + free(a->dirty); + a->data = NULL; + a->dirty = NULL; + PyErr_NoMemory(); + return -1; + } + for (i = 0; i < n; i++) { + if (tp_unbox_i64(PyList_GET_ITEM(obj, i), &a->data[i]) != 0) { + free(a->data); + free(a->dirty); + a->data = NULL; + a->dirty = NULL; + return 1; + } + } + a->list = obj; + a->len = n; + return 0; +} + +/* list indexing: a negative index counts from the end; out of range -> IndexError */ +static inline int tp__array_slot(Py_ssize_t len, int64_t i, int store, Py_ssize_t *slot) +{ + if (i < 0) { + if (i < -(int64_t)len) goto out_of_range; + i += (int64_t)len; + } + else if (i >= (int64_t)len) { + goto out_of_range; + } + *slot = (Py_ssize_t)i; + return 0; +out_of_range: + PyErr_SetString(PyExc_IndexError, + store ? "list assignment index out of range" : "list index out of range"); + return -1; +} + +static inline int tp_f64_array_slot(const tp_f64_array *a, int64_t i, int store, Py_ssize_t *slot) +{ + return tp__array_slot(a->len, i, store, slot); +} + +static inline int tp_i64_array_slot(const tp_i64_array *a, int64_t i, int store, Py_ssize_t *slot) +{ + return tp__array_slot(a->len, i, store, slot); +} + +static inline int tp_f64_array_exit(tp_f64_array *a) +{ + int rc = 0; + if (a->data != NULL && a->dirty != NULL && a->list != NULL) { + Py_ssize_t i; + for (i = 0; i < a->len; i++) { + PyObject *v; + if (!a->dirty[i]) { + continue; + } + v = PyFloat_FromDouble(a->data[i]); + if (v == NULL || PyList_SetItem(a->list, i, v) < 0) { + rc = -1; /* SetItem consumed v on failure; the error is set */ + break; + } + } + } + free(a->data); + free(a->dirty); + a->list = NULL; + a->len = 0; + a->data = NULL; + a->dirty = NULL; + return rc; +} + +static inline int tp_i64_array_exit(tp_i64_array *a) +{ + int rc = 0; + if (a->data != NULL && a->dirty != NULL && a->list != NULL) { + Py_ssize_t i; + for (i = 0; i < a->len; i++) { + PyObject *v; + if (!a->dirty[i]) { + continue; + } + v = PyLong_FromLongLong((long long)a->data[i]); + if (v == NULL || PyList_SetItem(a->list, i, v) < 0) { + rc = -1; + break; + } + } + } + free(a->data); + free(a->dirty); + a->list = NULL; + a->len = 0; + a->data = NULL; + a->dirty = NULL; + return rc; +} + +/* ------------------------------------------------------------------------------------------ + * Local arrays (ir.NewArray / CopyArray): owned by the function, no list object behind them. + * Same structs as above with list == NULL and dirty == NULL; the slot helpers work on them + * unchanged. rc 0 ok, -1 MemoryError (the struct is then zeroed, so _free is still safe). + * n < 0 gives an empty array, like CPython's [x] * -1 == []. A size that overflows + * n * sizeof(element) is a MemoryError, not a wrapped small allocation. _free never writes + * anything back, zeroes the struct and is idempotent. _exit on a local array is defined to + * behave exactly like _free (nothing to write back since list == NULL, rc 0), so a stray _exit + * is harmless; local arrays are still released with _free. + * ------------------------------------------------------------------------------------------ */ +static inline void *tp__local_alloc(int64_t n, size_t elem, Py_ssize_t *len) +{ + void *p; + if (n < 0) { + n = 0; + } + if (n > (int64_t)PY_SSIZE_T_MAX || (uint64_t)n > (uint64_t)(SIZE_MAX / elem)) { + PyErr_NoMemory(); + return NULL; + } + p = malloc(n ? (size_t)n * elem : elem); + if (p == NULL) { + PyErr_NoMemory(); + return NULL; + } + *len = (Py_ssize_t)n; + return p; +} + +static inline int tp_i64_array_new(tp_i64_array *a, int64_t n, int64_t fill) +{ + Py_ssize_t len = 0, i; + int64_t *p; + a->list = NULL; a->len = 0; a->data = NULL; a->dirty = NULL; + p = (int64_t *)tp__local_alloc(n, sizeof(int64_t), &len); + if (p == NULL) { + return -1; + } + for (i = 0; i < len; i++) { + p[i] = fill; + } + a->data = p; + a->len = len; + return 0; +} + +static inline int tp_i64_array_iota(tp_i64_array *a, int64_t n) +{ + Py_ssize_t len = 0, i; + int64_t *p; + a->list = NULL; a->len = 0; a->data = NULL; a->dirty = NULL; + p = (int64_t *)tp__local_alloc(n, sizeof(int64_t), &len); + if (p == NULL) { + return -1; + } + for (i = 0; i < len; i++) { + p[i] = (int64_t)i; + } + a->data = p; + a->len = len; + return 0; +} + +static inline int tp_f64_array_new(tp_f64_array *a, int64_t n, double fill) +{ + Py_ssize_t len = 0, i; + double *p; + a->list = NULL; a->len = 0; a->data = NULL; a->dirty = NULL; + p = (double *)tp__local_alloc(n, sizeof(double), &len); + if (p == NULL) { + return -1; + } + for (i = 0; i < len; i++) { + p[i] = fill; + } + a->data = p; + a->len = len; + return 0; +} + +/* dst is overwritten (not freed); src may be a parameter array or a local one; dst is local */ +static inline int tp_i64_array_copy(tp_i64_array *dst, const tp_i64_array *src) +{ + Py_ssize_t len = 0; + int64_t *p = (int64_t *)tp__local_alloc((int64_t)src->len, sizeof(int64_t), &len); + dst->list = NULL; dst->len = 0; dst->data = NULL; dst->dirty = NULL; + if (p == NULL) { + return -1; + } + if (len > 0) { + memcpy(p, src->data, (size_t)len * sizeof(int64_t)); + } + dst->data = p; + dst->len = len; + return 0; +} + +static inline int tp_f64_array_copy(tp_f64_array *dst, const tp_f64_array *src) +{ + Py_ssize_t len = 0; + double *p = (double *)tp__local_alloc((int64_t)src->len, sizeof(double), &len); + dst->list = NULL; dst->len = 0; dst->data = NULL; dst->dirty = NULL; + if (p == NULL) { + return -1; + } + if (len > 0) { + memcpy(p, src->data, (size_t)len * sizeof(double)); + } + dst->data = p; + dst->len = len; + return 0; +} + +static inline void tp_i64_array_free(tp_i64_array *a) +{ + free(a->data); + free(a->dirty); + a->list = NULL; a->len = 0; a->data = NULL; a->dirty = NULL; +} + +static inline void tp_f64_array_free(tp_f64_array *a) +{ + free(a->data); + free(a->dirty); + a->list = NULL; a->len = 0; a->data = NULL; a->dirty = NULL; +} + +/* a new tuple of the n items; the items are borrowed (each gets its own new reference) */ +static inline PyObject *tp_tuple(PyObject *const *items, Py_ssize_t n) +{ + Py_ssize_t i; + PyObject *t = PyTuple_New(n); + if (t == NULL) { + return NULL; + } + for (i = 0; i < n; i++) { + Py_INCREF(items[i]); + PyTuple_SET_ITEM(t, i, items[i]); + } + return t; +} + +/* aliasing guard: 1 if any two of the n objects are the same object */ +static inline int tp_any_same(PyObject *const *objs, Py_ssize_t n) +{ + Py_ssize_t i, j; + for (i = 0; i < n; i++) { + for (j = i + 1; j < n; j++) { + if (objs[i] == objs[j]) { + return 1; + } + } + } + return 0; +} + +/* ------------------------------------------------------------------------------------------ + * Opaque objects (Kotlin interop and any Python object). All return new references or NULL with + * CPython's exception set; none deopts. + * ------------------------------------------------------------------------------------------ */ + +/* LOAD_GLOBAL: module globals, then builtins, looked up now; NameError("name 'x' is not defined") + * with .name set, as ceval's format_exc_check_arg does. `module_dict` is the module's __dict__; + * its "__builtins__" (a module or a dict) is searched next, the interpreter's builtins if absent. */ +static inline PyObject *tp_global(PyObject *module_dict, PyObject *name) +{ + PyObject *v, *builtins; + v = PyDict_GetItemWithError(module_dict, name); + if (v != NULL) { + Py_INCREF(v); + return v; + } + if (PyErr_Occurred()) { + return NULL; + } + builtins = PyDict_GetItemString(module_dict, "__builtins__"); /* borrowed, errors ignored */ + if (builtins != NULL && PyModule_Check(builtins)) { + builtins = PyModule_GetDict(builtins); + } + if (builtins == NULL || !PyDict_Check(builtins)) { + builtins = PyEval_GetBuiltins(); + } + if (builtins != NULL) { + v = PyDict_GetItemWithError(builtins, name); + if (v != NULL) { + Py_INCREF(v); + return v; + } + if (PyErr_Occurred()) { + return NULL; + } + } + { + PyObject *exc; + PyErr_Format(PyExc_NameError, "name '%.200U' is not defined", name); + exc = PyErr_GetRaisedException(); + if (exc != NULL) { + if (PyObject_SetAttrString(exc, "name", name) < 0) { + PyErr_Clear(); + } + PyErr_SetRaisedException(exc); + } + } + return NULL; +} + +static inline PyObject *tp_getattr(PyObject *obj, PyObject *name) +{ + return PyObject_GetAttr(obj, name); +} + +/* vectorcall: the last len(kwnames) of the nargs values are keyword values */ +static inline PyObject *tp_call(PyObject *callee, PyObject *const *args, size_t nargs, + PyObject *kwnames) +{ + return PyObject_Vectorcall(callee, args, nargs, kwnames); +} + +static inline int tp_truth(PyObject *obj) +{ + return PyObject_IsTrue(obj); +} + +/* A comparison in a condition, as CPython's `if a < b:` runs it: the rich comparison, then the truth + * value of its result. NOT PyObject_RichCompareBool: that one short-circuits `a is b` for == and != + * (so a NaN would equal itself), which `if x == x:` does not. */ +static inline int tp_compare_bool(PyObject *a, PyObject *b, int op) +{ + int r; + PyObject *res = PyObject_RichCompare(a, b, op); + if (res == NULL) { + return -1; + } + r = PyObject_IsTrue(res); + Py_DECREF(res); + return r; +} + +/* float(obj): PyNumber_Float, then the double of the result */ +static inline int tp_obj_to_f64(PyObject *obj, double *out) +{ + PyObject *f = PyNumber_Float(obj); + if (f == NULL) { + return -1; + } + *out = PyFloat_AS_DOUBLE(f); + Py_DECREF(f); + return 0; +} + +/* op is an ir.BinOpKind: TP_BINOP_ADD .. TP_BINOP_MOD (declaration order) */ +static inline PyObject *tp_binop_obj(PyObject *a, PyObject *b, int op) +{ + switch (op) { + case TP_BINOP_ADD: return PyNumber_Add(a, b); + case TP_BINOP_SUB: return PyNumber_Subtract(a, b); + case TP_BINOP_MUL: return PyNumber_Multiply(a, b); + case TP_BINOP_TRUEDIV: return PyNumber_TrueDivide(a, b); + case TP_BINOP_FLOORDIV: return PyNumber_FloorDivide(a, b); + case TP_BINOP_MOD: return PyNumber_Remainder(a, b); + } + PyErr_SetString(PyExc_SystemError, "tp_binop_obj: unknown operator"); + return NULL; +} + +/* Py_CLEAR: the only way generated code drops a reference */ +static inline void tp_release(PyObject **slot) +{ + Py_CLEAR(*slot); +} + +/* ------------------------------------------------------------------------------------------ + * Call depth (issue #57): compiled frames have no Python frame, so a recursion made of them would + * neither raise RecursionError nor stop before the C stack ends. tp_enter_call / tp_leave_call + * bracket every compiled impl function. See API.md "Call depth". + * ------------------------------------------------------------------------------------------ */ +#if defined(_MSC_VER) +#define TP_THREAD_LOCAL __declspec(thread) +#else +#define TP_THREAD_LOCAL __thread +#endif + +typedef struct { + Py_ssize_t count; /* compiled frames in flight on this thread (this module) */ + PyObject *frame; /* strong ref: the top Python frame when `pydepth` was measured */ + Py_ssize_t pydepth; /* Python frames below and including `frame` */ +} tp_depth_state; + +static TP_THREAD_LOCAL tp_depth_state tp_depth = {0, NULL, 0}; + +/* Python frame depth of the running thread: public API only. The walk is O(depth), so it is + * cached by the identity of the top frame (kept alive while compiled frames are in flight). */ +static inline Py_ssize_t tp_python_depth(void) +{ + PyFrameObject *top = PyThreadState_GetFrame(PyThreadState_Get()); /* new ref or NULL */ + if (top == NULL) { + return 0; + } + if (tp_depth.frame == (PyObject *)top) { + Py_DECREF(top); + return tp_depth.pydepth; + } + Py_ssize_t depth = 0; + PyFrameObject *cur = top; + Py_INCREF(cur); + while (cur != NULL) { + depth++; + PyFrameObject *back = PyFrame_GetBack(cur); + Py_DECREF(cur); + cur = back; + } + PyObject *old = tp_depth.frame; + tp_depth.frame = (PyObject *)top; /* takes the reference from GetFrame */ + tp_depth.pydepth = depth; + Py_XDECREF(old); + return depth; +} + +static inline int tp_enter_call(void) +{ + if (tp_python_depth() + tp_depth.count + 1 > Py_GetRecursionLimit()) { + PyErr_SetString(PyExc_RecursionError, "maximum recursion depth exceeded"); + return -1; + } + if (Py_EnterRecursiveCall(" in compiled code")) { + return -1; + } + tp_depth.count++; + return 0; +} + +static inline void tp_leave_call(void) +{ + Py_LeaveRecursiveCall(); + if (--tp_depth.count == 0) { + Py_CLEAR(tp_depth.frame); + } +} + +#endif /* TP_RUNTIME_H */ diff --git a/python-multiplatform-ksp/src/main/python/typedpython/verify.py b/python-multiplatform-ksp/src/main/python/typedpython/verify.py new file mode 100644 index 000000000..e9b164799 --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython/verify.py @@ -0,0 +1,1097 @@ +"""TypedPython IR safety verifier (SPEC N-9, #41; maintainer decision 2026-10-03). + +`verify(module)` proves, per function and on the IR alone, the properties listed in the "Safety" +section of `ir.py`. A function it cannot prove is removed from `Module.functions` and moved to +`Module.skipped` with the reason, so it stays interpreted; every reason is also returned as a +`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 front end set (`pure`, `may_deopt`, `ArrayParam.stored`, node `type`s): each is recomputed from +the body and compared. + +Rules (the `rule` of a Diagnostic): + + verify/definite-assignment a local may be read before it is assigned on some path + verify/missing-return a function returning a value can reach the end of its body + verify/type an operand, result, declaration, constant, or call signature is wrong + verify/ownership an OBJ is produced by a node that does not create a new reference, or + crosses into a scalar slot without Unbox (or a scalar into an OBJ + slot without Box) + verify/deopt-impure a node that can deopt appears in an impure function + verify/flag-may-deopt `may_deopt` differs from what the body computes + verify/flag-pure `pure` is set but the body has an effect + verify/array-use an array parameter is used other than by Index/StoreIndex/Len (or as a + CopyArray source), or those nodes name something that is not an array + verify/array-stored `ArrayParam.stored` differs from the body + verify/array-escape a local array is used other than by Index/StoreIndex/Len and as a + CopyArray source (returned, passed, boxed, stored in a tuple, aliased, + compared...), or NewArray/CopyArray stand anywhere but as the value of + an Assign to a local array + verify/unknown-node a node (or enum field) that `ir` does not define + verify/call-unresolved a Call names a function that is not compiled in this module + verify/call-unverified a Call names a function that the verifier rejected + verify/condition-only a CompareObj outside a condition (If/While cond, or an And/Or + operand that is itself in a condition): its rich-comparison result + need not be a bool, so only its truth value may be used + verify/structure break/continue outside a loop, the ForRange var assigned in its body, + a zero or non-constant step, duplicate names, bad declarations, an + entry global that collides with a parameter/local/other entry global or + is assigned in the body + verify/proven-overflow `proven` is set on an op the verifier cannot prove stays inside i64 (or + on a node `proven` does not apply to, or it is not a bool) + verify/redo `Call.redo` where it is not legal (see below) + verify/entry-global-open a function with entry_globals is not closed + +Proven i64 operations. The verifier never trusts `BinOp.proven` / `UnaryOp.proven`: for every I64 +expression it computes an interval [lo, hi] (exact Python ints) and accepts `proven=True` only on an +I64 ADD/SUB/MUL (BinOp) or NEG (UnaryOp) whose exact result interval lies inside [-2**63, 2**63-1]; +anything else (a different op, a non-I64 node, a result that may leave i64, a non-bool flag) is +rejected, and so is the whole function. A proven op is not a deopting node (an impure function may +contain it; a pure one does not count it toward `may_deopt`); every unproven I64 ADD/SUB/MUL/NEG +still deopts. The interval rules, exactly: + + Const v [v, v] + Local, a ForRange var inside its own loop body: + step > 0 [lo(start), max(lo(start), hi(stop) - 1)] + step < 0 [min(hi(start), lo(stop) + 1), hi(start)] + (the bounds are the intervals of the start/stop expressions, both + evaluated before the loop; then intersected with the i64 range) + Len(array) [0, 2**62] + ADD / SUB / MUL interval arithmetic on the operand intervals (for MUL the min/max of + the four endpoint products); a proven op must fit i64 exactly, an + unproven one is clamped to i64 (a result outside it deopts, so only + in-range values flow on) + NEG / POS [-hi, -lo] / [lo, hi] + everything else the full i64 range: I64 params and entry globals, every local that is + not the ForRange var of an enclosing loop (the verifier does no flow + analysis, so a local assigned a constant is still "any i64"), a loop + var after its loop, Index elements, Unbox, Call results, FLOORDIV, + MOD, TRUEDIV + +Local arrays (NewArray / CopyArray, ir.Function "Local arrays"). A local of an array type holds a +native array the function owns. The verifier proves, never trusting the front end: + + * NewArray/CopyArray are the value of an Assign to a local declared with the same array type, and + nowhere else (`verify/array-escape` elsewhere, `verify/type` on a type mismatch). NewArray: length + I64; fill absent iff `iota` (a bool), `iota` only for I64_ARRAY; a fill has the element type. + CopyArray: the source is a local or parameter array of the same type. + * No escape: a local array is read only by Index/StoreIndex/Len and as a CopyArray source. Any + `Local` naming it (a return value, a Call/CallObject/Box/Tuple operand, another assignment...) is + `verify/array-escape`. It cannot be a loop variable, a parameter, or an entry global. + * Purity: a StoreIndex into a local array is not an effect (and does not set `stored`, which is + for ArrayParams); a function whose only stores are into local arrays can be `pure`. A store into + an array parameter still is. `pure` and `may_deopt` are recomputed as before and compared. + * Definite assignment: the allocation's operands are read before the array is assigned, and every + Index/StoreIndex/Len/CopyArray source of a local array needs it assigned on every path. + * Bounds. (1) The ForRange rule (var over range(c >= 0, Len(a), step > 0)) now also covers a local + array, but only if the loop body never assigns that array (a reassignment may change its + length; the stop is evaluated once). (2) A local array assigned by exactly one Assign statement + in the whole body has a known length interval [lo, hi]: for NewArray the interval of the length + expression clamped at 0 below (a negative length is an empty array), for CopyArray the interval + of the source if the source is itself such a local (otherwise [0, 2**62]). Then Len(a) has that + interval, and an Index/StoreIndex whose index interval lies inside [0, lo - 1] is proven. That + proves constant indices and bounded index expressions; a negative index is never proven (the + `proven` flag means 0 <= index < len). An array assigned twice or more has no known length. + +Tuple. `Tuple(OBJ, elements)`: a tuple of OBJ expressions (a scalar needs Box), result OBJ, a +reference creator in the ownership rule. Building one is not an effect. + +What this cannot prove: anything that needs knowing a value through assignments (accumulators, +`x = 1; x + 1`), relations between variables (`i < n` does not bound `i + n`), bounds that come from +guards or conditions (`if n < 100`), values through array elements, calls or Unbox, the result of +`//` and `%` (even by a constant), a loop var after the loop, or `range` bounds that are themselves +full-range (`range(n)` bounds the var only by i64). A function needing such a proof keeps its +checked, deopting ops (and is then legal only if pure). + +Call.redo. Legal only if the caller is impure, the callee is pure and may_deopt, and the callee +returns F64, BOOL or NONE (`verify/redo` otherwise). A redo call is not a deopting node in the +caller: the callee alone is redone, unobservably, since it is pure. + +Function.entry_globals. Scalar `Param`s (I64/F64/BOOL), read once at entry as part of the guards; +assigned at entry for definite assignment; the body reads them as `Local(name)` of that type and may +not assign them; their names collide with no parameter, local or other entry global. The function +must be *closed*: no GetAttr, CallObject, Truth, CompareObj, ObjToFloat or OBJ BinOp, and Calls only +to closed functions (a fixpoint over the call graph), else `verify/entry-global-open`. Beyond that +rule (conservative, not in ir.py): a Call of a function that has entry globals can fail the callee's +entry guard in the middle of the caller, which is a deopt there unless the caller is itself guarded +the same way (it lists the same name and type as an entry global, and being closed cannot see it +rebound) or the call is a legal `redo`. +""" +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass + +from . import ir +from .ir import ( + And, ArrayParam, Assign, BinOp, BinOpKind, Box, Break, Call, CallObject, Compare, CompareKind, + CompareObj, Const, Continue, CopyArray, ExprStmt, ForRange, Function, GetAttr, Global, If, Index, Len, + Local, MathCall, MathFunc, Module, NewArray, ObjToFloat, Or, Param, Return, StoreIndex, ToFloat, + Truth, Tuple, Type, Unbox, UnaryOp, UnaryOpKind, While, +) + +DA = "verify/definite-assignment" +MISSING_RETURN = "verify/missing-return" +TYPE = "verify/type" +OWNERSHIP = "verify/ownership" +DEOPT = "verify/deopt-impure" +FLAG_DEOPT = "verify/flag-may-deopt" +FLAG_PURE = "verify/flag-pure" +ARRAY_USE = "verify/array-use" +ARRAY_STORED = "verify/array-stored" +ESCAPE = "verify/array-escape" +UNKNOWN = "verify/unknown-node" +UNRESOLVED = "verify/call-unresolved" +UNVERIFIED = "verify/call-unverified" +STRUCTURE = "verify/structure" +CONDITION_ONLY = "verify/condition-only" +PROVEN = "verify/proven-overflow" +REDO = "verify/redo" +ENTRY_OPEN = "verify/entry-global-open" + + +@dataclass(frozen=True) +class Diagnostic: + function: str + rule: str + message: str + source_line: int + + +# Exact classes, not isinstance: a subclass of an ir node is not an ir node (its meaning is not +# the one the back end implements). +_EXPRS = frozenset({Const, Local, BinOp, UnaryOp, Compare, And, Or, ToFloat, Box, Unbox, MathCall, + Call, Global, GetAttr, CallObject, Truth, CompareObj, ObjToFloat, Len, Index, + NewArray, CopyArray, Tuple}) +_STMTS = frozenset({Assign, StoreIndex, ExprStmt, If, While, ForRange, Return, Break, Continue}) +# Nodes whose OBJ result is a new reference (ir.py "ownership": Box, Global, GetAttr, CallObject, +# OBJ BinOp, Tuple), plus reads of an OBJ local (the local owns its reference; the back end increfs or +# borrows under that rule) and Calls of a compiled function returning OBJ (which returns a new +# reference by the same rule). +_OBJ_PRODUCERS = frozenset({Box, Global, GetAttr, CallObject, BinOp, Local, Call, Tuple}) +_VALUE_TYPES = (Type.I64, Type.F64, Type.BOOL, Type.OBJ) # what a local may hold +_RETURN_TYPES = _VALUE_TYPES + (Type.NONE,) +_ELEMENT = {Type.F64_ARRAY: Type.F64, Type.I64_ARRAY: Type.I64} +_MATH_ARITY = {MathFunc.ATAN2: 2, MathFunc.HYPOT: 2} # every other MathFunc: 1 +_I64_DEOPT_OPS = frozenset({BinOpKind.ADD, BinOpKind.SUB, BinOpKind.MUL, BinOpKind.FLOORDIV, + BinOpKind.MOD, BinOpKind.TRUEDIV}) +_I64_MIN, _I64_MAX = -2 ** 63, 2 ** 63 - 1 +_FULL = (_I64_MIN, _I64_MAX) +_LEN_MAX = 2 ** 62 +_PROVABLE_OPS = frozenset({BinOpKind.ADD, BinOpKind.SUB, BinOpKind.MUL}) + +# Definite assignment uses `None` for "unreachable" (bottom: every name counts as assigned). +_State = frozenset[str] | None + + +def _meet(a: _State, b: _State) -> _State: + if a is None: + return b + if b is None: + return a + return a & b + + +class _Loop: + def __init__(self, var: str | None, bounds_array: str | None): + self.var = var # ForRange var (None for While) + self.bounds_array = bounds_array # array whose indices by `var` are in bounds + self.interval = _FULL # the values `var` takes inside the body + self.breaks: list[_State] = [] + + +class _Checker: + """Checks one function against the module's signatures. Collects diagnostics; rebuilds the + body with proven bounds.""" + + def __init__(self, function: Function, signatures: dict[str, Function]): + self.fn = function + self.sigs = signatures + self.diags: list[Diagnostic] = [] + self.can_deopt = False + self.effects: list[str] = [] # reasons the body is impure + self.stored: set[str] = set() + self.callees: set[str] = set() + self.loops: list[_Loop] = [] + self.scalars: dict[str, Type] = {} # name -> type of every scalar/OBJ variable + self.arrays: dict[str, Type] = {} # name -> array type of every ArrayParam + self.larrays: dict[str, Type] = {} # name -> array type of every local array + self.assigns: dict[str, int] = {} # local array -> number of Assign statements to it + self.lens: dict[str, tuple[int, int]] = {} # single-assignment local array -> length + self.entry: dict[str, Type] = {} # name -> type of every entry global + self.opens: list[str] = [] # reasons the function is not closed + + def error(self, rule: str, message: str) -> None: + d = Diagnostic(self.fn.name, rule, message, self.fn.source_line) + if d not in self.diags: + self.diags.append(d) + + # --- declarations ------------------------------------------------------------------------- + + def declarations(self) -> frozenset[str]: + fn = self.fn + params: set[str] = set() + for p in fn.params: + if type(p) is ArrayParam: + if p.type not in ir.ARRAYS: + self.error(TYPE, f"array parameter '{p.name}' has non-array type {p.type}") + elif not isinstance(p.stored, bool): + self.error(TYPE, f"array parameter '{p.name}' has a non-bool `stored`") + else: + self.arrays[p.name] = p.type + elif type(p) is Param: + if p.type not in _VALUE_TYPES: + self.error(TYPE, f"parameter '{p.name}' has type {p.type}; arrays must be " + f"ArrayParam") + else: + self.scalars[p.name] = p.type + else: + self.error(UNKNOWN, f"parameter {p!r} is not an ir Param/ArrayParam") + continue + if p.name in params: + self.error(STRUCTURE, f"parameter '{p.name}' appears twice") + params.add(p.name) + eg = fn.entry_globals + if not isinstance(eg, tuple): + self.error(STRUCTURE, "entry_globals is not a tuple") + eg = () + for p in eg: + if type(p) is ArrayParam: + self.error(TYPE, f"entry global '{p.name}' is an array") + continue + if type(p) is not Param: + self.error(UNKNOWN, f"entry global {p!r} is not an ir Param") + continue + if p.type not in ir.SCALARS: + self.error(TYPE, f"entry global '{p.name}' has type {p.type}; only i64, f64 and " + f"bool globals can be read at entry") + continue + if p.name in params or p.name in self.entry: + self.error(STRUCTURE, f"entry global '{p.name}' collides with a parameter or " + f"another entry global") + continue + self.entry[p.name] = p.type + self.scalars[p.name] = p.type + params |= set(self.entry) + if not isinstance(fn.locals, dict): + self.error(STRUCTURE, "locals is not a dict") + return frozenset(params) + for name, t in fn.locals.items(): + if name in self.entry: + self.error(STRUCTURE, f"local '{name}' collides with an entry global") + elif name in self.arrays: + self.error(ARRAY_USE, f"local '{name}' shadows array parameter '{name}'") + elif t in ir.ARRAYS: + if name in self.scalars: + self.error(TYPE, f"local array '{name}' collides with a scalar parameter") + else: + self.larrays[name] = t + elif t not in _VALUE_TYPES: + self.error(TYPE, f"local '{name}' has type {t}") + elif name in self.scalars and self.scalars[name] is not t: + self.error(TYPE, f"local '{name}' declared {t} but parameter is " + f"{self.scalars[name]}") + else: + self.scalars[name] = t + if fn.returns not in _RETURN_TYPES: + self.error(TYPE, f"return type {fn.returns} is not a value type") + for flag in ("pure", "may_deopt"): + if not isinstance(getattr(fn, flag), bool): + self.error(TYPE, f"`{flag}` is not a bool") + return frozenset(params) + + # --- expressions -------------------------------------------------------------------------- + + def slot(self, e: ir.Expr, want: Type, what: str) -> None: + """`e` flows into a slot of type `want` (already checked as an expression).""" + got = getattr(e, "type", None) + if got is want: + return + if got is Type.OBJ and want in ir.SCALARS: + self.error(OWNERSHIP, f"{what}: OBJ flows into a {want.value} slot without Unbox") + elif want is Type.OBJ and got in ir.SCALARS: + self.error(OWNERSHIP, f"{what}: {got.value} flows into an OBJ slot without Box") + elif got in ir.ARRAYS: + pass # reported as array-use where the array was read + else: + self.error(TYPE, f"{what}: expected {want.value}, got " + f"{got.value if isinstance(got, Type) else got!r}") + + def expr(self, e: object, state: _State, cond: bool = False) -> ir.Expr: + """Check `e`, returning it rebuilt (with proven bounds). `state` is the definitely + assigned set at this point; `cond` says `e` is used only for its truth value (an If/While + condition, or an And/Or operand in one).""" + cls = type(e) + if cls not in _EXPRS: + self.error(UNKNOWN, f"expression node {cls.__name__} is not defined by ir") + return e # type: ignore[return-value] + t = e.type + if not isinstance(t, Type): + self.error(UNKNOWN, f"{cls.__name__} has non-Type type {t!r}") + return e + if t is Type.OBJ and cls not in _OBJ_PRODUCERS: + self.error(OWNERSHIP, f"{cls.__name__} cannot produce an OBJ (only Box, Global, GetAttr, CallObject, OBJ " + f"BinOp, Tuple, OBJ locals and OBJ-returning Calls create " + f"references)") + if cls is CompareObj and not cond: + self.error(CONDITION_ONLY, "CompareObj outside a condition: the rich comparison's " + "result object would be kept, and it need not be a bool") + method = getattr(self, "e_" + cls.__name__) + if cls in (And, Or): + return method(e, state, cond) + return method(e, state) + + def e_Const(self, e: Const, state: _State) -> ir.Expr: + v, t = e.value, e.type + ok = ((t is Type.I64 and type(v) is int and _I64_MIN <= v <= _I64_MAX) + or (t is Type.F64 and type(v) is float) + or (t is Type.BOOL and type(v) is bool) + or (t is Type.NONE and v is None)) + if not ok and t is not Type.OBJ: + self.error(TYPE, f"constant {v!r} does not match type {t.value}") + return e + + def e_Local(self, e: Local, state: _State) -> ir.Expr: + name = e.name + if name in self.arrays: + self.error(ARRAY_USE, f"array parameter '{name}' is used as a value (only Index, " + f"StoreIndex and Len may use it)") + return e + if name in self.larrays: + self.error(ESCAPE, f"local array '{name}' is used as a value (only Index, StoreIndex, " + f"Len and CopyArray may use it)") + return e + declared = self.scalars.get(name) + if declared is None: + self.error(TYPE, f"local '{name}' is not declared") + return e + if declared is not e.type: + self.error(TYPE, f"local '{name}' is {declared.value} but read as {e.type.value}") + if state is not None and name not in state: + self.error(DA, f"local '{name}' may be read before it is assigned") + return e + + def interval(self, e: ir.Expr) -> tuple[int, int]: + """The interval rules in the module docstring: [lo, hi] bounding every value `e` (an I64 + expression, already checked) can take when it completes without deopting.""" + cls = type(e) + if getattr(e, "type", None) is not Type.I64: + return _FULL + if cls is Const: + return (e.value, e.value) if type(e.value) is int else _FULL + if cls is Local: + for loop in reversed(self.loops): + if loop.var == e.name: + return loop.interval + return _FULL + if cls is Len: + return self.lens.get(e.array, (0, _LEN_MAX)) + if cls is BinOp and e.op in _PROVABLE_OPS: + lo, hi = _exact(e.op, self.interval(e.left), self.interval(e.right)) + return (max(lo, _I64_MIN), min(hi, _I64_MAX)) + if cls is UnaryOp and e.op in (UnaryOpKind.NEG, UnaryOpKind.POS): + lo, hi = self.interval(e.operand) + lo, hi = (-hi, -lo) if e.op is UnaryOpKind.NEG else (lo, hi) + return (max(lo, _I64_MIN), min(hi, _I64_MAX)) + return _FULL + + def check_proven(self, shape_ok: bool, interval: tuple[int, int], what: str) -> bool: + """`proven=True` (already known to be a bool) is accepted only if re-proved. Returns + whether the node counts as non-deopting.""" + if not shape_ok: + self.error(PROVEN, f"`proven` is set on {what}, which has no i64 overflow to prove") + elif not (_I64_MIN <= interval[0] and interval[1] <= _I64_MAX): + self.error(PROVEN, f"`proven` is set on {what} but its result interval " + f"[{interval[0]}, {interval[1]}] is not inside i64") + else: + return True + return False + + def e_BinOp(self, e: BinOp, state: _State) -> ir.Expr: + if type(e.op) is not BinOpKind: + self.error(UNKNOWN, f"BinOp op {e.op!r} is not a BinOpKind") + return e + left, right = self.expr(e.left, state), self.expr(e.right, state) + lt, rt = getattr(left, "type", None), getattr(right, "type", None) + proven = False + if type(e.proven) is not bool: + self.error(TYPE, f"BinOp `proven` {e.proven!r} is not a bool") + elif e.proven: + shape = (e.type is Type.I64 and lt is Type.I64 and rt is Type.I64 + and e.op in _PROVABLE_OPS) + exact = _exact(e.op, self.interval(left), self.interval(right)) if shape else _FULL + proven = self.check_proven(shape, exact, f"{e.type.value} {e.op.value}") + if lt not in (Type.I64, Type.F64, Type.OBJ): + self.error(TYPE if lt is not Type.OBJ else OWNERSHIP, + f"BinOp {e.op.value} on {getattr(lt, 'value', lt)} operands") + elif lt is not rt: + self.slot(right, lt, f"right operand of {e.op.value}") + else: + want = Type.F64 if (lt is Type.I64 and e.op is BinOpKind.TRUEDIV) else lt + if e.type is not want: + self.error(TYPE, f"{lt.value} {e.op.value} {rt.value} is {want.value}, node says " + f"{e.type.value}") + if lt is Type.I64 and e.op in _I64_DEOPT_OPS and not proven: + self.can_deopt = True + if lt is Type.OBJ: + self.effects.append(f"OBJ {e.op.value} (runs user methods)") + self.opens.append(f"OBJ {e.op.value} (runs user methods)") + return dataclasses.replace(e, left=left, right=right) + + def e_UnaryOp(self, e: UnaryOp, state: _State) -> ir.Expr: + if type(e.op) is not UnaryOpKind: + self.error(UNKNOWN, f"UnaryOp op {e.op!r} is not a UnaryOpKind") + return e + operand = self.expr(e.operand, state) + ot = getattr(operand, "type", None) + proven = False + if type(e.proven) is not bool: + self.error(TYPE, f"UnaryOp `proven` {e.proven!r} is not a bool") + elif e.proven: + shape = e.type is Type.I64 and ot is Type.I64 and e.op is UnaryOpKind.NEG + lo, hi = self.interval(operand) if shape else _FULL + proven = self.check_proven(shape, (-hi, -lo) if shape else _FULL, + f"unary {e.op.value} of {getattr(ot, 'value', ot)}") + if e.op is UnaryOpKind.NOT: + self.slot(operand, Type.BOOL, "operand of not") + if e.type is not Type.BOOL: + self.error(TYPE, f"not gives bool, node says {e.type.value}") + else: + if ot is Type.OBJ: + self.error(OWNERSHIP, f"unary {e.op.value} on OBJ has no IR form") + elif ot not in (Type.I64, Type.F64): + self.error(TYPE, f"unary {e.op.value} on {getattr(ot, 'value', ot)}") + elif e.type is not ot: + self.error(TYPE, f"unary {e.op.value} of {ot.value} is {ot.value}, node says " + f"{e.type.value}") + elif ot is Type.I64 and e.op is UnaryOpKind.NEG and not proven: + self.can_deopt = True + return dataclasses.replace(e, operand=operand) + + def e_Compare(self, e: Compare, state: _State) -> ir.Expr: + if type(e.op) is not CompareKind: + self.error(UNKNOWN, f"Compare op {e.op!r} is not a CompareKind") + return e + left, right = self.expr(e.left, state), self.expr(e.right, state) + lt, rt = getattr(left, "type", None), getattr(right, "type", None) + numeric = (Type.I64, Type.F64) + if Type.OBJ in (lt, rt): + self.error(OWNERSHIP, "Compare on an OBJ operand (rich comparison returns an object, " + "not a bool)") + elif not ((lt in numeric and rt in numeric) or (lt is Type.BOOL and rt is Type.BOOL)): + self.error(TYPE, f"Compare of {getattr(lt, 'value', lt)} and " + f"{getattr(rt, 'value', rt)}") + if e.type is not Type.BOOL: + self.error(TYPE, f"Compare gives bool, node says {e.type.value}") + return dataclasses.replace(e, left=left, right=right) + + def _bool_pair(self, e: And | Or, state: _State, cond: bool = False) -> ir.Expr: + name = type(e).__name__ + left, right = self.expr(e.left, state, cond), self.expr(e.right, state, cond) + self.slot(left, Type.BOOL, f"left operand of {name}") + self.slot(right, Type.BOOL, f"right operand of {name}") + if e.type is not Type.BOOL: + self.error(TYPE, f"{name} gives bool, node says {e.type.value}") + return dataclasses.replace(e, left=left, right=right) + + e_And = _bool_pair + e_Or = _bool_pair + + def e_ToFloat(self, e: ToFloat, state: _State) -> ir.Expr: + operand = self.expr(e.operand, state) + self.slot(operand, Type.I64, "operand of ToFloat") + if e.type is not Type.F64: + self.error(TYPE, f"ToFloat gives f64, node says {e.type.value}") + return dataclasses.replace(e, operand=operand) + + def e_Box(self, e: Box, state: _State) -> ir.Expr: + operand = self.expr(e.operand, state) + ot = getattr(operand, "type", None) + if ot is Type.OBJ: + self.error(OWNERSHIP, "Box of an OBJ (it is already an object)") + elif ot not in ir.SCALARS and ot not in ir.ARRAYS: + self.error(TYPE, f"Box of {getattr(ot, 'value', ot)}") + if e.type is not Type.OBJ: + self.error(TYPE, f"Box gives obj, node says {e.type.value}") + return dataclasses.replace(e, operand=operand) + + def e_Unbox(self, e: Unbox, state: _State) -> ir.Expr: + operand = self.expr(e.operand, state) + ot = getattr(operand, "type", None) + if ot in ir.SCALARS: + self.error(OWNERSHIP, f"Unbox of a {ot.value} (only an OBJ can be unboxed)") + elif ot is not Type.OBJ and ot not in ir.ARRAYS: + self.error(TYPE, f"Unbox of {getattr(ot, 'value', ot)}") + if e.type not in ir.SCALARS: + self.error(TYPE, f"Unbox gives a scalar, node says {e.type.value}") + elif e.type is Type.I64: + self.can_deopt = True + return dataclasses.replace(e, operand=operand) + + def e_MathCall(self, e: MathCall, state: _State) -> ir.Expr: + if type(e.func) is not MathFunc: + self.error(UNKNOWN, f"MathCall func {e.func!r} is not a MathFunc") + return e + args = self.args(e.args, state, f"math.{e.func.value}") + arity = _MATH_ARITY.get(e.func, 1) + if len(args) != arity: + self.error(TYPE, f"math.{e.func.value} takes {arity} argument(s), got {len(args)}") + for k, a in enumerate(args): + self.slot(a, Type.F64, f"argument {k + 1} of math.{e.func.value}") + if e.type is not Type.F64: + self.error(TYPE, f"math.{e.func.value} gives f64, node says {e.type.value}") + return dataclasses.replace(e, args=args) + + def args(self, args: object, state: _State, what: str) -> tuple[ir.Expr, ...]: + if not isinstance(args, tuple): + self.error(STRUCTURE, f"arguments of {what} are not a tuple") + return () + return tuple(self.expr(a, state) for a in args) + + def e_Call(self, e: Call, state: _State) -> ir.Expr: + args = self.args(e.args, state, f"call of '{e.function}'") + callee = self.sigs.get(e.function) + if callee is None: + self.error(UNRESOLVED, f"call of '{e.function}', which is not a compiled function of " + f"this module") + return dataclasses.replace(e, args=args) + self.callees.add(e.function) + if len(args) != len(callee.params): + self.error(TYPE, f"'{e.function}' takes {len(callee.params)} argument(s), got " + f"{len(args)}") + for k, (a, p) in enumerate(zip(args, callee.params)): + if type(p) is ArrayParam: + self.error(ARRAY_USE, f"'{e.function}' takes array parameter '{p.name}'; an array " + f"cannot be passed by Call (the entry guard and write-back " + f"belong to the Python-level call)") + else: + self.slot(a, p.type, f"argument {k + 1} of '{e.function}'") + if e.type is not callee.returns: + self.error(TYPE, f"'{e.function}' returns {callee.returns.value}, node says " + f"{e.type.value}") + redo = False + if type(e.redo) is not bool: + self.error(TYPE, f"Call `redo` {e.redo!r} is not a bool") + elif e.redo: + if self.fn.pure is True: + self.error(REDO, f"redo call of '{e.function}' in a pure function (a pure caller " + f"propagates the deopt instead)") + elif callee.pure is not True or callee.may_deopt is not True: + self.error(REDO, f"redo call of '{e.function}', which is not a pure may_deopt " + f"function") + elif callee.returns not in (Type.F64, Type.BOOL, Type.NONE): + self.error(REDO, f"redo call of '{e.function}' returning " + f"{getattr(callee.returns, 'value', callee.returns)}: only f64, " + f"bool and none results are exactly reproducible") + else: + redo = True + if callee.may_deopt is True and not redo: + self.can_deopt = True + if not redo and not self.covers(callee): + self.can_deopt = True + if callee.pure is not True: + self.effects.append(f"calls impure '{e.function}'") + return dataclasses.replace(e, args=args) + + def covers(self, callee: Function) -> bool: + """Every entry global of `callee` is also an entry global of this function with the same + type: the callee's entry guard cannot fail mid-caller (this function, being closed, + cannot see the global rebound).""" + eg = callee.entry_globals + if not isinstance(eg, tuple): + return False + return all(type(p) is Param and self.entry.get(p.name) is p.type for p in eg) + + def obj_operand(self, e: object, state: _State, what: str) -> ir.Expr: + x = self.expr(e, state) + self.slot(x, Type.OBJ, what) + return x + + def result(self, e: ir.Expr, want: Type) -> None: + if e.type is not want: + self.error(TYPE, f"{type(e).__name__} gives {want.value}, node says {e.type.value}") + + def e_Global(self, e: Global, state: _State) -> ir.Expr: + if not isinstance(e.name, str): + self.error(TYPE, f"Global name {e.name!r} is not a str") + self.result(e, Type.OBJ) + return e # a read alone has no effect (ir.Global) + + def e_GetAttr(self, e: GetAttr, state: _State) -> ir.Expr: + obj = self.obj_operand(e.obj, state, f"object of .{e.name}") + if not isinstance(e.name, str): + self.error(TYPE, f"GetAttr name {e.name!r} is not a str") + self.result(e, Type.OBJ) + self.effects.append(f"GetAttr .{e.name} (a property may have effects)") + self.opens.append(f"GetAttr .{e.name}") + return dataclasses.replace(e, obj=obj) + + def e_CallObject(self, e: CallObject, state: _State) -> ir.Expr: + callee = self.obj_operand(e.callee, state, "callee of CallObject") + args = self.args(e.args, state, "CallObject") + for k, a in enumerate(args): + self.slot(a, Type.OBJ, f"argument {k + 1} of CallObject") + kw = e.kwnames + if not (isinstance(kw, tuple) and all(isinstance(n, str) for n in kw)): + self.error(TYPE, f"CallObject kwnames {kw!r} is not a tuple of str") + elif len(kw) > len(args) or len(set(kw)) != len(kw): + self.error(TYPE, f"CallObject has {len(kw)} keyword name(s) {kw!r} for {len(args)} " + f"argument(s) (they must name distinct trailing arguments)") + self.result(e, Type.OBJ) + self.effects.append("CallObject") + self.opens.append("CallObject") + return dataclasses.replace(e, callee=callee, args=args) + + def e_Truth(self, e: Truth, state: _State) -> ir.Expr: + operand = self.obj_operand(e.operand, state, "operand of Truth") + self.result(e, Type.BOOL) + self.effects.append("Truth (runs __bool__/__len__)") + self.opens.append("Truth") + return dataclasses.replace(e, operand=operand) + + def e_CompareObj(self, e: CompareObj, state: _State) -> ir.Expr: + if type(e.op) is not CompareKind: + self.error(UNKNOWN, f"CompareObj op {e.op!r} is not a CompareKind") + return e + left, right = self.expr(e.left, state), self.expr(e.right, state) + types = (getattr(left, "type", None), getattr(right, "type", None)) + if Type.OBJ not in types: + self.error(TYPE, "CompareObj without an OBJ operand (use Compare)") + for t in types: + if t is not Type.OBJ and t not in ir.SCALARS and t not in ir.ARRAYS: + self.error(TYPE, f"CompareObj operand of type {getattr(t, 'value', t)}") + self.result(e, Type.BOOL) + self.effects.append("CompareObj (runs rich comparison)") + self.opens.append("CompareObj") + return dataclasses.replace(e, left=left, right=right) + + def e_ObjToFloat(self, e: ObjToFloat, state: _State) -> ir.Expr: + operand = self.obj_operand(e.operand, state, "operand of ObjToFloat") + self.result(e, Type.F64) + self.effects.append("ObjToFloat (runs __float__/__index__)") + self.opens.append("ObjToFloat") + return dataclasses.replace(e, operand=operand) + + def array(self, name: str, node: str, state: _State = None) -> Type | None: + """The array type of `name` (an array parameter or a local array); a local array must be + definitely assigned.""" + t = self.arrays.get(name) + if t is not None: + return t + t = self.larrays.get(name) + if t is None: + self.error(ARRAY_USE, f"{node} of '{name}', which is not an array") + elif state is not None and name not in state: + self.error(DA, f"local array '{name}' may be used ({node}) before it is assigned") + return t + + def e_Len(self, e: Len, state: _State) -> ir.Expr: + self.array(e.array, "len", state) + if e.type is not Type.I64: + self.error(TYPE, f"len gives i64, node says {e.type.value}") + return e + + def index(self, array: str, index: object, state: _State, node: str + ) -> tuple[ir.Expr, Type | None, bool]: + at = self.array(array, node, state) + idx = self.expr(index, state) + self.slot(idx, Type.I64, f"index of {node} '{array}'") + return idx, (_ELEMENT[at] if at else None), self.in_bounds(array, idx) + + def e_Index(self, e: Index, state: _State) -> ir.Expr: + idx, element, proven = self.index(e.array, e.index, state, "index") + if element is not None and e.type is not element and e.type is not Type.OBJ: + self.error(TYPE, f"element of '{e.array}' is {element.value}, node says " + f"{e.type.value}") + return dataclasses.replace(e, index=idx, proven=proven) + + def in_bounds(self, array: str, idx: ir.Expr) -> bool: + """0 <= idx < len(array): idx is the var of an enclosing ForRange over + range(c >= 0, len(array), step > 0) (for a local array, one whose body never assigns it), or + -- for a local array of known length -- idx has an interval inside [0, minimum length - 1]. + The var is never assigned in the body (checked), the stop is evaluated once, and no IR node + changes an array's length.""" + if array in self.lens and getattr(idx, "type", None) is Type.I64: + lo, hi = self.interval(idx) + if 0 <= lo and hi < self.lens[array][0]: + return True + if type(idx) is not Local or not (array in self.arrays or array in self.larrays): + return False + for loop in reversed(self.loops): + if loop.var == idx.name: + return loop.bounds_array == array + return False + + # --- tuples and local arrays -------------------------------------------------------------- + + def e_Tuple(self, e: Tuple, state: _State) -> ir.Expr: + if not isinstance(e.elements, tuple): + self.error(STRUCTURE, "Tuple elements are not a tuple") + return e + elements = tuple(self.obj_operand(x, state, f"element {k + 1} of Tuple") + for k, x in enumerate(e.elements)) + self.result(e, Type.OBJ) + return dataclasses.replace(e, elements=elements) + + def e_NewArray(self, e: NewArray, state: _State) -> ir.Expr: + return self.stray(e, state) + + def e_CopyArray(self, e: CopyArray, state: _State) -> ir.Expr: + return self.stray(e, state) + + def stray(self, e: NewArray | CopyArray, state: _State) -> ir.Expr: + self.error(ESCAPE, f"{type(e).__name__} is only legal as the value of an Assign to a local " + f"array") + return e + + def allocation(self, e: NewArray | CopyArray, target: str, state: _State) -> ir.Expr: + """`e` is the value of `Assign(target, e)`. Checks the operands, and that the node's type + is an array type equal to the target's declared one.""" + name = type(e).__name__ + want = self.larrays.get(target) + if not isinstance(e.type, Type) or e.type not in ir.ARRAYS: + self.error(TYPE, f"{name} gives an array, node says " + f"{getattr(e.type, 'value', e.type)}") + elif want is not None and e.type is not want: + self.error(TYPE, f"{name} gives {e.type.value} but local array '{target}' is " + f"{want.value}") + element = _ELEMENT.get(e.type) + if type(e) is CopyArray: + src = self.arrays.get(e.src) or self.larrays.get(e.src) + if not isinstance(e.src, str) or src is None: + self.error(ARRAY_USE, f"CopyArray of '{e.src}', which is not an array") + else: + self.array(e.src, "copy", state) + if src is not e.type: + self.error(TYPE, f"CopyArray of {src.value} '{e.src}' gives {e.type.value}") + return e + length = self.expr(e.length, state) + self.slot(length, Type.I64, "length of NewArray") + fill = e.fill + if type(e.iota) is not bool: + self.error(TYPE, f"NewArray `iota` {e.iota!r} is not a bool") + elif e.iota: + if e.type is not Type.I64_ARRAY: + self.error(TYPE, "NewArray iota is only for i64 arrays") + if fill is not None: + self.error(TYPE, "NewArray iota takes no fill") + elif fill is None: + self.error(TYPE, "NewArray needs a fill unless it is iota") + if fill is not None: + fill = self.expr(fill, state) + if element is not None: + self.slot(fill, element, "fill of NewArray") + return dataclasses.replace(e, length=length, fill=fill) + + def length_of(self, e: NewArray | CopyArray) -> tuple[int, int]: + """The interval of the length of the array `e` creates (the operands are checked).""" + if type(e) is CopyArray: + return self.lens.get(e.src, (0, _LEN_MAX)) + if getattr(e.length, "type", None) is not Type.I64: + return (0, _LEN_MAX) + lo, hi = self.interval(e.length) + return (min(max(lo, 0), _LEN_MAX), min(max(hi, 0), _LEN_MAX)) + + # --- statements --------------------------------------------------------------------------- + + def block(self, body: object, state: _State) -> tuple[tuple[ir.Stmt, ...], _State]: + if not isinstance(body, tuple): + self.error(STRUCTURE, "a statement block is not a tuple") + return (), state + out = [] + for s in body: + s2, state = self.stmt(s, state) + out.append(s2) + return tuple(out), state + + def stmt(self, s: object, state: _State) -> tuple[ir.Stmt, _State]: + cls = type(s) + if cls not in _STMTS: + self.error(UNKNOWN, f"statement node {cls.__name__} is not defined by ir") + return s, state # type: ignore[return-value] + return getattr(self, "s_" + cls.__name__)(s, state) + + def s_Assign(self, s: Assign, state: _State): + target = s.target + alloc = type(s.value) in (NewArray, CopyArray) + if alloc: + if target in self.larrays: + value = self.allocation(s.value, target, state) + if self.assigns.get(target) == 1: + self.lens[target] = self.length_of(value) + else: + value = self.allocation(s.value, target, state) + self.error(ESCAPE, f"{type(s.value).__name__} is assigned to '{target}', which is " + f"not a local array") + else: + value = self.expr(s.value, state) + if target in self.larrays: + self.error(TYPE, f"local array '{target}' may only be assigned NewArray or " + f"CopyArray") + if target in self.larrays: + pass + elif target in self.arrays: + self.error(ARRAY_USE, f"assignment to array parameter '{target}'") + elif target not in self.scalars: + self.error(TYPE, f"assignment to undeclared local '{target}'") + elif not alloc: + self.slot(value, self.scalars[target], f"assignment to '{target}'") + if getattr(value, "type", None) is Type.NONE: + self.error(TYPE, f"None assigned to '{target}'") + if target in self.entry: + self.error(STRUCTURE, f"entry global '{target}' is assigned in the body") + for loop in self.loops: + if loop.var == target: + self.error(STRUCTURE, f"loop variable '{target}' is assigned in its loop body") + return dataclasses.replace(s, value=value), (None if state is None else state | {target}) + + def s_StoreIndex(self, s: StoreIndex, state: _State): + idx, element, proven = self.index(s.array, s.index, state, "store") + value = self.expr(s.value, state) + if element is not None: + self.slot(value, element, f"value stored into '{s.array}'") + if s.array not in self.larrays: # a local array is the function's own: not an effect + self.stored.add(s.array) + self.effects.append(f"stores into '{s.array}'") + return dataclasses.replace(s, index=idx, value=value, proven=proven), state + + def s_ExprStmt(self, s: ExprStmt, state: _State): + return dataclasses.replace(s, value=self.expr(s.value, state)), state + + def cond(self, c: object, state: _State, what: str) -> ir.Expr: + c2 = self.expr(c, state, cond=True) + self.slot(c2, Type.BOOL, f"condition of {what}") + return c2 + + def s_If(self, s: If, state: _State): + cond = self.cond(s.cond, state, "if") + then, st = self.block(s.then, state) + orelse, se = self.block(s.orelse, state) + return dataclasses.replace(s, cond=cond, then=then, orelse=orelse), _meet(st, se) + + def s_While(self, s: While, state: _State): + cond = self.cond(s.cond, state, "while") + loop = _Loop(None, None) + self.loops.append(loop) + # Assignments only add names, so the loop-head state is the entry state (the meet of the + # entry, end-of-body and continue states equals the entry state). + body, _ = self.block(s.body, state) + self.loops.pop() + forever = type(s.cond) is Const and s.cond.type is Type.BOOL and s.cond.value is True + after: _State = None if forever else state + for br in loop.breaks: + after = _meet(after, br) + return dataclasses.replace(s, cond=cond, body=body), after + + def s_ForRange(self, s: ForRange, state: _State): + start = self.expr(s.start, state) + stop = self.expr(s.stop, state) + self.slot(start, Type.I64, "range start") + self.slot(stop, Type.I64, "range stop") + step = s.step + step_ok = (type(step) is Const and step.type is Type.I64 and type(step.value) is int + and step.value != 0 and _I64_MIN <= step.value <= _I64_MAX) + if not step_ok: + self.error(STRUCTURE, f"range step must be a non-zero i64 Const, got {step!r}") + var = s.var + if var in self.arrays: + self.error(ARRAY_USE, f"loop variable '{var}' is an array parameter") + elif self.scalars.get(var) is not Type.I64: + self.error(TYPE, f"loop variable '{var}' must be a declared i64 local") + if var in self.entry: + self.error(STRUCTURE, f"entry global '{var}' is assigned by the loop") + for outer in self.loops: + if outer.var == var: + self.error(STRUCTURE, f"loop variable '{var}' is assigned in its loop body " + f"(by an inner loop)") + bounds = None + if (step_ok and step.value > 0 and type(start) is Const and start.type is Type.I64 + and type(start.value) is int and start.value >= 0 and type(stop) is Len + and (stop.array in self.arrays + or (stop.array in self.larrays + and stop.array not in _assigned(s.body)))): + bounds = stop.array + loop = _Loop(var, bounds) + if (step_ok and getattr(start, "type", None) is Type.I64 + and getattr(stop, "type", None) is Type.I64): + (slo, shi), (elo, ehi) = self.interval(start), self.interval(stop) + if step.value > 0: + lo = slo + hi = max(lo, ehi - 1) + else: + hi = shi + lo = min(hi, elo + 1) + loop.interval = (max(lo, _I64_MIN), min(hi, _I64_MAX)) + self.loops.append(loop) + body, _ = self.block(s.body, None if state is None else state | {var}) + self.loops.pop() + # Zero iterations leave `var` (and everything the body assigns) as it was: the state after + # the loop is the entry state, met with every break state (each a superset of it). + after: _State = state + for br in loop.breaks: + after = _meet(after, br) + return dataclasses.replace(s, start=start, stop=stop, body=body), after + + def s_Return(self, s: Return, state: _State): + returns = self.fn.returns + if s.value is None: + if returns is not Type.NONE: + self.error(TYPE, f"return without a value in a function returning " + f"{getattr(returns, 'value', returns)}") + return s, None + value = self.expr(s.value, state) + if isinstance(returns, Type): + self.slot(value, returns, "return value") + return dataclasses.replace(s, value=value), None + + def _jump(self, s: Break | Continue, state: _State): + if not self.loops: + self.error(STRUCTURE, f"{type(s).__name__.lower()} outside a loop") + elif type(s) is Break: + self.loops[-1].breaks.append(state) + return s, None + + s_Break = _jump + s_Continue = _jump + + # --- the function ------------------------------------------------------------------------- + + def run(self) -> Function: + fn = self.fn + params = self.declarations() + if self.larrays: + counts: dict[str, int] = {} + _assigned(fn.body, counts) + self.assigns = {n: counts.get(n, 0) for n in self.larrays} + body, end = self.block(fn.body, params) + if end is not None and fn.returns is not Type.NONE: + self.error(MISSING_RETURN, f"the end of '{fn.name}' is reachable but it returns " + f"{getattr(fn.returns, 'value', fn.returns)}") + # deopt placement and flags + if self.can_deopt and fn.pure is not True: + self.error(DEOPT, "a node that can deopt (checked i64 arithmetic, i64 negation, Unbox to " + "i64, or a call of a may_deopt function) is in an impure function; a redo " + "would repeat its effects") + if fn.may_deopt is not self.can_deopt: + if fn.pure is True or fn.may_deopt is True: + self.error(FLAG_DEOPT, f"may_deopt is {fn.may_deopt} but the body " + f"{'can' if self.can_deopt else 'cannot'} deopt") + if fn.pure is True and self.effects: + self.error(FLAG_PURE, "pure is set but the body " + "; ".join(sorted(set(self.effects)))) + for p in fn.params: + if type(p) is ArrayParam and p.name in self.arrays and p.stored is not (p.name in + self.stored): + self.error(ARRAY_STORED, f"array '{p.name}' has stored={p.stored} but the body " + f"{'stores' if p.name in self.stored else 'never stores'}" + f" into it") + return dataclasses.replace(fn, body=body) + + +def _assigned(body: object, counts: dict[str, int] | None = None) -> dict[str, int]: + """Every name assigned by an Assign statement anywhere in `body` (nested blocks included), with + the number of Assign statements. Tolerates a malformed body (the checker reports it).""" + counts = {} if counts is None else counts + if isinstance(body, tuple): + for st in body: + cls = type(st) + if cls is Assign: + counts[st.target] = counts.get(st.target, 0) + 1 + elif cls is If: + _assigned(st.then, counts) + _assigned(st.orelse, counts) + elif cls in (While, ForRange): + _assigned(st.body, counts) + return counts + + +def _exact(op: BinOpKind, a: tuple[int, int], b: tuple[int, int]) -> tuple[int, int]: + """The exact (unbounded) result interval of ADD/SUB/MUL on operand intervals.""" + if op is BinOpKind.ADD: + return a[0] + b[0], a[1] + b[1] + if op is BinOpKind.SUB: + return a[0] - b[1], a[1] - b[0] + products = (a[0] * b[0], a[0] * b[1], a[1] * b[0], a[1] * b[1]) + return min(products), max(products) + + +def verify(module: Module) -> tuple[Module, list[Diagnostic]]: + """Prove each function of `module`; return the module of proved functions (with proven bounds + marked) and every diagnostic. Rejected functions move to `skipped` with the reason.""" + diags: list[Diagnostic] = [] + reasons: dict[str, str] = {} + functions = module.functions if isinstance(module.functions, tuple) else () + + def reject(fn_name: str, line: int, rule: str, message: str) -> None: + d = Diagnostic(fn_name, rule, message, line) + if d not in diags: + diags.append(d) + reasons.setdefault(fn_name, f"{rule}: {message}") + + named: dict[str, list[Function]] = {} + for g in functions: + if type(g) is not Function: + reject(getattr(g, "name", repr(g)), getattr(g, "source_line", 0), UNKNOWN, + f"{type(g).__name__} is not an ir Function") + continue + named.setdefault(g.name, []).append(g) + for name, gs in named.items(): + if len(gs) > 1: + for g in gs: + reject(name, g.source_line, STRUCTURE, f"function '{name}' is defined " + f"{len(gs)} times") + sigs = {name: gs[0] for name, gs in named.items() if len(gs) == 1} + + checked: dict[str, Function] = {} + callees: dict[str, set[str]] = {} + open_why: dict[str, str] = {} # a function that runs user code -> why + for name, g in sigs.items(): + c = _Checker(g, sigs) + rebuilt = c.run() + for d in c.diags: + reject(d.function, d.source_line, d.rule, d.message) + checked[name] = rebuilt + callees[name] = c.callees + if c.opens: + open_why[name] = c.opens[0] + + # Closedness (entry_globals): a fixpoint over the call graph. + changed = True + while changed: + changed = False + for name, cs in callees.items(): + if name not in open_why: + bad = sorted(c for c in cs if c in open_why) + if bad: + open_why[name] = f"calls '{bad[0]}', which runs user code ({open_why[bad[0]]})" + changed = True + for name, g in sigs.items(): + if name in open_why and isinstance(g.entry_globals, tuple) and g.entry_globals: + reject(name, g.source_line, ENTRY_OPEN, + f"has entry globals but is not closed: {open_why[name]}") + + # A call cycle (direct or mutual recursion) is legal: every compiled impl function counts its + # frame at entry (tp_enter_call), so recursion ends in RecursionError where CPython raises it + # and the C stack cannot overflow (issue #57). Purity and may_deopt flow through the cycle as + # through any Call: each flag is proved on the function's own body against its callees' + # declared flags, so a cycle through a may_deopt callee is legal only in a pure function. + + # A call into a function that is not compiled cannot be a C call: propagate to a fixpoint. + changed = True + while changed: + changed = False + for name, cs in callees.items(): + if name in reasons: + continue + bad = sorted(c for c in cs if c in reasons) + if bad: + reject(name, sigs[name].source_line, UNVERIFIED, + f"calls '{bad[0]}', which is not compiled ({reasons[bad[0]]})") + changed = True + + kept = tuple(checked[g.name] for g in functions + if type(g) is Function and g.name in checked and g.name not in reasons) + skipped = dict(module.skipped) + for name, reason in reasons.items(): + skipped.setdefault(name, reason) + return Module(module.name, kept, skipped), diags diff --git a/python-multiplatform-ksp/src/main/python/typedpython_builtins.pth b/python-multiplatform-ksp/src/main/python/typedpython_builtins.pth new file mode 100644 index 000000000..98776970a --- /dev/null +++ b/python-multiplatform-ksp/src/main/python/typedpython_builtins.pth @@ -0,0 +1 @@ +import builtins; builtins.compiled = getattr(builtins, "compiled", lambda f, /: f) diff --git a/python-multiplatform-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/python-multiplatform-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 000000000..1014b899a --- /dev/null +++ b/python-multiplatform-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +python.multiplatform.ksp.PythonBindingProcessorProvider diff --git a/python-multiplatform-ksp/src/test/kotlin/python/multiplatform/ksp/SourceRenderingTest.kt b/python-multiplatform-ksp/src/test/kotlin/python/multiplatform/ksp/SourceRenderingTest.kt new file mode 100644 index 000000000..e6961790b --- /dev/null +++ b/python-multiplatform-ksp/src/test/kotlin/python/multiplatform/ksp/SourceRenderingTest.kt @@ -0,0 +1,396 @@ +package python.multiplatform.ksp + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SourceRenderingTest { + + @Test + fun fragmentSourceImplementsTheRuntimeInterfaceAndCarriesTheModuleName() { + val model = FragmentModel( + moduleName = "my_lib", + entries = listOf( + CallableEntryModel( + name = "my.lib.greet", + arity = 1, + paramTags = listOf(Tag.STRING), + returnTag = Tag.STRING, + kind = "FUNCTION", + lambdaBody = "{ args -> greet(args[0] as String) }", + ), + ), + classes = emptyList(), + ) + + val src = renderFragmentSource(model) + + assertTrue(src.contains("package python.multiplatform.generated.fragments")) + assertTrue(src.contains("object Fragment_my_lib : python.multiplatform.reflection.FunctionTableFragment")) + assertTrue(src.contains("override val moduleName: String = \"my_lib\"")) + assertTrue(src.contains("name = \"my.lib.greet\"")) + assertTrue(src.contains("{ args -> greet(args[0] as String) }")) + // no fragment is public API for a user to call directly; but it must not be `internal`, + // or the app module's generated aggregator cannot reference it across the module + // boundary. See renderFragmentSource's doc comment. + assertTrue(!src.contains("internal object Fragment_my_lib")) + } + + @Test + fun fragmentSourceOmitsTraverseArgumentForClassesWithoutPythonFields() { + val model = FragmentModel( + moduleName = "my_lib", + entries = emptyList(), + classes = listOf( + ClassModel(name = "my.lib.Counter", memberNames = listOf("my.lib.Counter."), traverseBody = null), + ), + ) + + val src = renderFragmentSource(model) + + assertTrue(src.contains("name = \"my.lib.Counter\"")) + assertTrue(!src.contains("traverse =")) + } + + @Test + fun fragmentSourceIncludesTraverseArgumentForClassesWithPythonFields() { + val model = FragmentModel( + moduleName = "my_lib", + entries = emptyList(), + classes = listOf( + ClassModel( + name = "my.lib.RefHolder", + memberNames = listOf("my.lib.RefHolder."), + traverseBody = "{ obj, visit -> (obj as RefHolder).field?.let { visit(it.pointer.toRawValue()) } }", + ), + ), + ) + + val src = renderFragmentSource(model) + + assertTrue(src.contains("traverse = { obj, visit ->")) + } + + @Test + fun aggregatorSourceReferencesEveryDiscoveredFragmentExplicitly() { + val src = renderAggregatorSource( + listOf( + "python.multiplatform.generated.fragments.Fragment_library_module", + "python.multiplatform.generated.fragments.Fragment_app_module", + ), + ) + + assertTrue(src.contains("package python.multiplatform.generated")) + assertTrue(src.contains("object FunctionTable")) + assertTrue(src.contains("python.multiplatform.generated.fragments.Fragment_library_module,")) + assertTrue(src.contains("python.multiplatform.generated.fragments.Fragment_app_module,")) + assertTrue(src.contains("UpcallTable.install(fragments)")) + } + + @Test + fun aggregatorSourceWithNoFragmentsStillProducesValidSyntaxShape() { + val src = renderAggregatorSource(emptyList()) + + assertTrue(src.contains("val fragments: List = listOf(\n )")) + } + + @Test + fun fragmentSourceSuppressesUncheckedCastsBecauseEveryArgumentReadIsOne() { + // Reading `args[0] as List` out of an `Array` is unchecked by + // construction; without the file-level suppression every generated fragment with a + // generic parameter buries the build in warnings. + val src = renderFragmentSource(FragmentModel("my_lib", emptyList(), emptyList())) + + assertTrue(src.startsWith("@file:Suppress(\"UNCHECKED_CAST\")")) + } + + @Test + fun classSourceCarriesTheKotlinShapeAndAnEnumsEntryNames() { + // An interface has no constructor and an enum has a fixed instance set: the Python side + // picks a different proxy shape for each, and cannot infer it from member names. + val model = FragmentModel( + moduleName = "my_lib", + entries = emptyList(), + classes = listOf( + ClassModel( + name = "my.lib.Greeter", + memberNames = listOf("my.lib.Greeter.greet"), + traverseBody = null, + kind = "INTERFACE", + ), + ClassModel( + name = "my.lib.Color", + memberNames = listOf("my.lib.Color.RED"), + traverseBody = null, + kind = "ENUM", + enumEntryNames = listOf("RED", "GREEN"), + ), + ), + ) + + val src = renderFragmentSource(model) + + assertTrue(src.contains("kind = python.multiplatform.reflection.ReflectedClassKind.INTERFACE")) + assertTrue(src.contains("kind = python.multiplatform.reflection.ReflectedClassKind.ENUM")) + assertTrue(src.contains("enumEntryNames = listOf(\"RED\", \"GREEN\")")) + } + + @Test + fun anOrdinaryClassRendersTheDefaultKindAndNoEntryNames() { + val src = renderFragmentSource( + FragmentModel( + moduleName = "my_lib", + entries = emptyList(), + classes = listOf(ClassModel("my.lib.Counter", listOf("my.lib.Counter."), null)), + ), + ) + + assertTrue(src.contains("kind = python.multiplatform.reflection.ReflectedClassKind.CLASS")) + assertTrue(!src.contains("enumEntryNames")) + } + + // -------------------------------------------------------------- the declaration metadata + // + // `fbab1a68` put `returnTypeName` on `ExposedCallable` as the gate that lets + // `PythonProxySource` own a `TypeTag.OBJECT` result: `OBJECT` also covers a `PyObject` that + // happens to be an `int`, and owning one of those would release a handle nobody issued, so + // ownership is granted only when the producer names the Kotlin type. The walker supplies the + // name; at that commit KSP did not, which is why `WalkedArtifactComposeModifierTest`'s + // `emptyModifier()` -- a KSP entry -- leaked one handle per call while the walked links beside + // it did not. These tests pin the renderer's half of the fix: given a model that carries the + // metadata, the generated source must say so. + + @Test + fun aReturnTypeNameOnTheModelIsRenderedOnTheEntry() { + val model = FragmentModel( + moduleName = "my_lib", + entries = listOf( + CallableEntryModel( + name = "my.lib.Counter.", + arity = 1, + paramTags = listOf(Tag.INT), + returnTag = Tag.OBJECT, + kind = "CONSTRUCTOR", + lambdaBody = "{ args -> Counter((args[0] as Long)) }", + returnTypeName = "my.lib.Counter", + ), + ), + classes = emptyList(), + ) + + val src = renderFragmentSource(model) + + assertTrue( + src.contains("returnTypeName = \"my.lib.Counter\","), + "the gate PythonProxySource reads must be rendered, or a KSP-generated constructor " + + "result stays a bare, leaking handle:\n$src", + ) + } + + @Test + fun aNullReturnTypeNameIsNotRenderedAtAll() { + // Every entry in every fragment would otherwise carry `returnTypeName = null,`, which + // means nothing beyond what the runtime default already says. + val src = renderFragmentSource( + FragmentModel( + moduleName = "my_lib", + entries = listOf( + CallableEntryModel("my.lib.greet", 0, emptyList(), Tag.STRING, "FUNCTION", "{ greet() }"), + ), + classes = emptyList(), + ), + ) + + assertTrue(!src.contains("returnTypeName")) + } + + @Test + fun paramNamesAndParamTypeNamesOnTheModelAreRenderedOnTheEntry() { + val model = FragmentModel( + moduleName = "my_lib", + entries = listOf( + CallableEntryModel( + name = "my.lib.scale", + arity = 2, + paramTags = listOf(Tag.INT, Tag.INT), + returnTag = Tag.INT, + kind = "FUNCTION", + lambdaBody = "{ args -> scale((args[0] as Long).toInt(), (args[1] as Long).toInt()).toLong() }", + paramNames = listOf("value", "by"), + paramTypeNames = listOf("kotlin.Int", "kotlin.Int"), + ), + ), + classes = emptyList(), + ) + + val src = renderFragmentSource(model) + + assertTrue(src.contains("paramNames = listOf(\"value\", \"by\"),")) + assertTrue(src.contains("paramTypeNames = listOf(\"kotlin.Int\", \"kotlin.Int\"),")) + } + + @Test + fun emptyParamNamesAndParamTypeNamesAreNotRenderedAtAll() { + val src = renderFragmentSource( + FragmentModel( + moduleName = "my_lib", + entries = listOf( + CallableEntryModel("my.lib.greet", 0, emptyList(), Tag.STRING, "FUNCTION", "{ greet() }"), + ), + classes = emptyList(), + ), + ) + + assertTrue(!src.contains("paramNames")) + assertTrue(!src.contains("paramTypeNames")) + } + + @Test + fun paramHasDefaultOnTheModelIsRenderedOnTheEntry() { + val model = FragmentModel( + moduleName = "my_lib", + entries = listOf( + CallableEntryModel( + name = "my.lib.pad", + arity = 2, + paramTags = listOf(Tag.INT, Tag.INT), + returnTag = Tag.INT, + kind = "FUNCTION", + lambdaBody = "{ args -> pad((args[0] as Long), (args[1] as Long)) }", + paramHasDefault = listOf(false, true), + ), + ), + classes = emptyList(), + ) + + val src = renderFragmentSource(model) + + assertTrue(src.contains("paramHasDefault = listOf(false, true),")) + } + + @Test + fun emptyParamHasDefaultIsNotRenderedAtAll() { + val src = renderFragmentSource( + FragmentModel( + moduleName = "my_lib", + entries = listOf( + CallableEntryModel("my.lib.greet", 0, emptyList(), Tag.STRING, "FUNCTION", "{ greet() }"), + ), + classes = emptyList(), + ), + ) + + assertTrue(!src.contains("paramHasDefault")) + } + + // ------------------------------------------------------------------------ the suspend flag + + @Test + fun aSuspendingEntryCarriesTheFlagTheTrampolineBranchesOn() { + // `docs/design/upcall.md` §5.2: the trampoline has to know, before it looks at the + // result, whether what came back is the value or a PendingCall. The flag is separate from + // `kind` because the two are orthogonal -- a suspending *method* still has its receiver in + // args[0], and doubling CallableKind would have made every hasReceiver decision restate it. + val model = FragmentModel( + moduleName = "my_lib", + entries = listOf( + CallableEntryModel( + name = "my.lib.fetch", + arity = 1, + paramTags = listOf(Tag.STRING), + returnTag = Tag.STRING, + kind = "FUNCTION", + lambdaBody = + "{ args -> python.multiplatform.ffi.upcall.PendingCall.start { fetch(args[0] as String) } }", + isSuspend = true, + ), + ), + classes = emptyList(), + ) + + val src = renderFragmentSource(model) + + assertTrue(src.contains("isSuspend = true")) + // The declared return type, not the PendingCall: the fast path marshals the real value + // with this tag, and the Future's set_result uses the same one. + assertTrue(src.contains("returnType = python.multiplatform.reflection.TypeTag.STRING")) + assertTrue(src.contains("python.multiplatform.ffi.upcall.PendingCall.start {")) + } + + @Test + fun anOrdinaryEntryDoesNotMentionTheSuspendFlagAtAll() { + // Every entry in every fragment would otherwise carry `isSuspend = false`; the default on + // the runtime class keeps generated source the size it was. + val src = renderFragmentSource( + FragmentModel( + moduleName = "my_lib", + entries = listOf( + CallableEntryModel("my.lib.greet", 0, emptyList(), Tag.STRING, "FUNCTION", "{ greet() }"), + ), + classes = emptyList(), + ), + ) + + assertTrue(!src.contains("isSuspend")) + } + + @Test + fun duplicateEntryNamesAreDroppedBeforeTheyReachTheTable() { + // Legal Kotlin: `class Foo { val x = 1; companion object { val x = 2 } }` yields two + // entries called `pkg.Foo.x` once companion members are folded into the owner. Two + // entries under one name make UpcallTable.resolve return whichever won the race; the + // instance member is the one that keeps its receiver contract, so it wins deliberately. + val instance = CallableEntryModel("p.Foo.x", 0, emptyList(), Tag.INT, "GETTER", "{ args -> 1L }") + val static = CallableEntryModel("p.Foo.x", 0, emptyList(), Tag.INT, "STATIC_GETTER", "{ 2L }") + val other = CallableEntryModel("p.Foo.y", 0, emptyList(), Tag.INT, "GETTER", "{ args -> 3L }") + + val deduped = listOf(instance, static, other).distinctByName() + + assertEquals(listOf("p.Foo.x", "p.Foo.y"), deduped.map { it.name }) + assertEquals("GETTER", deduped[0].kind) + } + + // ------------------------------------------------------------------- the install-table seam + + @Test + fun theInstallSeamActualLandsInTheDeclarationsOwnPackageAndCallsTheAggregator() { + // ROADMAP §13: `FunctionTable` is nameable only from the compilation that generated it, + // so this file is the one place the call can be written -- and it has to land in the + // `expect`'s own package or it does not match it. + val src = renderInstallSeamSource( + InstallSeamModel(packageName = "com.example.app", simpleName = "installTable", isInternal = false), + ) + + assertTrue(src.contains("package com.example.app")) + assertTrue(src.contains("actual fun installTable() {")) + assertTrue(src.contains("python.multiplatform.generated.FunctionTable.installInto()")) + assertTrue(!src.contains("internal actual")) + } + + @Test + fun theInstallSeamActualIsMarkedPythonInternal() { + // It is a public top-level function in the user's own package. Without the annotation, an + // incremental round that hands the scanner its own previous output would expose a + // Python-callable that reinstalls the table underneath its caller. + val src = renderInstallSeamSource( + InstallSeamModel(packageName = "com.example.app", simpleName = "installTable", isInternal = false), + ) + + assertTrue(src.contains("@python.multiplatform.reflection.PythonInternal")) + } + + @Test + fun anInternalSeamGetsAnInternalActualBecauseExpectAndActualMustAgree() { + val src = renderInstallSeamSource( + InstallSeamModel(packageName = "com.example.app", simpleName = "installTable", isInternal = true), + ) + + assertTrue(src.contains("internal actual fun installTable() {")) + } + + @Test + fun seamFileNamesAreDerivedFromTheDeclarationSoTwoSeamsDoNotCollide() { + assertEquals("InstallUpcallTable_installTable", installSeamFileName("installTable")) + assertTrue(installSeamFileName("a") != installSeamFileName("b")) + } +} diff --git a/python-multiplatform-ksp/src/test/kotlin/python/multiplatform/ksp/TypeShapeTest.kt b/python-multiplatform-ksp/src/test/kotlin/python/multiplatform/ksp/TypeShapeTest.kt new file mode 100644 index 000000000..c696837af --- /dev/null +++ b/python-multiplatform-ksp/src/test/kotlin/python/multiplatform/ksp/TypeShapeTest.kt @@ -0,0 +1,103 @@ +package python.multiplatform.ksp + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TypeShapeTest { + + @Test + fun tagMapsThePrimitivesBundledUnderIntAndFloat() { + assertEquals(Tag.INT, tagFor(TypeShape("kotlin.Long", false))) + assertEquals(Tag.INT, tagFor(TypeShape("kotlin.Int", false))) + assertEquals(Tag.INT, tagFor(TypeShape("kotlin.Short", false))) + assertEquals(Tag.INT, tagFor(TypeShape("kotlin.Byte", false))) + assertEquals(Tag.FLOAT, tagFor(TypeShape("kotlin.Double", false))) + assertEquals(Tag.FLOAT, tagFor(TypeShape("kotlin.Float", false))) + } + + @Test + fun tagFallsBackToObjectForAnythingWithoutADedicatedMarshaller() { + assertEquals(Tag.OBJECT, tagFor(TypeShape("com.example.MyClass", false))) + assertEquals(Tag.OBJECT, tagFor(TypeShape("python.multiplatform.ffi.PyObject", true))) + } + + @Test + fun castOfTheBoundaryWidthTypeIsAPlainCast() { + assertEquals("args[0] as Long", castExpression(TypeShape("kotlin.Long", false), "args[0]")) + assertEquals("args[0] as Long?", castExpression(TypeShape("kotlin.Long", true), "args[0]")) + assertEquals("args[0] as String", castExpression(TypeShape("kotlin.String", false), "args[0]")) + } + + @Test + fun castOfANarrowerIntTypeNarrowsAfterCastingToTheBoundaryWidth() { + // The boundary always carries INT-tagged values as Long; a declared `Int` parameter + // needs a narrowing conversion the cast alone does not give it. + assertEquals("(args[0] as Long).toInt()", castExpression(TypeShape("kotlin.Int", false), "args[0]")) + assertEquals("(args[0] as Long?)?.toInt()", castExpression(TypeShape("kotlin.Int", true), "args[0]")) + assertEquals("(args[0] as Long).toShort()", castExpression(TypeShape("kotlin.Short", false), "args[0]")) + assertEquals("(args[0] as Long).toByte()", castExpression(TypeShape("kotlin.Byte", false), "args[0]")) + } + + @Test + fun castOfFloatNarrowsFromTheBoundaryDouble() { + assertEquals("(args[0] as Double).toFloat()", castExpression(TypeShape("kotlin.Float", false), "args[0]")) + assertEquals("(args[0] as Double?)?.toFloat()", castExpression(TypeShape("kotlin.Float", true), "args[0]")) + } + + @Test + fun castOfAnObjectTypeUsesItsQualifiedName() { + assertEquals( + "args[0] as com.example.Counter", + castExpression(TypeShape("com.example.Counter", false), "args[0]"), + ) + assertEquals( + "args[0] as com.example.Counter?", + castExpression(TypeShape("com.example.Counter", true), "args[0]"), + ) + } + + @Test + fun returnWrapWidensNarrowerNumericTypesToTheBoundaryRepresentation() { + assertEquals("(x.increment()).toLong()", wrapReturnExpression(TypeShape("kotlin.Int", false), "x.increment()")) + assertEquals("(x.scale()).toDouble()", wrapReturnExpression(TypeShape("kotlin.Float", false), "x.scale()")) + } + + @Test + fun returnWrapOfUnitAppendsAnExplicitUnitSoTheLambdaTypeChecks() { + assertEquals("x.reset(); Unit", wrapReturnExpression(TypeShape("kotlin.Unit", false), "x.reset()")) + } + + @Test + fun returnWrapOfTheBoundaryWidthOrObjectTypesPassesThrough() { + assertEquals("x.add()", wrapReturnExpression(TypeShape("kotlin.Long", false), "x.add()")) + assertEquals("x.build()", wrapReturnExpression(TypeShape("com.example.Widget", false), "x.build()")) + } + + @Test + fun castOfAGenericTypeKeepsItsTypeArgumentsOrTheGeneratedCodeDoesNotCompile() { + // `args[0] as kotlin.collections.List` is not valid Kotlin -- "One type argument + // expected". Observed as a real compile failure of a generated fragment before the + // renderer carried arguments through. + val listOfString = TypeShape( + qualifiedName = "kotlin.collections.List", + nullable = false, + rendered = "kotlin.collections.List", + ) + assertEquals("args[0] as kotlin.collections.List", castExpression(listOfString, "args[0]")) + + val nullableMap = TypeShape( + qualifiedName = "kotlin.collections.Map", + nullable = true, + rendered = "kotlin.collections.Map", + ) + assertEquals( + "args[0] as kotlin.collections.Map?", + castExpression(nullableMap, "args[0]"), + ) + } + + @Test + fun shapeWithoutAnExplicitRenderingFallsBackToItsQualifiedName() { + assertEquals("com.example.Counter", TypeShape("com.example.Counter", false).rendered) + } +} diff --git a/python-multiplatform-ksp/src/test/python/conftest.py b/python-multiplatform-ksp/src/test/python/conftest.py new file mode 100644 index 000000000..f04ff2974 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/conftest.py @@ -0,0 +1,48 @@ +from pathlib import Path +import textwrap + +import pytest + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def write(tmp_path): + """Write a dedented source file under tmp_path and return its path.""" + + def _write(name: str, source: str) -> Path: + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(source)) + return path + + return _write + + +def rules(diagnostics): + return sorted({d.rule for d in diagnostics}) + + +def errors(diagnostics): + return [d for d in diagnostics if d.severity == "error"] + + +# TP_SANITIZE=1 (tools/sanitizers.py): every extension TypedPython builds gets ASan + UBSan, and a +# sanitizer report fails the run (-fno-sanitize-recover=undefined; ASan halts). Nothing else changes. +import os # noqa: E402 + +if os.environ.get("TP_SANITIZE") == "1": + import sys as _sys + from typedpython import cbuild as _cbuild + + _SAN = ["-fsanitize=address,undefined", "-fno-sanitize-recover=undefined", "-g", + "-fno-omit-frame-pointer"] + _orig_compiler = _cbuild.compiler + + def _sanitized_compiler(): + cmd = _orig_compiler() + _SAN + if _sys.platform.startswith("linux"): # keep the ASan preload out of the compiler + cmd = ["env", "-u", "LD_PRELOAD"] + cmd + return cmd + + _cbuild.compiler = _sanitized_compiler diff --git a/python-multiplatform-ksp/src/test/python/fixtures/deps/untyped_dep.py b/python-multiplatform-ksp/src/test/python/fixtures/deps/untyped_dep.py new file mode 100644 index 000000000..af73da304 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/fixtures/deps/untyped_dep.py @@ -0,0 +1,7 @@ +# Stands in for a third-party package without stubs. It is deliberately unannotated: the gate +# must not report anything inside it, only where its untyped results flow into user code. +import json + + +def load(text): + return json.loads(text) diff --git a/python-multiplatform-ksp/src/test/python/runtime_shim/tp_shim.c b/python-multiplatform-ksp/src/test/python/runtime_shim/tp_shim.c new file mode 100644 index 000000000..2b6b07221 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/runtime_shim/tp_shim.c @@ -0,0 +1,611 @@ +/* Test extension: exposes every tp_runtime.h helper to Python so test_runtime.py can compare it + * with CPython. Every function returns (rc, value); rc == -1 raises the Python error the helper + * set. A helper that breaks the return convention (rc -1 with no error set, rc 0/1 with an error + * set) raises AssertionError, so convention violations cannot hide. */ +#define PY_SSIZE_T_CLEAN +#include +#include "tp_runtime.h" + +#ifndef TP_SHIM_NAME +#define TP_SHIM_NAME tp_shim +#endif +#define TP_CAT2(a, b) a##b +#define TP_CAT(a, b) TP_CAT2(a, b) +#define TP_STR2(a) #a +#define TP_STR(a) TP_STR2(a) + +static int rc_ok(int rc) +{ + if (rc < 0 && !PyErr_Occurred()) { + PyErr_SetString(PyExc_AssertionError, "rc < 0 but no Python error is set"); + return 0; + } + if (rc < 0) { + return 0; + } + if (PyErr_Occurred()) { + PyErr_Format(PyExc_AssertionError, "rc == %d but a Python error is set", rc); + return 0; + } + return 1; +} + +static PyObject *ret_none(int rc) { return Py_BuildValue("(iO)", rc, Py_None); } + +/* ---- int64 ---- */ +#define I64_BIN(OP) \ + static PyObject *s_##OP##_i64(PyObject *self, PyObject *args) \ + { \ + long long a, b; \ + int64_t out = 0; \ + if (!PyArg_ParseTuple(args, "LL", &a, &b)) return NULL; \ + int rc = tp_##OP##_i64((int64_t)a, (int64_t)b, &out); \ + if (!rc_ok(rc)) return NULL; \ + if (rc) return ret_none(rc); \ + return Py_BuildValue("(iL)", rc, (long long)out); \ + } +I64_BIN(add) +I64_BIN(sub) +I64_BIN(mul) +I64_BIN(floordiv) +I64_BIN(mod) + +static PyObject *s_neg_i64(PyObject *self, PyObject *args) +{ + long long a; + int64_t out = 0; + if (!PyArg_ParseTuple(args, "L", &a)) return NULL; + int rc = tp_neg_i64((int64_t)a, &out); + if (!rc_ok(rc)) return NULL; + if (rc) return ret_none(rc); + return Py_BuildValue("(iL)", rc, (long long)out); +} + +static PyObject *s_truediv_i64(PyObject *self, PyObject *args) +{ + long long a, b; + double out = 0; + if (!PyArg_ParseTuple(args, "LL", &a, &b)) return NULL; + int rc = tp_truediv_i64((int64_t)a, (int64_t)b, &out); + if (!rc_ok(rc)) return NULL; + if (rc) return ret_none(rc); + return Py_BuildValue("(id)", rc, out); +} + +static PyObject *s_i64_to_f64(PyObject *self, PyObject *args) +{ + long long a; + if (!PyArg_ParseTuple(args, "L", &a)) return NULL; + return PyFloat_FromDouble(tp_i64_to_f64((int64_t)a)); +} + +/* ---- float ---- */ +#define F64_BIN(OP) \ + static PyObject *s_##OP##_f64(PyObject *self, PyObject *args) \ + { \ + double a, b, out = 0; \ + if (!PyArg_ParseTuple(args, "dd", &a, &b)) return NULL; \ + int rc = tp_##OP##_f64(a, b, &out); \ + if (!rc_ok(rc)) return NULL; \ + if (rc) return ret_none(rc); \ + return Py_BuildValue("(id)", rc, out); \ + } +F64_BIN(truediv) +F64_BIN(floordiv) +F64_BIN(mod) + +static PyObject *s_cmp_i64_f64(PyObject *self, PyObject *args) +{ + long long a; + double b; + int op; + if (!PyArg_ParseTuple(args, "Ldi", &a, &b, &op)) return NULL; + return PyLong_FromLong(tp_cmp_i64_f64((int64_t)a, b, op)); +} + +static PyObject *s_cmp_f64_i64(PyObject *self, PyObject *args) +{ + double a; + long long b; + int op; + if (!PyArg_ParseTuple(args, "dLi", &a, &b, &op)) return NULL; + return PyLong_FromLong(tp_cmp_f64_i64(a, (int64_t)b, op)); +} + +/* ---- math ---- */ +static PyObject *s_math1(PyObject *self, PyObject *args) +{ + const char *name; + double x, out = 0; + int rc; + if (!PyArg_ParseTuple(args, "sd", &name, &x)) return NULL; + if (!strcmp(name, "sqrt")) rc = tp_math_sqrt(x, &out); + else if (!strcmp(name, "exp")) rc = tp_math_exp(x, &out); + else if (!strcmp(name, "log")) rc = tp_math_log(x, &out); + else if (!strcmp(name, "sin")) rc = tp_math_sin(x, &out); + else if (!strcmp(name, "cos")) rc = tp_math_cos(x, &out); + else if (!strcmp(name, "tan")) rc = tp_math_tan(x, &out); + else if (!strcmp(name, "fabs")) rc = tp_math_fabs(x, &out); + else { PyErr_SetString(PyExc_ValueError, "unknown math1"); return NULL; } + if (!rc_ok(rc)) return NULL; + if (rc) return ret_none(rc); + return Py_BuildValue("(id)", rc, out); +} + +static PyObject *s_math2(PyObject *self, PyObject *args) +{ + const char *name; + double x, y, out = 0; + int rc; + if (!PyArg_ParseTuple(args, "sdd", &name, &x, &y)) return NULL; + if (!strcmp(name, "atan2")) rc = tp_math_atan2(x, y, &out); + else if (!strcmp(name, "hypot")) rc = tp_math_hypot(x, y, &out); + else { PyErr_SetString(PyExc_ValueError, "unknown math2"); return NULL; } + if (!rc_ok(rc)) return NULL; + if (rc) return ret_none(rc); + return Py_BuildValue("(id)", rc, out); +} + +/* ---- boxing ---- */ +static PyObject *s_box_i64(PyObject *self, PyObject *args) +{ + long long v; + if (!PyArg_ParseTuple(args, "L", &v)) return NULL; + return tp_box_i64((int64_t)v); +} +static PyObject *s_box_f64(PyObject *self, PyObject *args) +{ + double v; + if (!PyArg_ParseTuple(args, "d", &v)) return NULL; + return tp_box_f64(v); +} +static PyObject *s_box_bool(PyObject *self, PyObject *args) +{ + int v; + if (!PyArg_ParseTuple(args, "i", &v)) return NULL; + return tp_box_bool(v); +} +static PyObject *s_unbox_i64(PyObject *self, PyObject *o) +{ + int64_t out = 0; + int rc = tp_unbox_i64(o, &out); + if (!rc_ok(rc)) return NULL; + if (rc) return ret_none(rc); + return Py_BuildValue("(iL)", rc, (long long)out); +} +static PyObject *s_unbox_f64(PyObject *self, PyObject *o) +{ + double out = 0; + int rc = tp_unbox_f64(o, &out); + if (!rc_ok(rc)) return NULL; + if (rc) return ret_none(rc); + return Py_BuildValue("(id)", rc, out); +} +static PyObject *s_unbox_bool(PyObject *self, PyObject *o) +{ + int out = 0; + int rc = tp_unbox_bool(o, &out); + if (!rc_ok(rc)) return NULL; + if (rc) return ret_none(rc); + return Py_BuildValue("(ii)", rc, out); +} + +/* ---- arrays ---- + * array_run(list, script) -> (enter_rc, results, exit_rc, exit_error) + * script items: ("get", i) | ("set", i, v) | ("len",) | ("dirty",) | ("clear",) + * A failing "get"/"set" slot lookup appends the exception instance. The struct is filled with + * garbage before enter, so an enter that leaves a field unset shows up. */ +#define ARRAY_RUN(SUF, STRUCT, CTYPE, FROMC, PARSEFMT, PARSET) \ + static PyObject *s_##SUF##_array_run(PyObject *self, PyObject *args) \ + { \ + PyObject *obj, *script; \ + if (!PyArg_ParseTuple(args, "OO", &obj, &script)) return NULL; \ + STRUCT a; \ + memset(&a, 0xAB, sizeof a); \ + int erc = tp_##SUF##_array_enter(obj, &a); \ + if (!rc_ok(erc)) return NULL; \ + PyObject *results = PyList_New(0); \ + if (!results) { tp_##SUF##_array_exit(&a); return NULL; } \ + if (erc == 0) { \ + Py_ssize_t n = PyList_GET_SIZE(script); \ + for (Py_ssize_t k = 0; k < n; k++) { \ + PyObject *op = PyList_GET_ITEM(script, k); \ + const char *kind = PyUnicode_AsUTF8(PyTuple_GET_ITEM(op, 0)); \ + PyObject *item = NULL; \ + if (!strcmp(kind, "get") || !strcmp(kind, "set")) { \ + long long i = PyLong_AsLongLong(PyTuple_GET_ITEM(op, 1)); \ + int store = kind[0] == 's'; \ + Py_ssize_t slot = -7; \ + int rc = tp_##SUF##_array_slot(&a, (int64_t)i, store, &slot); \ + if (rc < 0) { \ + item = PyErr_GetRaisedException(); \ + } else if (rc != 0 || PyErr_Occurred()) { \ + PyErr_SetString(PyExc_AssertionError, "slot rc not 0 / -1"); \ + Py_DECREF(results); tp_##SUF##_array_exit(&a); return NULL; \ + } else if (!store) { \ + item = FROMC(a.data[slot]); \ + } else { \ + PARSET v; \ + if (!PyArg_Parse(PyTuple_GET_ITEM(op, 2), PARSEFMT, &v)) { \ + Py_DECREF(results); tp_##SUF##_array_exit(&a); return NULL; \ + } \ + a.data[slot] = (CTYPE)v; \ + a.dirty[slot] = 1; \ + item = Py_NewRef(Py_None); \ + } \ + } else if (!strcmp(kind, "len")) { \ + item = PyLong_FromSsize_t(a.len); \ + } else if (!strcmp(kind, "dirty")) { \ + item = PyBytes_FromStringAndSize((const char *)a.dirty, a.len); \ + } else if (!strcmp(kind, "clear")) { \ + if (PyList_SetSlice(a.list, 0, PyList_GET_SIZE(a.list), NULL) < 0) { \ + Py_DECREF(results); tp_##SUF##_array_exit(&a); return NULL; \ + } \ + item = Py_NewRef(Py_None); \ + } \ + if (!item || PyList_Append(results, item) < 0) { \ + Py_XDECREF(item); Py_DECREF(results); \ + tp_##SUF##_array_exit(&a); \ + return NULL; \ + } \ + Py_DECREF(item); \ + } \ + } \ + int xrc = tp_##SUF##_array_exit(&a); \ + PyObject *xerr = NULL; \ + if (xrc < 0) { \ + if (!PyErr_Occurred()) { \ + PyErr_SetString(PyExc_AssertionError, "exit rc -1 without error"); \ + Py_DECREF(results); return NULL; \ + } \ + xerr = PyErr_GetRaisedException(); \ + } else if (PyErr_Occurred()) { \ + Py_DECREF(results); return NULL; \ + } \ + /* the struct must be unusable-but-safe afterwards: a second exit is a no-op */ \ + int again = tp_##SUF##_array_exit(&a); \ + PyObject *ret = Py_BuildValue("(iNiNi)", erc, results, xrc, \ + xerr ? xerr : Py_NewRef(Py_None), again); \ + return ret; \ + } +ARRAY_RUN(f64, tp_f64_array, double, PyFloat_FromDouble, "d", double) +ARRAY_RUN(i64, tp_i64_array, int64_t, PyLong_FromLongLong, "L", long long) + +static PyObject *s_array_exit_zeroed(PyObject *self, PyObject *arg) +{ + int which = (int)PyLong_AsLong(arg); + int rc; + if (which == 0) { + tp_f64_array a; + memset(&a, 0, sizeof a); + rc = tp_f64_array_exit(&a); + } else { + tp_i64_array a; + memset(&a, 0, sizeof a); + rc = tp_i64_array_exit(&a); + } + if (!rc_ok(rc)) return NULL; + return PyLong_FromLong(rc); +} + + +/* ---- local arrays ---- + * local_run(kind, init, script) -> (rc, error, results) + * kind: "f64" | "i64"; init: ("new", n, fill) | ("iota", n) [i64 only] | ("param", pylist, nocopy) + * ("copy_local", n, fill) / ("copy_param", pylist) build a source, then copy it into the array under test. + * script ops: ("get", i) | ("set", i, v) | ("len",) | ("flags",) -> (list_is_null, dirty_is_null, data_is_null) + * | ("free",) | ("exit",) -> exit rc | ("copy",) -> (list, contents of an independent copy; + * the copy is then set to [i]=v by ("copyset", i, v) which returns (src contents, copy contents)) + * Struct memory is poisoned with 0xAB before every init, so a field left unset shows up. */ +#define LOCAL_RUN(SUF, STRUCT, CTYPE, FROMC, PARSEFMT, PARSET) \ + static int loc_##SUF##_contents(const STRUCT *a, PyObject **out) \ + { \ + PyObject *l = PyList_New(a->len); \ + if (!l) return -1; \ + for (Py_ssize_t i = 0; i < a->len; i++) { \ + PyObject *v = FROMC(a->data[i]); \ + if (!v) { Py_DECREF(l); return -1; } \ + PyList_SET_ITEM(l, i, v); \ + } \ + *out = l; \ + return 0; \ + } \ + static PyObject *s_##SUF##_local_run(PyObject *self, PyObject *args) \ + { \ + PyObject *init, *script; \ + if (!PyArg_ParseTuple(args, "OO", &init, &script)) return NULL; \ + STRUCT a, src; \ + memset(&a, 0xAB, sizeof a); \ + memset(&src, 0xAB, sizeof src); \ + const char *ik = PyUnicode_AsUTF8(PyTuple_GET_ITEM(init, 0)); \ + int rc = 0; \ + int have_src = 0; \ + if (!strcmp(ik, "new")) { \ + long long n = PyLong_AsLongLong(PyTuple_GET_ITEM(init, 1)); \ + PARSET f; \ + if (!PyArg_Parse(PyTuple_GET_ITEM(init, 2), PARSEFMT, &f)) return NULL; \ + rc = tp_##SUF##_array_new(&a, (int64_t)n, (CTYPE)f); \ + } else if (!strcmp(ik, "iota")) { \ + long long n = PyLong_AsLongLong(PyTuple_GET_ITEM(init, 1)); \ + rc = TP_IOTA_##SUF(&a, (int64_t)n); \ + } else { \ + /* copy_local / copy_param: build the source, then copy it */ \ + if (!strcmp(ik, "copy_param")) { \ + int erc = tp_##SUF##_array_enter(PyTuple_GET_ITEM(init, 1), &src); \ + if (erc != 0) { if (!PyErr_Occurred()) PyErr_SetString(PyExc_AssertionError, "enter deopt"); return NULL; } \ + } else { \ + long long n = PyLong_AsLongLong(PyTuple_GET_ITEM(init, 1)); \ + PARSET f; \ + if (!PyArg_Parse(PyTuple_GET_ITEM(init, 2), PARSEFMT, &f)) return NULL; \ + if (tp_##SUF##_array_new(&src, (int64_t)n, (CTYPE)f) != 0) { return NULL; } \ + for (Py_ssize_t i = 0; i < src.len; i++) src.data[i] = (CTYPE)(f + (PARSET)i); \ + } \ + have_src = 1; \ + rc = tp_##SUF##_array_copy(&a, &src); \ + } \ + if (rc != 0) { \ + if (rc != -1 || !PyErr_Occurred()) { \ + PyErr_SetString(PyExc_AssertionError, "local init: rc not 0/-1 or no error"); \ + return NULL; \ + } \ + PyObject *err = PyErr_GetRaisedException(); \ + int zeroed = a.list == NULL && a.len == 0 && a.data == NULL && a.dirty == NULL; \ + if (have_src) tp_##SUF##_array_free(&src); \ + return Py_BuildValue("(iN[N])", rc, err, PyBool_FromLong(zeroed)); \ + } \ + if (PyErr_Occurred()) { PyErr_SetString(PyExc_AssertionError, "error set with rc 0"); return NULL; } \ + PyObject *results = PyList_New(0); \ + Py_ssize_t ns = PyList_GET_SIZE(script); \ + for (Py_ssize_t k = 0; k < ns; k++) { \ + PyObject *op = PyList_GET_ITEM(script, k); \ + const char *kind = PyUnicode_AsUTF8(PyTuple_GET_ITEM(op, 0)); \ + PyObject *item = NULL; \ + if (!strcmp(kind, "get") || !strcmp(kind, "set")) { \ + long long i = PyLong_AsLongLong(PyTuple_GET_ITEM(op, 1)); \ + int store = kind[0] == 's'; \ + Py_ssize_t slot = -7; \ + int r = tp_##SUF##_array_slot(&a, (int64_t)i, store, &slot); \ + if (r < 0) item = PyErr_GetRaisedException(); \ + else if (!store) item = FROMC(a.data[slot]); \ + else { \ + PARSET v; \ + if (!PyArg_Parse(PyTuple_GET_ITEM(op, 2), PARSEFMT, &v)) goto fail; \ + a.data[slot] = (CTYPE)v; \ + item = Py_NewRef(Py_None); \ + } \ + } else if (!strcmp(kind, "len")) { \ + item = PyLong_FromSsize_t(a.len); \ + } else if (!strcmp(kind, "contents")) { \ + if (loc_##SUF##_contents(&a, &item) < 0) goto fail; \ + } else if (!strcmp(kind, "srccontents")) { \ + if (!have_src || loc_##SUF##_contents(&src, &item) < 0) goto fail; \ + } else if (!strcmp(kind, "flags")) { \ + item = Py_BuildValue("(iiin)", a.list == NULL, a.dirty == NULL, a.data == NULL, a.len); \ + } else if (!strcmp(kind, "srcflags")) { \ + item = Py_BuildValue("(iiin)", src.list == NULL, src.dirty == NULL, src.data == NULL, src.len); \ + } else if (!strcmp(kind, "free")) { \ + tp_##SUF##_array_free(&a); \ + item = Py_NewRef(Py_None); \ + } else if (!strcmp(kind, "exit")) { \ + item = PyLong_FromLong(tp_##SUF##_array_exit(&a)); \ + } else if (!strcmp(kind, "srcset")) { \ + long long i = PyLong_AsLongLong(PyTuple_GET_ITEM(op, 1)); \ + PARSET v; \ + if (!have_src || !PyArg_Parse(PyTuple_GET_ITEM(op, 2), PARSEFMT, &v)) goto fail; \ + src.data[i] = (CTYPE)v; \ + item = Py_NewRef(Py_None); \ + } else { \ + PyErr_SetString(PyExc_ValueError, "bad op"); \ + goto fail; \ + } \ + if (!item || PyList_Append(results, item) < 0) { Py_XDECREF(item); goto fail; } \ + Py_DECREF(item); \ + } \ + tp_##SUF##_array_free(&a); \ + if (have_src) tp_##SUF##_array_free(&src); \ + return Py_BuildValue("(iON)", 0, Py_None, results); \ + fail: \ + tp_##SUF##_array_free(&a); \ + if (have_src) tp_##SUF##_array_free(&src); \ + Py_DECREF(results); \ + return NULL; \ + } +static int TP_IOTA_f64(tp_f64_array *a, int64_t n) { (void)a; (void)n; PyErr_SetString(PyExc_ValueError, "no f64 iota"); return -2; } +static int TP_IOTA_i64(tp_i64_array *a, int64_t n) { return tp_i64_array_iota(a, n); } +LOCAL_RUN(f64, tp_f64_array, double, PyFloat_FromDouble, "d", double) +LOCAL_RUN(i64, tp_i64_array, int64_t, PyLong_FromLongLong, "L", long long) + +/* free on a zeroed struct, twice */ +static PyObject *s_local_free_zeroed(PyObject *self, PyObject *arg) +{ + if (PyLong_AsLong(arg) == 0) { + tp_f64_array a; memset(&a, 0, sizeof a); + tp_f64_array_free(&a); tp_f64_array_free(&a); + } else { + tp_i64_array a; memset(&a, 0, sizeof a); + tp_i64_array_free(&a); tp_i64_array_free(&a); + } + Py_RETURN_NONE; +} + +/* copy from a zeroed (never-initialised) source struct: must give an empty local array */ +static PyObject *s_local_copy_of_zeroed(PyObject *self, PyObject *arg) +{ + Py_ssize_t len; + if (PyLong_AsLong(arg) == 0) { + tp_f64_array s0, d; memset(&s0, 0, sizeof s0); memset(&d, 0xAB, sizeof d); + if (tp_f64_array_copy(&d, &s0) != 0) return NULL; + len = d.len; tp_f64_array_free(&d); + } else { + tp_i64_array s0, d; memset(&s0, 0, sizeof s0); memset(&d, 0xAB, sizeof d); + if (tp_i64_array_copy(&d, &s0) != 0) return NULL; + len = d.len; tp_i64_array_free(&d); + } + return PyLong_FromSsize_t(len); +} + +static PyObject *s_tuple(PyObject *self, PyObject *seq) +{ + PyObject *fast = PySequence_Fast(seq, "sequence expected"); + if (!fast) return NULL; + PyObject *t = tp_tuple(PySequence_Fast_ITEMS(fast), PySequence_Fast_GET_SIZE(fast)); + Py_DECREF(fast); + return t; +} + +static PyObject *s_any_same(PyObject *self, PyObject *seq) +{ + PyObject *fast = PySequence_Fast(seq, "sequence expected"); + if (!fast) return NULL; + int r = tp_any_same(PySequence_Fast_ITEMS(fast), PySequence_Fast_GET_SIZE(fast)); + Py_DECREF(fast); + return PyLong_FromLong(r); +} + + +/* ---- opaque objects ---- */ +static PyObject *s_global(PyObject *self, PyObject *args) +{ + PyObject *d, *name; + if (!PyArg_ParseTuple(args, "OO", &d, &name)) return NULL; + return tp_global(d, name); +} +static PyObject *s_getattr(PyObject *self, PyObject *args) +{ + PyObject *o, *name; + if (!PyArg_ParseTuple(args, "OO", &o, &name)) return NULL; + return tp_getattr(o, name); +} +/* call(callee, args_tuple, kwnames_tuple_or_None): the trailing len(kwnames) args are keywords */ +static PyObject *s_call(PyObject *self, PyObject *args) +{ + PyObject *callee, *argt, *kw; + if (!PyArg_ParseTuple(args, "OO!O", &callee, &PyTuple_Type, &argt, &kw)) return NULL; + if (kw != Py_None && (!PyTuple_Check(kw) || PyTuple_GET_SIZE(kw) > PyTuple_GET_SIZE(argt))) { + PyErr_SetString(PyExc_ValueError, "shim: kwnames must be a tuple no longer than args"); + return NULL; + } + /* vectorcall: nargs counts the positional arguments only; the keyword values follow them */ + return tp_call(callee, &PyTuple_GET_ITEM(argt, 0), + (size_t)(PyTuple_GET_SIZE(argt) - (kw == Py_None ? 0 : PyTuple_GET_SIZE(kw))), + kw == Py_None ? NULL : kw); +} +/* call_many: n calls in a row, results dropped (for refcount stability checks) */ +static PyObject *s_call_many(PyObject *self, PyObject *args) +{ + PyObject *callee, *argt; + long n; + if (!PyArg_ParseTuple(args, "OO!l", &callee, &PyTuple_Type, &argt, &n)) return NULL; + long errors = 0; + for (long i = 0; i < n; i++) { + PyObject *r = tp_call(callee, &PyTuple_GET_ITEM(argt, 0), (size_t)PyTuple_GET_SIZE(argt), NULL); + if (r == NULL) { errors++; PyErr_Clear(); } else { tp_release(&r); } + } + return PyLong_FromLong(errors); +} +static PyObject *s_getattr_many(PyObject *self, PyObject *args) +{ + PyObject *o, *name; + long n; + if (!PyArg_ParseTuple(args, "OOl", &o, &name, &n)) return NULL; + long errors = 0; + for (long i = 0; i < n; i++) { + PyObject *r = tp_getattr(o, name); + if (r == NULL) { errors++; PyErr_Clear(); } else { tp_release(&r); } + } + return PyLong_FromLong(errors); +} +static PyObject *s_binop_many(PyObject *self, PyObject *args) +{ + PyObject *a, *b; + int op; + long n; + if (!PyArg_ParseTuple(args, "OOil", &a, &b, &op, &n)) return NULL; + long errors = 0; + for (long i = 0; i < n; i++) { + PyObject *r = tp_binop_obj(a, b, op); + if (r == NULL) { errors++; PyErr_Clear(); } else { tp_release(&r); } + } + return PyLong_FromLong(errors); +} +static PyObject *s_truth(PyObject *self, PyObject *o) +{ + int rc = tp_truth(o); + if (rc < 0) { if (!PyErr_Occurred()) PyErr_SetString(PyExc_AssertionError, "-1 w/o error"); return NULL; } + if (PyErr_Occurred()) return NULL; + return PyLong_FromLong(rc); +} +static PyObject *s_compare_bool(PyObject *self, PyObject *args) +{ + PyObject *a, *b; + int op; + if (!PyArg_ParseTuple(args, "OOi", &a, &b, &op)) return NULL; + int rc = tp_compare_bool(a, b, op); + if (rc < 0) { if (!PyErr_Occurred()) PyErr_SetString(PyExc_AssertionError, "-1 w/o error"); return NULL; } + if (PyErr_Occurred()) return NULL; + return PyLong_FromLong(rc); +} +static PyObject *s_obj_to_f64(PyObject *self, PyObject *o) +{ + double out = 0; + int rc = tp_obj_to_f64(o, &out); + if (!rc_ok(rc)) return NULL; + return PyFloat_FromDouble(out); +} +static PyObject *s_binop_obj(PyObject *self, PyObject *args) +{ + PyObject *a, *b; + int op; + if (!PyArg_ParseTuple(args, "OOi", &a, &b, &op)) return NULL; + PyObject *r = tp_binop_obj(a, b, op); + if (r == NULL && !PyErr_Occurred()) PyErr_SetString(PyExc_AssertionError, "NULL w/o error"); + return r; +} +/* release(obj) -> True if the slot was cleared; the shim owns one extra reference meanwhile */ +static PyObject *s_release(PyObject *self, PyObject *o) +{ + PyObject *slot = Py_NewRef(o); + tp_release(&slot); + PyObject *null_slot = NULL; + tp_release(&null_slot); /* releasing an empty slot is a no-op */ + return PyBool_FromLong(slot == NULL && null_slot == NULL); +} + +static PyObject *s_info(PyObject *self, PyObject *unused) +{ + return Py_BuildValue("{s:i,s:i}", "portable", TP_RUNTIME_PORTABLE_OVERFLOW, + "py_version_hex", (int)PY_VERSION_HEX); +} + +#define M(N, F, FL) {N, (PyCFunction)(void (*)(void))F, FL, NULL} +static PyMethodDef methods[] = { + M("add_i64", s_add_i64, METH_VARARGS), M("sub_i64", s_sub_i64, METH_VARARGS), + M("mul_i64", s_mul_i64, METH_VARARGS), M("neg_i64", s_neg_i64, METH_VARARGS), + M("floordiv_i64", s_floordiv_i64, METH_VARARGS), M("mod_i64", s_mod_i64, METH_VARARGS), + M("truediv_i64", s_truediv_i64, METH_VARARGS), M("i64_to_f64", s_i64_to_f64, METH_VARARGS), + M("truediv_f64", s_truediv_f64, METH_VARARGS), M("floordiv_f64", s_floordiv_f64, METH_VARARGS), + M("mod_f64", s_mod_f64, METH_VARARGS), + M("cmp_i64_f64", s_cmp_i64_f64, METH_VARARGS), M("cmp_f64_i64", s_cmp_f64_i64, METH_VARARGS), + M("math1", s_math1, METH_VARARGS), M("math2", s_math2, METH_VARARGS), + M("box_i64", s_box_i64, METH_VARARGS), M("box_f64", s_box_f64, METH_VARARGS), + M("box_bool", s_box_bool, METH_VARARGS), + M("unbox_i64", s_unbox_i64, METH_O), M("unbox_f64", s_unbox_f64, METH_O), + M("unbox_bool", s_unbox_bool, METH_O), + M("f64_array_run", s_f64_array_run, METH_VARARGS), M("i64_array_run", s_i64_array_run, METH_VARARGS), + M("array_exit_zeroed", s_array_exit_zeroed, METH_O), + M("f64_local_run", s_f64_local_run, METH_VARARGS), M("i64_local_run", s_i64_local_run, METH_VARARGS), + M("local_free_zeroed", s_local_free_zeroed, METH_O), M("tuple_", s_tuple, METH_O), + M("local_copy_of_zeroed", s_local_copy_of_zeroed, METH_O), + M("global_", s_global, METH_VARARGS), M("getattr_", s_getattr, METH_VARARGS), + M("call", s_call, METH_VARARGS), M("call_many", s_call_many, METH_VARARGS), + M("getattr_many", s_getattr_many, METH_VARARGS), M("binop_many", s_binop_many, METH_VARARGS), + M("truth", s_truth, METH_O), M("compare_bool", s_compare_bool, METH_VARARGS), + M("obj_to_f64", s_obj_to_f64, METH_O), M("binop_obj", s_binop_obj, METH_VARARGS), + M("release", s_release, METH_O), + M("any_same", s_any_same, METH_O), M("info", s_info, METH_NOARGS), + {NULL, NULL, 0, NULL}}; + +static struct PyModuleDef moddef = {PyModuleDef_HEAD_INIT, TP_STR(TP_SHIM_NAME), NULL, -1, methods, + NULL, NULL, NULL, NULL}; + +PyMODINIT_FUNC TP_CAT(PyInit_, TP_SHIM_NAME)(void) { return PyModule_Create(&moddef); } diff --git a/python-multiplatform-ksp/src/test/python/test_any_flow.py b/python-multiplatform-ksp/src/test/python/test_any_flow.py new file mode 100644 index 000000000..fae430c44 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_any_flow.py @@ -0,0 +1,43 @@ +"""Any reaching user code from an untyped dependency (design §2.3, §4.2).""" +from conftest import FIXTURES, errors +from typedpython import gate + +DEPS = FIXTURES / "deps" + + +def test_untyped_result_flowing_into_user_code_is_an_error(write): + path = write("user.py", """ + from untyped_dep import load + + def total(text: str) -> None: + value = load(text) + print(value) + """) + found = errors(gate.check([path], search_paths=[DEPS])) + flow = [d for d in found if d.rule == "any-flow"] + assert flow, found + assert all(d.path == str(path) for d in flow) + + +def test_cast_at_the_boundary_resolves_it(write): + path = write("user.py", """ + from typing import cast + from untyped_dep import load + + def total(text: str) -> int: + value = cast(int, load(text)) + return value + 1 + """) + assert gate.check([path], search_paths=[DEPS]) == [] + + +def test_the_dependency_itself_is_not_checked(write): + path = write("user.py", """ + from typing import cast + from untyped_dep import load + + def total(text: str) -> int: + return cast(int, load(text)) + """) + found = gate.check([path], search_paths=[DEPS]) + assert not [d for d in found if d.path.endswith("untyped_dep.py")], found diff --git a/python-multiplatform-ksp/src/test/python/test_builtin_compiled.py b/python-multiplatform-ksp/src/test/python/test_builtin_compiled.py new file mode 100644 index 000000000..71ed65011 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_builtin_compiled.py @@ -0,0 +1,100 @@ +"""`compiled` is a builtin name (design §2.3): no import, on plain CPython and for the checker.""" +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +from conftest import errors, rules +from typedpython import gate + +PROJECT = Path(__file__).resolve().parents[3] +TMP = PROJECT / ".tmp" +PTH = "typedpython_builtins.pth" + + +def test_gate_accepts_compiled_without_an_import(write): + path = write("m.py", """ + @compiled + def f(x: int) -> int: + return x + 1 + + y: int = f(1) + """) + assert errors(gate.check([path])) == [] + + +def test_gate_still_rejects_an_unknown_builtin(write): + path = write("m.py", """ + @not_a_builtin + def f(x: int) -> int: + return x + """) + found = errors(gate.check([path])) + assert "pyrefly/unknown-name" in rules(found) + assert any("not_a_builtin" in d.message for d in found) + + +def test_compiled_keeps_the_decorated_type(write): + path = write("m.py", """ + @compiled + def f(x: int) -> int: + return x + + s: str = f(1) + """) + assert "pyrefly/bad-assignment" in rules(errors(gate.check([path]))) + + +@pytest.fixture(scope="module") +def installed(): + """The wheel, built and installed into a fresh venv; returns (python, wheel path).""" + root = TMP / "builtin-venv" + wheels = TMP / "builtin-wheel" + for d in (root, wheels): + shutil.rmtree(d, ignore_errors=True) + subprocess.run(["uv", "build", "--wheel", "--out-dir", str(wheels), str(PROJECT)], + check=True, capture_output=True) + wheel = next(wheels.glob("typedpython-*.whl")) + subprocess.run(["uv", "venv", "--python", "3.13", str(root)], check=True, capture_output=True) + python = root / "bin" / "python" + subprocess.run(["uv", "pip", "install", "--python", str(python), "--no-deps", str(wheel)], + check=True, capture_output=True) + return python, wheel + + +def py(python, *args): + return subprocess.run([str(python), *args], capture_output=True, text=True) + + +def test_wheel_puts_the_pth_at_its_root(installed): + _, wheel = installed + assert PTH in zipfile.ZipFile(wheel).namelist() + + +def test_decorator_works_with_no_import(installed): + python, _ = installed + r = py(python, "-c", "@compiled\ndef f(): return 1\nprint(f())") + assert (r.returncode, r.stdout.strip()) == (0, "1"), r.stderr + + +def test_compiled_is_the_identity(installed): + python, _ = installed + r = py(python, "-c", "def f(): pass\nprint(compiled(f) is f)") + assert r.stdout.strip() == "True", r.stderr + + +def test_the_pth_does_not_overwrite_the_runtime(installed): + python, _ = installed + site = next((python.parent.parent / "lib").glob("python3*/site-packages")) + code = ( + "import builtins, site\n" + "sentinel = object()\n" + "builtins.compiled = sentinel\n" + f"site.addsitedir({str(site)!r})\n" + "print(builtins.compiled is sentinel)\n" + ) + r = py(python, "-S", "-c", code) + assert r.stdout.strip() == "True", r.stderr diff --git a/python-multiplatform-ksp/src/test/python/test_cgen.py b/python-multiplatform-ksp/src/test/python/test_cgen.py new file mode 100644 index 000000000..eea61452b --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_cgen.py @@ -0,0 +1,1581 @@ +"""TypedPython C back end: typed IR -> C source -> built CPython extension (#41). + +The IR is built by hand here (no front end). Every end-to-end test compares the compiled function +with the interpreted one that the extension keeps in `__typedpython_interpreted__`, because the +contract (ir.py, INTENT §1.4) is "compiled code computes what CPython computes". + +End-to-end tests need the C runtime header `tp_runtime.h`. It is looked up in +`$TYPEDPYTHON_RUNTIME_DIR` when that is set, otherwise in the package's own `runtime/` directory; +when it is missing those tests skip with that reason (a pre-implementation skip, not a regression). +Generation-only tests (`test_gen_*`) never need it. + +Every build happens inside the caller's `heavy.sh` lock (the whole pytest process runs under it); +build output goes to the worktree's `.tmp/cgen-tests/`, never to /tmp. +""" +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +import sysconfig +import textwrap +from pathlib import Path + +import pytest + +from typedpython import ir +from typedpython.ir import ( + And, ArrayParam, Assign, BinOp, BinOpKind, Box, Call, Compare, CompareKind, Const, ExprStmt, + ForRange, Function, If, Index, Len, Local, Module, Param, Return, StoreIndex, Type, + Global, GetAttr, CallObject, Truth, CompareObj, ObjToFloat, + UnaryOp, UnaryOpKind, Unbox, While, Break, Continue, ToFloat, MathCall, MathFunc, + NewArray, CopyArray, Tuple, +) + +PACKAGE = Path(__file__).resolve().parents[2] / "main" / "python" / "typedpython" +WORKTREE = Path(__file__).resolve().parents[4] +OUT_ROOT = WORKTREE / ".tmp" / "cgen-tests" + + +def runtime_dir() -> Path: + env = os.environ.get("TYPEDPYTHON_RUNTIME_DIR") + return Path(env) if env else PACKAGE / "runtime" + + +def need_runtime(): + header = runtime_dir() / "tp_runtime.h" + if not header.is_file(): + pytest.skip(f"C runtime header not available yet: {header} does not exist " + "(set TYPEDPYTHON_RUNTIME_DIR to the tp-runtime worktree's runtime/ dir)") + text = header.read_text() + missing = [h for h in OBJ_HELPERS if not re.search(rf"\b{h}\s*\(", text)] + if missing: + pytest.skip(f"{header} does not define the opaque-object helpers yet: {missing}") + + +# --- IR shorthands ------------------------------------------------------------------------------- + +I64, F64, BOOL, OBJ, NONE = Type.I64, Type.F64, Type.BOOL, Type.OBJ, Type.NONE + + +def c(v, t=None): + if t is None: + t = BOOL if isinstance(v, bool) else I64 if isinstance(v, int) else F64 + return Const(t, v) + + +def L(name, t): + return Local(t, name) + + +def binop(op, a, b, t=None): + if t is None: + t = F64 if (op is BinOpKind.TRUEDIV) else a.type + return BinOp(t, op, a, b) + + +def call(name, *args, kwnames=()): + """`name(*args)` for a module global (a binder function is an ordinary global).""" + return CallObject(OBJ, Global(OBJ, name), tuple(args), tuple(kwnames)) + + +def fn(name, params, returns, locals_, body, *, pure, may_deopt, line=1, entry_globals=()): + return Function(name=name, params=tuple(params), returns=returns, locals=dict(locals_), + body=tuple(body), pure=pure, may_deopt=may_deopt, source_line=line, + entry_globals=tuple(entry_globals)) + + +# The module source that every e2e module embeds; each test picks the functions it compiles. +SOURCE = textwrap.dedent('''\ + """typedpython test module — the interpreted reference.""" + calls = [] + + def record(x): + calls.append(x) + return x + + def poly(x: float, n: int) -> float: + acc = 0.0 + for i in range(n): + acc = acc * x + 0.1 + return acc + + def mul_all(a: int, n: int) -> int: + acc = 1 + for i in range(n): + acc = acc * a + return acc + + def twice_mul(a: int, n: int) -> int: + return mul_all(a, n) * 2 + + def eff(x: int) -> int: + record(x) + return x + + def scale(xs: list[float], k: float, n: int) -> None: + for i in range(n): + xs[i] = xs[i] * k + + def addfirst(dst: list[float], src: list[float]) -> None: + for i in range(len(dst)): + dst[i] = dst[i] + src[0] + + def floordiv(a: int, b: int) -> int: + return a // b + + def objmix(o, p): + q = o + p + q = record(q) + return q + o + + def count_until(limit: int, skip: int) -> int: + total = 0 + i = 0 + while True: + if i >= limit: + break + i = i + 1 + if i == skip: + continue + total = total + i + return total + + def isum(xs: list[int]) -> int: + s = 0 + for i in range(len(xs)): + s = s + xs[i] + return s + + def hyp(x: float, y: float) -> float: + return math.sqrt(x * x + y * y) + + def mixed_lt(a: int, b: float) -> bool: + return a < b and not (b < 0.0) + + def tofl(a: int) -> float: + return float(a) / 3.0 + + def neg_last(xs: list[float]) -> float: + return -xs[-1] + + # --- plain-Python stand-ins for binder proxies (a Kotlin function, a Kotlin object) --- + made = [] + + class Counter: + def __init__(self, scale): + self.scale = scale + self.reads = 0 + + @property + def factor(self): + self.reads += 1 + return self.scale + + def apply(self, v, *, offset=0.0): + return v * self.factor + offset + + def make(scale): + made.append(Counter(scale)) + return made[-1] + + def boom(x): + raise ValueError(f"bad value {x!r}") + + def target(): + return 1 + + def interop(scale: float, n: int) -> float: + obj = make(scale) + acc = 0.0 + for i in range(n): + v = obj.apply(acc, offset=0.5) + acc = float(v) * 0.5 + math.sqrt(acc + 1.0) + if obj.factor > 1.0: + acc = acc + 1.0 + return acc + float(obj.reads) + + def which(): + return target() + + def length(o): + return len(o) + + def calls_boom(o): + return boom(o) + + def truthy(o) -> bool: + if o: + return True + return False + + # --- 957315ea: proven ops, callee-only redo, entry snapshots of globals, CompareObj --- + def proven_eff(x: int) -> int: + record(x) + return x + 1 + + def growth(n: int) -> float: + acc = 1 + for i in range(n): + acc = acc * 3 + return float(acc) + + def growth_lie(n: int) -> float: + acc = 1 + for i in range(n): + acc = acc * 3 + return acc # the IR below claims float(acc): a contract violation the redo catches + + def fill(xs: list[float], n: int) -> float: + for i in range(len(xs)): + xs[i] = xs[i] * 2.0 + g = growth(n) + xs[0] = xs[0] + g + return g + + def fill_lie(xs: list[float], n: int) -> float: + xs[0] = xs[0] * 2.0 + return growth_lie(n) + + def big(n: int) -> bool: + acc = 1 + for i in range(n): + acc = acc * 3 + return acc > 1000 + + def flag(xs: list[float], n: int) -> float: + xs[0] = xs[0] + 1.0 + if big(n): + return 1.0 + return 0.0 + + def selfeq(x) -> bool: + if x == x: + return True + return False + + SCALE = 2.5 + + def scaled(x: float) -> float: + return x * SCALE + + def twice_scaled(x: float) -> float: + return scaled(x) * 2.0 + + # --- local arrays and tuple returns (fannkuch, M2) --- + def la_build(n: int) -> tuple: + a = list(range(n)) + b = [0] * n + s = 0 + for i in range(n // 2): + t = a[i] + a[i] = a[n - 1 - i] + a[n - 1 - i] = t + c = a[:] + for i in range(len(c)): + b[i] = c[i] * 3 + a[i] + s = s + b[i] + first = -1 + last = -1 + if len(a) > 0: + first = a[0] + last = a[-1] + return (len(a), len(b), first, last, s) + + def la_f64(n: int) -> tuple: + x = [0.5] * n + y = x[:] + total = 0.0 + for i in range(len(x)): + x[i] = x[i] * float(i) + 1.0 + y[i] = x[i] / 2.0 + total = total + y[i] + return (len(y), total) + + def la_overflow(n: int, k: int) -> tuple: + a = list(range(n)) + b = a[:] + acc = 1 + for i in range(len(b)): + acc = acc + k + b[i] = i + return (acc, len(a)) + + def la_index(n: int, i: int) -> int: + a = list(range(n)) + return a[i] + + def la_store(n: int, i: int, v: int) -> int: + a = [0] * n + a[i] = v + return len(a) + + def la_reassign(n: int) -> tuple: + a = list(range(n)) + a = [7] * (n + 1) + a = a[:] + return (len(a), a[0], a[-1]) + + def la_copy_param(xs: list[int]) -> tuple: + c = xs[:] + c[0] = 99 + return (xs[0], c[0], len(c)) + + def la_unit() -> tuple: + return () + ''') +SOURCE = "import math\n" + SOURCE + + +def f_poly(): + acc, x, n, i = L("acc", F64), L("x", F64), L("n", I64), L("i", I64) + return fn("poly", [Param("x", F64), Param("n", I64)], F64, + {"acc": F64, "i": I64}, + [Assign("acc", c(0.0)), + ForRange("i", c(0), n, c(1), + (Assign("acc", binop(BinOpKind.ADD, binop(BinOpKind.MUL, acc, x), c(0.1))),)), + Return(acc)], + pure=True, may_deopt=False, line=9) + + +def f_mul_all(): + acc, a, n = L("acc", I64), L("a", I64), L("n", I64) + return fn("mul_all", [Param("a", I64), Param("n", I64)], I64, {"acc": I64, "i": I64}, + [Assign("acc", c(1)), + ForRange("i", c(0), n, c(1), (Assign("acc", binop(BinOpKind.MUL, acc, a)),)), + Return(acc)], + pure=True, may_deopt=True) + + +def f_twice_mul(): + call = Call(I64, "mul_all", (L("a", I64), L("n", I64))) + return fn("twice_mul", [Param("a", I64), Param("n", I64)], I64, {}, + [Return(binop(BinOpKind.MUL, call, c(2)))], pure=True, may_deopt=True) + + +def f_eff(): + x = L("x", I64) + return fn("eff", [Param("x", I64)], I64, {}, + [ExprStmt(call("record", Box(OBJ, x))), Return(x)], + pure=False, may_deopt=False) + + +def f_scale(): + xs = "xs" + i = L("i", I64) + return fn("scale", [ArrayParam(xs, Type.F64_ARRAY, stored=True), Param("k", F64), Param("n", I64)], + NONE, {"i": I64}, + [ForRange("i", c(0), L("n", I64), c(1), + (StoreIndex(xs, i, binop(BinOpKind.MUL, Index(F64, xs, i), L("k", F64))),))], + pure=False, may_deopt=False) + + +def f_addfirst(): + i = L("i", I64) + return fn("addfirst", [ArrayParam("dst", Type.F64_ARRAY, stored=True), + ArrayParam("src", Type.F64_ARRAY, stored=False)], + NONE, {"i": I64}, + [ForRange("i", c(0), Len(I64, "dst"), c(1), + (StoreIndex("dst", i, binop(BinOpKind.ADD, Index(F64, "dst", i, proven=True), + Index(F64, "src", c(0)))),))], + pure=False, may_deopt=False) + + +def f_floordiv(): + return fn("floordiv", [Param("a", I64), Param("b", I64)], I64, {}, + [Return(binop(BinOpKind.FLOORDIV, L("a", I64), L("b", I64)))], pure=True, may_deopt=True) + + +def f_objmix(): + o, p, q = L("o", OBJ), L("p", OBJ), L("q", OBJ) + return fn("objmix", [Param("o", OBJ), Param("p", OBJ)], OBJ, {"q": OBJ}, + [Assign("q", binop(BinOpKind.ADD, o, p)), + Assign("q", call("record", q)), + Return(binop(BinOpKind.ADD, q, o))], + pure=False, may_deopt=False) + + +def f_count_until(): + i, total = L("i", I64), L("total", I64) + return fn("count_until", [Param("limit", I64), Param("skip", I64)], I64, {"total": I64, "i": I64}, + [Assign("total", c(0)), Assign("i", c(0)), + While(c(True), ( + If(Compare(BOOL, CompareKind.GE, i, L("limit", I64)), (Break(),)), + Assign("i", binop(BinOpKind.ADD, i, c(1))), + If(Compare(BOOL, CompareKind.EQ, i, L("skip", I64)), (Continue(),)), + Assign("total", binop(BinOpKind.ADD, total, i)), + )), + Return(total)], + pure=True, may_deopt=True) + + +def f_isum(): + s, i = L("s", I64), L("i", I64) + return fn("isum", [ArrayParam("xs", Type.I64_ARRAY, stored=False)], I64, {"s": I64, "i": I64}, + [Assign("s", c(0)), + ForRange("i", c(0), Len(I64, "xs"), c(1), + (Assign("s", binop(BinOpKind.ADD, s, Index(I64, "xs", i, proven=True))),)), + Return(s)], + pure=True, may_deopt=True) + + +def f_hyp(): + x, y = L("x", F64), L("y", F64) + s = binop(BinOpKind.ADD, binop(BinOpKind.MUL, x, x), binop(BinOpKind.MUL, y, y)) + return fn("hyp", [Param("x", F64), Param("y", F64)], F64, {}, + [Return(MathCall(F64, MathFunc.SQRT, (s,)))], pure=True, may_deopt=False) + + +def f_mixed_lt(): + a, b = L("a", I64), L("b", F64) + return fn("mixed_lt", [Param("a", I64), Param("b", F64)], BOOL, {}, + [Return(And(BOOL, Compare(BOOL, CompareKind.LT, a, b), + UnaryOp(BOOL, UnaryOpKind.NOT, Compare(BOOL, CompareKind.LT, b, c(0.0)))))], + pure=True, may_deopt=False) + + +def f_tofl(): + return fn("tofl", [Param("a", I64)], F64, {}, + [Return(binop(BinOpKind.TRUEDIV, ToFloat(F64, L("a", I64)), c(3.0)))], + pure=True, may_deopt=False) + + +def f_neg_last(): + return fn("neg_last", [ArrayParam("xs", Type.F64_ARRAY, stored=False)], F64, {}, + [Return(UnaryOp(F64, UnaryOpKind.NEG, Index(F64, "xs", c(-1))))], + pure=True, may_deopt=False) + + +def f_interop(): + obj, acc, v, i = L("obj", OBJ), L("acc", F64), L("v", OBJ), L("i", I64) + apply = CallObject(OBJ, GetAttr(OBJ, obj, "apply"), (Box(OBJ, acc), Box(OBJ, c(0.5))), ("offset",)) + step = binop(BinOpKind.ADD, binop(BinOpKind.MUL, ObjToFloat(F64, v), c(0.5)), + MathCall(F64, MathFunc.SQRT, (binop(BinOpKind.ADD, acc, c(1.0)),))) + return fn("interop", [Param("scale", F64), Param("n", I64)], F64, + {"obj": OBJ, "acc": F64, "v": OBJ, "i": I64}, + [Assign("obj", call("make", Box(OBJ, L("scale", F64)))), + Assign("acc", c(0.0)), + ForRange("i", c(0), L("n", I64), c(1), ( + Assign("v", apply), + Assign("acc", step), + If(CompareObj(BOOL, CompareKind.GT, GetAttr(OBJ, obj, "factor"), Box(OBJ, c(1.0))), + (Assign("acc", binop(BinOpKind.ADD, acc, c(1.0))),)), + )), + Return(binop(BinOpKind.ADD, acc, ObjToFloat(F64, GetAttr(OBJ, obj, "reads"))))], + pure=False, may_deopt=False) + + +def f_which(): + return fn("which", [], OBJ, {}, [Return(call("target"))], pure=False, may_deopt=False) + + +def f_length(): + return fn("length", [Param("o", OBJ)], OBJ, {}, [Return(call("len", L("o", OBJ)))], + pure=False, may_deopt=False) + + +def f_calls_boom(): + return fn("calls_boom", [Param("o", OBJ)], OBJ, {}, [Return(call("boom", L("o", OBJ)))], + pure=False, may_deopt=False) + + +def f_truthy(): + return fn("truthy", [Param("o", OBJ)], BOOL, {}, + [If(Truth(BOOL, L("o", OBJ)), (Return(c(True)),)), Return(c(False))], + pure=False, may_deopt=False) + + +def f_proven_eff(): + x = L("x", I64) + return fn("proven_eff", [Param("x", I64)], I64, {}, + [ExprStmt(call("record", Box(OBJ, x))), + Return(BinOp(I64, BinOpKind.ADD, x, c(1), proven=True))], + pure=False, may_deopt=False) + + +def _growth_ir(name, result): + acc = L("acc", I64) + return fn(name, [Param("n", I64)], result, {"acc": I64, "i": I64}, + [Assign("acc", c(1)), + ForRange("i", c(0), L("n", I64), c(1), (Assign("acc", binop(BinOpKind.MUL, acc, c(3))),)), + Return(ToFloat(F64, acc) if result is F64 else Compare(BOOL, CompareKind.GT, acc, c(1000)))], + pure=True, may_deopt=True) + + +def f_growth(): + return _growth_ir("growth", F64) + + +def f_growth_lie(): + return _growth_ir("growth_lie", F64) + + +def f_big(): + return _growth_ir("big", BOOL) + + +def f_fill(): + i, g = L("i", I64), L("g", F64) + return fn("fill", [ArrayParam("xs", Type.F64_ARRAY, stored=True), Param("n", I64)], F64, + {"i": I64, "g": F64}, + [ForRange("i", c(0), Len(I64, "xs"), c(1), + (StoreIndex("xs", i, binop(BinOpKind.MUL, Index(F64, "xs", i, proven=True), c(2.0)), + proven=True),)), + Assign("g", Call(F64, "growth", (L("n", I64),), redo=True)), + StoreIndex("xs", c(0), binop(BinOpKind.ADD, Index(F64, "xs", c(0)), g)), + Return(g)], + pure=False, may_deopt=False) + + +def f_fill_lie(): + return fn("fill_lie", [ArrayParam("xs", Type.F64_ARRAY, stored=True), Param("n", I64)], F64, {}, + [StoreIndex("xs", c(0), binop(BinOpKind.MUL, Index(F64, "xs", c(0)), c(2.0))), + Return(Call(F64, "growth_lie", (L("n", I64),), redo=True))], + pure=False, may_deopt=False) + + +def f_flag(): + return fn("flag", [ArrayParam("xs", Type.F64_ARRAY, stored=True), Param("n", I64)], F64, {}, + [StoreIndex("xs", c(0), binop(BinOpKind.ADD, Index(F64, "xs", c(0)), c(1.0))), + If(Call(BOOL, "big", (L("n", I64),), redo=True), (Return(c(1.0)),)), + Return(c(0.0))], + pure=False, may_deopt=False) + + +def f_selfeq(): + x = L("x", OBJ) + return fn("selfeq", [Param("x", OBJ)], BOOL, {}, + [If(CompareObj(BOOL, CompareKind.EQ, x, x), (Return(c(True)),)), Return(c(False))], + pure=False, may_deopt=False) + + +def f_scaled(): + return fn("scaled", [Param("x", F64)], F64, {}, + [Return(binop(BinOpKind.MUL, L("x", F64), L("SCALE", F64)))], + pure=True, may_deopt=False, entry_globals=[Param("SCALE", F64)]) + + +def f_twice_scaled(): + # The callee's entry-global guard can fail at the call: that is a deopt of the callee, which a + # pure caller propagates (so this caller may deopt). + return fn("twice_scaled", [Param("x", F64)], F64, {}, + [Return(binop(BinOpKind.MUL, Call(F64, "scaled", (L("x", F64),)), c(2.0)))], + pure=True, may_deopt=True) + + +INTEROP = (f_interop, f_which, f_length, f_calls_boom, f_truthy) +CONTRACT_957315EA = (f_proven_eff, f_growth, f_growth_lie, f_big, f_fill, f_fill_lie, f_flag, + f_selfeq, f_scaled, f_twice_scaled) +OBJ_HELPERS = ("tp_global", "tp_getattr", "tp_call", "tp_truth", "tp_compare_bool", "tp_obj_to_f64", + "tp_binop_obj", "tp_release") + +ALL = (f_poly, f_mul_all, f_twice_mul, f_eff, f_scale, f_addfirst, f_floordiv, f_objmix, + f_count_until, f_isum, f_hyp, f_mixed_lt, f_tofl, f_neg_last) + INTEROP + CONTRACT_957315EA + + +# --- building ------------------------------------------------------------------------------------- + +def source_file(name: str) -> Path: + d = OUT_ROOT / name + if d.exists(): + shutil.rmtree(d) + d.mkdir(parents=True) + p = d / f"{name}.py" + p.write_text(SOURCE) + return p + + +def build_module(name: str, functions=ALL, flags=None): + """Generate, build and import an extension `name` compiling `functions`.""" + from typedpython import cbuild, cgen + + need_runtime() + src = source_file(name) + module = Module(name=name, functions=tuple(f() for f in functions)) + c_source = cgen.generate(module, src) + kwargs = {} if flags is None else {"flags": flags} + so = cbuild.build(c_source, name, src.parent, runtime_dir=runtime_dir(), **kwargs) + return cbuild.load(name, so) + + +@pytest.fixture(scope="module") +def mod(): + return build_module("tp_cgen_e2e") + + +def interp(m, name): + return m.__typedpython_interpreted__[name] + + +def deopts(m): + return m.__typedpython_deopts__ + + +# --- generation only (no runtime header needed) --------------------------------------------------- + +def gen(functions=ALL, name="tp_gen_only"): + from typedpython import cgen + + src = source_file(name) + return cgen.generate(Module(name=name, functions=tuple(f() for f in functions)), src) + + +def test_gen_has_multiphase_init_and_embeds_the_source(): + text = gen() + assert "PyInit_tp_gen_only" in text and "PyModuleDef_Init" in text and "Py_mod_exec" in text + assert "__typedpython_interpreted__" in text and "__typedpython_deopts__" in text + assert "tp_impl_poly" in text and "METH_FASTCALL" in text + + +def test_gen_rejects_a_deopting_node_in_an_impure_function(): + from typedpython import cgen + + bad = fn("bad", [Param("a", I64)], I64, {}, + [ExprStmt(call("record", Box(OBJ, L("a", I64)))), + Return(binop(BinOpKind.ADD, L("a", I64), c(1)))], + pure=False, may_deopt=False) + with pytest.raises(cgen.CGenError, match="deopt"): + cgen.generate(Module("tp_bad", (bad,)), source_file("tp_bad")) + + +def test_gen_rejects_a_deopting_call_from_an_impure_caller(): + from typedpython import cgen + + caller = fn("caller", [Param("a", I64)], I64, {}, + [ExprStmt(call("record", Box(OBJ, L("a", I64)))), + Return(Call(I64, "mul_all", (L("a", I64), c(2))))], + pure=False, may_deopt=False) + with pytest.raises(cgen.CGenError, match="deopt"): + cgen.generate(Module("tp_bad2", (f_mul_all(), caller)), source_file("tp_bad2")) + + +# Every function the generated C calls must be CPython C API, a tp_runtime.h helper named in +# runtime/API.md, or a function the generated file defines itself. +C_WORDS = {"void", "if", "for", "while", "switch", "return", "sizeof", "defined", "INT64_C", "UINT64_C"} + + +def api_helpers() -> set[str]: + text = (PACKAGE / "runtime" / "API.md").read_text() + return set(re.findall(r"\b(tp_[a-z0-9_]+)\s*\(", text)) + + +def test_gen_calls_only_cpython_api_and_runtime_helpers(): + text = gen() + # strip comments and string literals so the embedded source does not count + body = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + body = re.sub(r'"(?:\\.|[^"\\])*"', '""', body) + called = set(re.findall(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(", body)) + defined = set(re.findall(r"^(?:static\s+)?[A-Za-z_][A-Za-z0-9_ \*]*?\b([A-Za-z_][A-Za-z0-9_]*)\s*\(", + body, flags=re.M)) + helpers = api_helpers() + assert "tp_add_i64" in helpers and "tp_any_same" in helpers # the scan reads API.md for real + bad = sorted(n for n in called - C_WORDS - helpers - defined + if not re.match(r"^(Py[A-Z_]|PyModuleDef_Init$)", n)) + assert bad == [], f"generated C calls functions outside the CPython API and tp_runtime.h: {bad}" + # tp_* names that are called but neither in API.md nor defined here are typos in waiting + stray = sorted(n for n in called if n.startswith("tp_") and n not in helpers and n not in defined) + assert stray == [] + # no private CPython API + assert not re.search(r"\b_Py[A-Za-z]", body) + + +# --- end to end ----------------------------------------------------------------------------------- + +def test_compiled_functions_replace_the_globals(mod): + assert type(mod.poly).__name__ == "builtin_function_or_method" + assert type(interp(mod, "poly")).__name__ == "function" + assert mod.record.__module__ == "tp_cgen_e2e" # interpreted names live in the same dict + assert interp(mod, "poly").__globals__ is mod.__dict__ + + +def test_f64_loop_returns_exactly_the_interpreted_result(mod): + before = deopts(mod) + for x, n in ((1.1, 1000), (0.37, 17), (-2.5, 9), (1e308, 3), (float("nan"), 2)): + got, want = mod.poly(x, n), interp(mod, "poly")(x, n) + assert type(got) is float + assert repr(got) == repr(want), (x, n, got, want) # bit-exact, incl. inf/nan + assert deopts(mod) == before + + +def test_i64_overflow_in_a_pure_function_deopts_to_the_big_int_result(mod): + before = deopts(mod) + assert mod.mul_all(3, 10) == 3 ** 10 + assert deopts(mod) == before + assert mod.mul_all(3, 50) == 3 ** 50 + assert deopts(mod) == before + 1 + + +def test_deopt_propagates_through_a_c_to_c_call(mod): + before = deopts(mod) + assert mod.twice_mul(7, 5) == 2 * 7 ** 5 + 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). + assert deopts(mod) == before + 2 + + +def test_big_int_argument_takes_the_interpreted_path_before_any_effect(mod): + mod.calls.clear() + before = deopts(mod) + assert mod.eff(5) == 5 + assert mod.calls == [5] and deopts(mod) == before + big = 2 ** 70 + assert mod.eff(big) == big + assert mod.calls == [5, big] # the effect ran once, by the interpreter + assert deopts(mod) == before + 1 + assert mod.mul_all(2 ** 64, 1) == 2 ** 64 and deopts(mod) == before + 2 + + +def test_wrong_arity_raises_cpythons_own_type_error(mod): + with pytest.raises(TypeError) as want: + interp(mod, "poly")(1.0) + with pytest.raises(TypeError) as got: + mod.poly(1.0) + assert str(got.value) == str(want.value) + assert "poly() missing 1 required positional argument: 'n'" == str(got.value) + with pytest.raises(TypeError) as got3: + mod.poly(1.0, 2, 3) + assert "poly() takes 2 positional arguments but 3 were given" == str(got3.value) + + +def test_keyword_arguments_take_the_interpreted_path(mod): + before = deopts(mod) + assert repr(mod.poly(x=1.1, n=30)) == repr(interp(mod, "poly")(1.1, 30)) + assert repr(mod.poly(1.1, n=30)) == repr(interp(mod, "poly")(1.1, 30)) + assert deopts(mod) == before + 2 + with pytest.raises(TypeError) as got: + mod.poly(1.1, m=3) + with pytest.raises(TypeError) as want: + interp(mod, "poly")(1.1, m=3) + assert str(got.value) == str(want.value) + + +def test_exact_float_guard_rejects_int_and_subclass(mod): + class F(float): + def __rmul__(self, other): + return 42.0 + + before = deopts(mod) + assert mod.poly(2, 3) == interp(mod, "poly")(2, 3) # int x -> interpreted + assert mod.poly(F(2.0), 3) == interp(mod, "poly")(F(2.0), 3) == 42.1 # subclass -> interpreted + assert deopts(mod) == before + 2 + + +def test_f64_array_param_is_written_back(mod): + xs = [1.0, 2.0, 3.5] + ident = id(xs) + before = deopts(mod) + assert mod.scale(xs, 2.0, 3) is None + assert xs == [2.0, 4.0, 7.0] and id(xs) == ident + assert all(type(v) is float for v in xs) + assert deopts(mod) == before + + +def test_exception_mid_loop_keeps_the_partial_updates(mod): + xs, ys = [1.0, 2.0, 3.0], [1.0, 2.0, 3.0] + with pytest.raises(IndexError) as got: + mod.scale(xs, 10.0, 5) + with pytest.raises(IndexError) as want: + interp(mod, "scale")(ys, 10.0, 5) + assert str(got.value) == str(want.value) == "list index out of range" + assert xs == ys == [10.0, 20.0, 30.0] + + +def test_negative_index_counts_from_the_end(mod): + assert mod.neg_last([1.0, 2.5]) == -2.5 + with pytest.raises(IndexError, match="list index out of range"): + mod.neg_last([]) + + +def test_aliasing_two_array_params_takes_the_interpreted_path(mod): + a, b = [1.0, 2.0, 3.0], [1.0, 2.0, 3.0] + before = deopts(mod) + mod.addfirst(a, a) + interp(mod, "addfirst")(b, b) + assert a == b == [2.0, 4.0, 5.0] + assert deopts(mod) == before + 1 + d, s = [1.0, 2.0], [10.0] + mod.addfirst(d, s) + assert d == [11.0, 12.0] and s == [10.0] and deopts(mod) == before + 1 + + +def test_a_non_float_element_takes_the_interpreted_path(mod): + xs, ys = [1.0, 2, 3.0], [1.0, 2, 3.0] + before = deopts(mod) + mod.scale(xs, 2.0, 3) + interp(mod, "scale")(ys, 2.0, 3) + assert xs == ys and [type(v) for v in xs] == [type(v) for v in ys] + assert deopts(mod) == before + 1 + assert mod.scale((1.0,), 2.0, 0) is None and deopts(mod) == before + 2 # a tuple is not a list + + +def test_i64_array_and_its_range_guard(mod): + before = deopts(mod) + assert mod.isum([1, 2, 3, -4]) == 2 and deopts(mod) == before + assert mod.isum([2 ** 63 - 1, 1]) == 2 ** 63 and deopts(mod) == before + 1 # overflow in body + assert mod.isum([2 ** 64, 1]) == 2 ** 64 + 1 and deopts(mod) == before + 2 # element guard + assert mod.isum([True, 1]) == 2 and deopts(mod) == before + 3 # bool is not int + + +def test_integer_division_by_zero_has_cpythons_message(mod): + assert mod.floordiv(-7, 2) == -4 + with pytest.raises(ZeroDivisionError) as got: + mod.floordiv(1, 0) + with pytest.raises(ZeroDivisionError) as want: + interp(mod, "floordiv")(1, 0) + assert str(got.value) == str(want.value) + before = deopts(mod) + assert mod.floordiv(-2 ** 63, -1) == 2 ** 63 and deopts(mod) == before + 1 + + +def test_while_break_continue(mod): + for limit, skip in ((10, 3), (0, 0), (5, 9), (100, 100)): + assert mod.count_until(limit, skip) == interp(mod, "count_until")(limit, skip) + + +def test_math_and_mixed_compare_and_tofloat(mod): + for x, y in ((3.0, 4.0), (1e200, 1e200), (0.1, 0.2)): + assert repr(mod.hyp(x, y)) == repr(interp(mod, "hyp")(x, y)) + for a, b in ((2 ** 53 + 1, float(2 ** 53)), (1, 1.5), (3, 2.0), (-1, -0.5), (1, float("nan"))): + assert mod.mixed_lt(a, b) is interp(mod, "mixed_lt")(a, b), (a, b) + for a in (1, 2 ** 53 + 1, -(2 ** 62) - 3, 7): + assert repr(mod.tofl(a)) == repr(interp(mod, "tofl")(a)) + + +def test_obj_code_keeps_reference_counts_balanced(mod): + o, p = 10 ** 30, 10 ** 31 # not cached small ints + mod.calls.clear() + assert mod.objmix(o, p) == interp(mod, "objmix")(o, p) + mod.calls.clear() + ro, rp = sys.getrefcount(o), sys.getrefcount(p) + for _ in range(1000): + mod.objmix(o, p) + mod.calls.clear() + assert (sys.getrefcount(o), sys.getrefcount(p)) == (ro, rp) + with pytest.raises(TypeError) as got: + mod.objmix(1, "a") + with pytest.raises(TypeError) as want: + interp(mod, "objmix")(1, "a") + assert str(got.value) == str(want.value) + + +def test_interop_matches_the_interpreted_run_including_property_reads(mod): + for scale, n in ((1.5, 20), (0.5, 7), (2.0, 0)): + mod.made.clear() + got = mod.interop(scale, n) + got_reads = mod.made[-1].reads + want = interp(mod, "interop")(scale, n) + want_reads = mod.made[-1].reads + assert repr(got) == repr(want), (scale, n) + assert got_reads == want_reads == 2 * n # `factor` is read in apply() and in the if + assert len(mod.made) == 2 # make() ran once per call, no redo + + +def test_a_rebound_global_is_seen_and_builtins_are_found(mod): + assert mod.which() == 1 + old = mod.target + try: + mod.target = lambda: 2 + assert mod.which() == 2 + del mod.target + with pytest.raises(NameError) as got: + mod.which() + with pytest.raises(NameError) as want: + interp(mod, "which")() + assert str(got.value) == str(want.value) == "name 'target' is not defined" + finally: + mod.target = old + assert mod.length([1, 2, 3]) == 3 + + +def test_an_exception_in_the_callee_propagates_and_leaks_nothing(mod): + o = object() + with pytest.raises(ValueError) as want: + interp(mod, "calls_boom")(o) + del want + before = sys.getrefcount(o) + for _ in range(1000): + with pytest.raises(ValueError) as got: + mod.calls_boom(o) + assert str(got.value) == f"bad value {o!r}" + del got + assert sys.getrefcount(o) == before + + +def test_truth_runs_dunder_bool_and_its_errors(mod): + class B: + def __bool__(self): + raise RuntimeError("no truth") + + assert mod.truthy([1]) is True and mod.truthy([]) is False and mod.truthy(0.0) is False + with pytest.raises(RuntimeError, match="no truth"): + mod.truthy(B()) + + +def test_gen_rejects_an_effect_in_a_pure_function(): + from typedpython import cgen + + bad = fn("badpure", [Param("o", OBJ)], OBJ, {}, [Return(GetAttr(OBJ, L("o", OBJ), "x"))], + pure=True, may_deopt=False) + with pytest.raises(cgen.CGenError, match="pure"): + cgen.generate(Module("tp_bad3", (bad,)), source_file("tp_bad3")) + + +# --- 957315ea: proven ops, Call.redo, entry_globals, CompareObj without identity shortcut ---------- + +def test_gen_accepts_proven_ops_in_an_impure_function(): + from typedpython import cgen + + a = L("a", I64) + ok = fn("ok", [Param("a", I64)], I64, {}, + [ExprStmt(call("record", Box(OBJ, a))), + Return(UnaryOp(I64, UnaryOpKind.NEG, BinOp(I64, BinOpKind.SUB, a, c(1), proven=True), + proven=True))], + pure=False, may_deopt=False) + text = cgen.generate(Module("tp_proven_gen", (ok,)), source_file("tp_proven_gen")) + assert "typedpython: proven operation overflowed" in text + + +def test_gen_rejects_proven_on_an_op_that_is_not_checked_i64_arithmetic(): + from typedpython import cgen + + bad = fn("badproven", [Param("a", I64)], I64, {}, + [Return(BinOp(I64, BinOpKind.FLOORDIV, L("a", I64), c(2), proven=True))], + pure=True, may_deopt=True) + with pytest.raises(cgen.CGenError, match="proven"): + cgen.generate(Module("tp_bad4", (bad,)), source_file("tp_bad4")) + + +def test_gen_rejects_redo_of_an_i64_callee(): + from typedpython import cgen + + caller = fn("caller", [Param("a", I64)], I64, {}, + [ExprStmt(call("record", Box(OBJ, L("a", I64)))), + Return(Call(I64, "mul_all", (L("a", I64), c(2)), redo=True))], + pure=False, may_deopt=False) + with pytest.raises(cgen.CGenError, match="redo"): + cgen.generate(Module("tp_bad5", (f_mul_all(), caller)), source_file("tp_bad5")) + + +def test_gen_rejects_entry_globals_in_a_function_that_runs_user_code(): + from typedpython import cgen + + bad = fn("notclosed", [Param("o", OBJ)], F64, {}, + [ExprStmt(call("record", L("o", OBJ))), Return(L("SCALE", F64))], + pure=False, may_deopt=False, entry_globals=[Param("SCALE", F64)]) + with pytest.raises(cgen.CGenError, match="closed"): + cgen.generate(Module("tp_bad6", (bad,)), source_file("tp_bad6")) + + +def test_proven_op_in_an_impure_function_computes_the_interpreted_result(mod): + mod.calls.clear() + before = deopts(mod) + assert mod.proven_eff(5) == interp(mod, "proven_eff")(5) == 6 + assert mod.calls == [5, 5] and deopts(mod) == before + mod.calls.clear() + # A proof the IR producer got wrong: the overflow is an internal error, never a deopt (the + # effect has happened already, so a redo would run it twice). + with pytest.raises(SystemError, match="typedpython: proven operation overflowed"): + mod.proven_eff(2 ** 63 - 1) + assert mod.calls == [2 ** 63 - 1] and deopts(mod) == before + + +def test_redo_reruns_only_the_pure_callee_and_the_callers_effects_happen_once(mod): + xs, ys = [1.0, 2.0], [1.0, 2.0] + before = deopts(mod) + got = mod.fill(xs, 10) + assert deopts(mod) == before # 3**10 fits: no redo + want = interp(mod, "fill")(ys, 10) + assert repr(got) == repr(want) and xs == ys == [2.0 + 3.0 ** 10, 4.0] + + xs, ys = [1.0, 2.0], [1.0, 2.0] + before = deopts(mod) + got = mod.fill(xs, 45) # 3**40 leaves i64 inside growth + assert deopts(mod) == before + 1 # growth redone once, fill not redone + want = interp(mod, "fill")(ys, 45) + assert type(got) is float and repr(got) == repr(want) == repr(float(3 ** 45)) + # effects exactly once: xs[1] doubled once, xs[0] doubled once then grown once + assert xs == ys == [2.0 + float(3 ** 45), 4.0] + assert [type(v) for v in xs] == [float, float] + + +def test_redo_of_a_bool_callee(mod): + for n, want_r in ((3, 0.0), (7, 1.0), (45, 1.0)): + xs, ys = [1.0], [1.0] + before = deopts(mod) + got = mod.flag(xs, n) + assert deopts(mod) == before + (1 if n >= 40 else 0) + assert got == interp(mod, "flag")(ys, n) == want_r + assert xs == ys == [2.0] + + +def test_redo_result_of_the_wrong_type_is_a_system_error_naming_the_callee(mod): + xs = [1.0] + assert mod.fill_lie(xs, 10) == float(3 ** 10) and xs == [2.0] + xs = [1.0] + with pytest.raises(SystemError, match="growth_lie"): + mod.fill_lie(xs, 45) + assert xs == [2.0] # the caller's store is written back once + + +def test_redo_releases_its_temporaries(mod): + # The redo creates a boxed argument, the interpreted function object reference and a result + # float per call; a leak of any one is >= 24 bytes per call, so 2000 calls would grow by + # >= 48 KB. (Floats are not GC-tracked and are fresh objects, so refcounts cannot show this.) + import tracemalloc + + def run(k): + for _ in range(k): + xs = [1.0] + mod.fill(xs, 45) + mod.flag(xs, 45) + try: + mod.fill_lie(xs, 45) + except SystemError: + pass + + run(200) # warm free lists and caches + tracemalloc.start() + try: + base = tracemalloc.get_traced_memory()[0] + run(2000) + grown = tracemalloc.get_traced_memory()[0] - base + finally: + tracemalloc.stop() + assert grown < 16 * 1024, grown + n = 10 ** 30 + before = sys.getrefcount(n) + for _ in range(1000): + mod.selfeq(n) + assert sys.getrefcount(n) == before + + +def test_entry_global_is_read_at_each_call(mod): + old = mod.SCALE + try: + before = deopts(mod) + assert repr(mod.scaled(3.0)) == repr(interp(mod, "scaled")(3.0)) == repr(7.5) + mod.SCALE = 4.0 # rebinding between calls is seen + assert mod.scaled(3.0) == interp(mod, "scaled")(3.0) == 12.0 + assert mod.twice_scaled(3.0) == interp(mod, "twice_scaled")(3.0) == 24.0 + assert deopts(mod) == before + mod.SCALE = 3 # an int: the whole call is interpreted + got = mod.scaled(3.0) + assert deopts(mod) == before + 1 + assert type(got) is float and got == interp(mod, "scaled")(3.0) == 9.0 + d = deopts(mod) + assert mod.twice_scaled(3.0) == 18.0 + # the pure caller is redone (+1); the interpreted caller calls the compiled scaled, whose + # guard fails again (+1) + assert deopts(mod) == d + 2 + del mod.SCALE # unbound: interpreted path raises NameError + with pytest.raises(NameError) as got_e: + mod.scaled(3.0) + with pytest.raises(NameError) as want_e: + interp(mod, "scaled")(3.0) + assert str(got_e.value) == str(want_e.value) == "name 'SCALE' is not defined" + finally: + mod.SCALE = old + + +def test_compare_obj_has_no_identity_shortcut(mod): + nan = float("nan") + assert mod.selfeq(nan) is interp(mod, "selfeq")(nan) is False + + class Never: + def __eq__(self, other): + return False + + o = Never() + assert mod.selfeq(o) is interp(mod, "selfeq")(o) is False + assert mod.selfeq(1.0) is True + + +# --- local arrays and tuples (issue #41, contract 513f10f2) ----------------------------------------- + +I64A, F64A = Type.I64_ARRAY, Type.F64_ARRAY +LOCAL_ARRAY_HELPERS = ("tp_i64_array_new", "tp_i64_array_iota", "tp_i64_array_copy", + "tp_i64_array_free", "tp_f64_array_new", "tp_f64_array_copy", + "tp_f64_array_free", "tp_tuple") + + +def need_local_array_runtime(): + need_runtime() + header = runtime_dir() / "tp_runtime.h" + text = header.read_text() + missing = [h for h in LOCAL_ARRAY_HELPERS if not re.search(rf"\b{h}\s*\(", text)] + if missing: + pytest.skip(f"{header} does not define the local-array helpers yet (the tp-runtime agent " + f"is still writing them): {missing}") + + +def f_la_build(): + n, i, t = L("n", I64), L("i", I64), L("t", I64) + s, first, last = L("s", I64), L("first", I64), L("last", I64) + mirror = binop(BinOpKind.SUB, binop(BinOpKind.SUB, n, c(1)), i) + return fn("la_build", [Param("n", I64)], OBJ, + {"a": I64A, "b": I64A, "c": I64A, "i": I64, "t": I64, "s": I64, "first": I64, "last": I64}, + [Assign("a", NewArray(I64A, n, None, True)), + Assign("b", NewArray(I64A, n, c(0))), + Assign("s", c(0)), + ForRange("i", c(0), binop(BinOpKind.FLOORDIV, n, c(2)), c(1), ( + Assign("t", Index(I64, "a", i)), + StoreIndex("a", i, Index(I64, "a", mirror)), + StoreIndex("a", mirror, t), + )), + Assign("c", CopyArray(I64A, "a")), + ForRange("i", c(0), Len(I64, "c"), c(1), ( + StoreIndex("b", i, binop(BinOpKind.ADD, binop(BinOpKind.MUL, Index(I64, "c", i), c(3)), + Index(I64, "a", i))), + Assign("s", binop(BinOpKind.ADD, s, Index(I64, "b", i))), + )), + Assign("first", c(-1)), Assign("last", c(-1)), + If(Compare(BOOL, CompareKind.GT, Len(I64, "a"), c(0)), ( + Assign("first", Index(I64, "a", c(0))), + Assign("last", Index(I64, "a", c(-1))), + )), + Return(Tuple(OBJ, (Box(OBJ, Len(I64, "a")), Box(OBJ, Len(I64, "b")), Box(OBJ, first), + Box(OBJ, last), Box(OBJ, s))))], + pure=True, may_deopt=True) + + +def f_la_f64(): + n, i, total = L("n", I64), L("i", I64), L("total", F64) + return fn("la_f64", [Param("n", I64)], OBJ, + {"x": F64A, "y": F64A, "total": F64, "i": I64}, + [Assign("x", NewArray(F64A, n, c(0.5))), + Assign("y", CopyArray(F64A, "x")), + Assign("total", c(0.0)), + ForRange("i", c(0), Len(I64, "x"), c(1), ( + StoreIndex("x", i, binop(BinOpKind.ADD, binop(BinOpKind.MUL, Index(F64, "x", i), + ToFloat(F64, i)), c(1.0))), + StoreIndex("y", i, binop(BinOpKind.TRUEDIV, Index(F64, "x", i), c(2.0))), + Assign("total", binop(BinOpKind.ADD, total, Index(F64, "y", i))), + )), + Return(Tuple(OBJ, (Box(OBJ, Len(I64, "y")), Box(OBJ, total))))], + pure=True, may_deopt=False) + + +def f_la_overflow(): + i, acc = L("i", I64), L("acc", I64) + return fn("la_overflow", [Param("n", I64), Param("k", I64)], OBJ, + {"a": I64A, "b": I64A, "acc": I64, "i": I64}, + [Assign("a", NewArray(I64A, L("n", I64), None, True)), + Assign("b", CopyArray(I64A, "a")), + Assign("acc", c(1)), + ForRange("i", c(0), Len(I64, "b"), c(1), ( + Assign("acc", binop(BinOpKind.ADD, acc, L("k", I64))), + StoreIndex("b", i, i), + )), + Return(Tuple(OBJ, (Box(OBJ, acc), Box(OBJ, Len(I64, "a")))))], + pure=True, may_deopt=True) + + +def f_la_index(): + return fn("la_index", [Param("n", I64), Param("i", I64)], I64, {"a": I64A}, + [Assign("a", NewArray(I64A, L("n", I64), None, True)), + Return(Index(I64, "a", L("i", I64)))], + pure=True, may_deopt=False) + + +def f_la_store(): + return fn("la_store", [Param("n", I64), Param("i", I64), Param("v", I64)], I64, {"a": I64A}, + [Assign("a", NewArray(I64A, L("n", I64), c(0))), + StoreIndex("a", L("i", I64), L("v", I64)), + Return(Len(I64, "a"))], + pure=True, may_deopt=False) + + +def f_la_reassign(): + n = L("n", I64) + return fn("la_reassign", [Param("n", I64)], OBJ, {"a": I64A}, + [Assign("a", NewArray(I64A, n, None, True)), + Assign("a", NewArray(I64A, binop(BinOpKind.ADD, n, c(1)), c(7))), + Assign("a", CopyArray(I64A, "a")), + Return(Tuple(OBJ, (Box(OBJ, Len(I64, "a")), Box(OBJ, Index(I64, "a", c(0))), + Box(OBJ, Index(I64, "a", c(-1))))))], + pure=True, may_deopt=True) + + +def f_la_copy_param(): + return fn("la_copy_param", [ArrayParam("xs", I64A, stored=False)], OBJ, {"c": I64A}, + [Assign("c", CopyArray(I64A, "xs")), + StoreIndex("c", c(0), c(99)), + Return(Tuple(OBJ, (Box(OBJ, Index(I64, "xs", c(0))), Box(OBJ, Index(I64, "c", c(0))), + Box(OBJ, Len(I64, "c")))))], + pure=True, may_deopt=False) + + +def f_la_unit(): + return fn("la_unit", [], OBJ, {}, [Return(Tuple(OBJ, ()))], pure=True, may_deopt=False) + + +LOCALARR = (f_la_build, f_la_f64, f_la_overflow, f_la_index, f_la_store, f_la_reassign, + f_la_copy_param, f_la_unit) + + +@pytest.fixture(scope="module") +def la(): + need_local_array_runtime() + return build_module("tp_cgen_la", functions=LOCALARR) + + +def test_gen_local_array_scan_calls_only_runtime_helpers(): + text = gen(LOCALARR, name="tp_gen_la") + body = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + body = re.sub(r'"(?:\\.|[^"\\])*"', '""', body) + called = set(re.findall(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(", body)) + helpers = api_helpers() + for h in LOCAL_ARRAY_HELPERS: + assert h in helpers, f"{h} is not in runtime/API.md" + assert h in called, f"the local-array functions never call {h}" + stray = sorted(n for n in called if n.startswith("tp_") and n not in helpers + and not re.search(rf"^static [^\n]*\b{n}\s*\(", body, flags=re.M)) + assert stray == [] + + +def test_gen_local_arrays_never_go_through_the_write_back_exit(): + text = gen((f_la_build,), name="tp_gen_la2") + impl = text[text.index("static int tp_impl_la_build(PyObject *tp_module, int64_t l_n"):] + impl = impl[:impl.index("static PyObject *tp_wrap_la_build")] + assert "_array_exit" not in impl and "_array_enter" not in impl + assert impl.count("tp_i64_array_free(") >= 3 # a, b, c are freed at the exit block + assert ".dirty" not in impl and "->dirty" not in impl # stores into a local set no dirty bit + + +def test_gen_rejects_a_new_array_that_is_not_an_assign_value(): + from typedpython import cgen + + bad = fn("bad", [Param("n", I64)], I64, {"a": I64A}, + [ExprStmt(NewArray(I64A, L("n", I64), c(0))), Return(c(0))], pure=True, may_deopt=False) + with pytest.raises(cgen.CGenError, match="NewArray"): + cgen.generate(Module("tp_bad3", (bad,)), source_file("tp_bad3")) + + +def test_gen_rejects_assigning_an_array_of_the_wrong_element_type(): + from typedpython import cgen + + bad = fn("bad", [Param("n", I64)], I64, {"a": F64A}, + [Assign("a", NewArray(I64A, L("n", I64), c(0))), Return(c(0))], pure=True, may_deopt=False) + with pytest.raises(cgen.CGenError): + cgen.generate(Module("tp_bad4", (bad,)), source_file("tp_bad4")) + + +def test_gen_a_store_into_a_param_array_still_makes_a_pure_function_an_error(): + from typedpython import cgen + + bad = fn("bad", [ArrayParam("xs", I64A, stored=True)], NONE, {}, + [StoreIndex("xs", c(0), c(1))], pure=True, may_deopt=False) + with pytest.raises(cgen.CGenError, match="pure"): + cgen.generate(Module("tp_bad5", (bad,)), source_file("tp_bad5")) + + +def test_gen_a_store_into_a_local_array_is_not_an_effect(): + text = gen((f_la_store,), name="tp_gen_la3") + assert "tp_impl_la_store" in text + + +def test_local_arrays_equal_cpython_for_several_n(la): + for n in (0, 1, 2, 3, 7, 10, 64, -1, -5): + got, want = la.la_build(n), interp(la, "la_build")(n) + assert type(got) is tuple and got == want, (n, got, want) + assert deopts(la) == 0 + + +def test_f64_local_arrays_equal_cpython_bit_for_bit(la): + for n in (0, 1, 5, 100, -3): + got, want = la.la_f64(n), interp(la, "la_f64")(n) + assert repr(got) == repr(want), (n, got, want) + + +def test_a_tuple_with_no_elements_is_the_empty_tuple(la): + assert la.la_unit() == () and type(la.la_unit()) is tuple + + +def test_local_array_index_errors_equal_cpython(la): + for fname, args in (("la_index", (5, 5)), ("la_index", (5, -6)), ("la_index", (0, 0)), + ("la_index", (5, 4)), ("la_index", (5, -5)), + ("la_store", (3, 3, 1)), ("la_store", (3, -4, 1)), ("la_store", (0, 0, 1)), + ("la_store", (3, -1, 9)), ("la_store", (3, 2, 9))): + try: + want = ("ok", interp(la, fname)(*args)) + except Exception as e: + want = (type(e), str(e)) + try: + got = ("ok", getattr(la, fname)(*args)) + except Exception as e: + got = (type(e), str(e)) + assert got == want, (fname, args, got, want) + # and the two messages are really the list ones, not something of our own + with pytest.raises(IndexError, match=r"^list index out of range$"): + la.la_index(2, 2) + with pytest.raises(IndexError, match=r"^list assignment index out of range$"): + la.la_store(2, 2, 0) + + +def test_reassigning_a_local_array_replaces_it_and_copy_of_itself_is_safe(la): + for n in (0, 1, 5, 40): + assert la.la_reassign(n) == interp(la, "la_reassign")(n) + for bad in (-1,): # a == [7]*0: a[0] raises in both + with pytest.raises(IndexError) as g: + la.la_reassign(bad) + with pytest.raises(IndexError) as w: + interp(la, "la_reassign")(bad) + assert str(g.value) == str(w.value) + + +def test_copy_of_an_array_param_leaves_the_list_untouched(la): + xs = [5, 6, 7] + assert la.la_copy_param(xs) == interp(la, "la_copy_param")([5, 6, 7]) == (5, 99, 3) + assert xs == [5, 6, 7] + + +def test_overflow_deopt_with_local_arrays_returns_the_big_int_result(la): + before = deopts(la) + got, want = la.la_overflow(50, 2 ** 62), interp(la, "la_overflow")(50, 2 ** 62) + assert got == want and got[0] == 1 + 50 * 2 ** 62 and got[0] > 2 ** 63 + assert deopts(la) == before + 1 + before = deopts(la) + assert la.la_overflow(10, 2) == interp(la, "la_overflow")(10, 2) and deopts(la) == before + + +def heap_in_use() -> int: + """Bytes the C allocator has handed out right now (macOS: aggregated over all malloc zones).""" + import ctypes + + if sys.platform != "darwin": + pytest.skip("heap-in-use probe is written for macOS (malloc_zone_statistics) only") + + class Stats(ctypes.Structure): + _fields_ = [("blocks_in_use", ctypes.c_uint), ("size_in_use", ctypes.c_size_t), + ("max_size_in_use", ctypes.c_size_t), ("size_allocated", ctypes.c_size_t)] + + libc = ctypes.CDLL(None) + stats = Stats() + libc.malloc_zone_statistics(None, ctypes.byref(stats)) + return stats.size_in_use + + +def leaked_bytes(call, rounds=40) -> int: + import gc + + call() # warm caches and the allocator + gc.collect() + before = heap_in_use() + for _ in range(rounds): + call() + gc.collect() + return heap_in_use() - before + + +# Each call below allocates two 800 KB arrays (n = 100000); a leak of one array per call is 32 MB +# over 40 rounds, far above the allocator noise this bound allows. (LeakSanitizer does not exist on +# macOS, so the leak check is this heap-in-use delta; ASan below checks the memory errors.) +LEAK_LIMIT = 4 * 1024 * 1024 + + +def test_deopt_in_a_pure_function_frees_the_local_arrays(la): + before = deopts(la) + assert la.la_overflow(100_000, 2 ** 62)[1] == 100_000 # deopted: the sum is a big int + assert deopts(la) == before + 1 + assert leaked_bytes(lambda: la.la_overflow(100_000, 2 ** 62)) < LEAK_LIMIT + + +def test_ok_path_frees_the_local_arrays(la): + assert leaked_bytes(lambda: la.la_overflow(100_000, 1)) < LEAK_LIMIT + assert deopts(la) >= 0 + + +def test_error_path_frees_the_local_arrays(la): + def boom(): + try: + la.la_index(100_000, 10 ** 9) + except IndexError: + pass + assert leaked_bytes(boom) < LEAK_LIMIT + + +def test_reassigning_in_a_loop_frees_the_old_arrays(la): + assert leaked_bytes(lambda: la.la_reassign(100_000)) < LEAK_LIMIT + + +# --- sanitizers ----------------------------------------------------------------------------------- + +ASAN_SCRIPT = textwrap.dedent('''\ + import sys + sys.path.insert(0, {pkg!r}) + from typedpython import cbuild + m = cbuild.load({name!r}, {so!r}) + I = m.__typedpython_interpreted__ + assert repr(m.poly(1.1, 500)) == repr(I["poly"](1.1, 500)) + assert m.mul_all(3, 50) == 3 ** 50 + assert m.twice_mul(7, 40) == 2 * 7 ** 40 + xs = [1.0, 2.0, 3.0] + try: + m.scale(xs, 10.0, 5) + except IndexError: + pass + assert xs == [10.0, 20.0, 30.0], xs + a = [1.0, 2.0, 3.0]; m.addfirst(a, a); assert a == [2.0, 4.0, 5.0] + d = [1.0] * 1000; m.addfirst(d, [2.0]); assert d == [3.0] * 1000 + assert m.isum(list(range(10000))) == sum(range(10000)) + assert m.isum([2 ** 63 - 1, 1]) == 2 ** 63 + try: + m.floordiv(1, 0) + except ZeroDivisionError: + pass + for _ in range(200): + m.objmix(10 ** 30, 10 ** 31); m.calls.clear() + assert m.neg_last([1.0, 2.0]) == -2.0 + assert m.count_until(50, 7) == I["count_until"](50, 7) + for _ in range(50): + xs = [1.0, 2.0]; m.fill(xs, 45); m.flag(xs, 45) + try: + m.fill_lie([1.0], 45) + except SystemError: + pass + assert m.scaled(2.0) == 5.0 and m.selfeq(float("nan")) is False + for _ in range(50): + m.made.clear(); m.interop(1.5, 20) + try: + m.calls_boom(1) + except ValueError: + pass + print("ASAN-RUN-OK", m.__typedpython_deopts__) + ''') + + +def asan_run(name: str, functions, script_template: str) -> tuple[str, Path]: + """Build `functions` with ASan+UBSan, run `script_template` in a sanitized launcher; return the + combined output and the extension path (skips when the platform cannot do it).""" + from typedpython import cbuild + + need_runtime() + cc = sysconfig.get_config_var("CC") or "cc" + if sys.platform != "darwin": + pytest.skip("sanitizer preload recipe written for macOS only") + probe = subprocess.run(cc.split() + ["-print-file-name=libclang_rt.asan_osx_dynamic.dylib"], + capture_output=True, text=True) + asan_rt = Path(probe.stdout.strip()) + if probe.returncode != 0 or not asan_rt.is_file(): + pytest.skip(f"host compiler {cc!r} has no ASan runtime ({probe.stdout.strip()!r})") + src = source_file(name) + from typedpython import cgen + c_source = cgen.generate(Module(name=name, functions=tuple(f() for f in functions)), src) + flags = list(cbuild.DEFAULT_FLAGS) + ["-O1", "-g", "-fno-omit-frame-pointer", + "-fsanitize=address,undefined", + "-fno-sanitize-recover=undefined"] + so = cbuild.build(c_source, name, src.parent, runtime_dir=runtime_dir(), flags=flags) + # macOS refuses DYLD_INSERT_LIBRARIES of the sanitizer runtime into the framework python + # ("Sanitizer load violates platform policy", observed 2026-10-03 on Darwin 25.5), so the + # extension is loaded by a small sanitized launcher linked against libpython: ASan is then in + # the process from the start, as it must be. + launcher_c = src.parent / "asan_python.c" + launcher_c.write_text("#include \nint main(int argc, char **argv) " + "{ return Py_BytesMain(argc, argv); }\n") + launcher = src.parent / "asan_python" + libdir = sysconfig.get_config_var("LIBDIR") + ldver = sysconfig.get_config_var("LDVERSION") + link = subprocess.run(cc.split() + ["-fsanitize=address,undefined", "-g", + f"-I{sysconfig.get_paths()['include']}", str(launcher_c), + f"-L{libdir}", f"-lpython{ldver}", "-o", str(launcher)], + capture_output=True, text=True) + if link.returncode != 0: + pytest.skip(f"cannot link a sanitized launcher against libpython{ldver}: {link.stderr[-2000:]}") + env = dict(os.environ, PYTHONHOME=sys.base_prefix, + ASAN_OPTIONS="detect_leaks=0:abort_on_error=0:halt_on_error=1", + UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1") + env.pop("DYLD_INSERT_LIBRARIES", None) + script = script_template.format(pkg=str(PACKAGE.parent), name=name, so=str(so)) + run = subprocess.run([str(launcher), "-c", script], capture_output=True, text=True, env=env) + out = run.stdout + run.stderr + if "violates platform policy" in out or "Interceptors are not working" in out: + pytest.skip(f"ASan runtime could not be loaded: {out[-2000:]}") + assert run.returncode == 0 and "ASAN-RUN-OK" in run.stdout, out[-6000:] + return out, so + + +def assert_instrumented(so: Path, out: str) -> None: + linked = subprocess.run(["otool", "-L", str(so)], capture_output=True, text=True).stdout + assert "libclang_rt.asan" in linked, linked # the extension really is instrumented + assert "runtime error" not in out and "AddressSanitizer" not in out, out[-6000:] + + +def test_build_with_address_and_undefined_sanitizers(): + out, so = asan_run("tp_cgen_asan", ALL, ASAN_SCRIPT) + assert_instrumented(so, out) + + +ASAN_LA_SCRIPT = textwrap.dedent('''\ + import sys + sys.path.insert(0, {pkg!r}) + from typedpython import cbuild + m = cbuild.load({name!r}, {so!r}) + I = m.__typedpython_interpreted__ + for n in (0, 1, 2, 3, 7, 64, -4): + assert m.la_build(n) == I["la_build"](n), n + assert repr(m.la_f64(n)) == repr(I["la_f64"](n)), n + for n in (0, 1, 9): + assert m.la_reassign(n) == I["la_reassign"](n), n + for args in ((5, 5), (5, -6), (0, 0), (5, 4)): + for name in ("la_index",): + try: + want = I[name](*args) + except IndexError as e: + want = str(e) + try: + got = getattr(m, name)(*args) + except IndexError as e: + got = str(e) + assert got == want, (name, args, got, want) + try: + m.la_store(3, 3, 1) + except IndexError: + pass + assert m.la_unit() == () + xs = [5, 6, 7] + assert m.la_copy_param(xs) == (5, 99, 3) and xs == [5, 6, 7] + for _ in range(100): + assert m.la_overflow(1000, 2 ** 62) == I["la_overflow"](1000, 2 ** 62) + assert m.la_overflow(1000, 1) == I["la_overflow"](1000, 1) + print("ASAN-RUN-OK", m.__typedpython_deopts__) + ''') + + +def test_local_arrays_under_address_and_undefined_sanitizers(): + need_local_array_runtime() + out, so = asan_run("tp_cgen_asan_la", LOCALARR, ASAN_LA_SCRIPT) + assert_instrumented(so, out) + + +# --- building against the embedded CPython ------------------------------------------------------ + +EMBEDDED_314 = (WORKTREE.parents[1] / "python-multiplatform" / "build" / "python-standalone" + / "extracted" / "macos-aarch64" / "python" / "include" / "python3.14") + + +def test_builds_against_the_embedded_cpython_314_headers(): + """Compile only: the host 3.13 interpreter cannot import a 3.14 extension.""" + from typedpython import cbuild, cgen + + need_runtime() + if not (EMBEDDED_314 / "Python.h").is_file(): + pytest.skip(f"embedded CPython 3.14 headers not extracted: {EMBEDDED_314}") + name = "tp_cgen_314" + src = source_file(name) + c_source = cgen.generate(Module(name=name, functions=tuple(f() for f in ALL)), src) + flags = list(cbuild.DEFAULT_FLAGS) + ["-Werror=deprecated-declarations", + "-Werror=incompatible-pointer-types", + "-Werror=int-conversion"] + so = cbuild.build(c_source, name, src.parent, runtime_dir=runtime_dir(), flags=flags, + python_include=EMBEDDED_314, ext_suffix=".cpython-314-darwin.so") + assert so.name == "tp_cgen_314.cpython-314-darwin.so" and so.stat().st_size > 0 + # the 3.14 headers were really used: the same source against them sees PY_VERSION_HEX 3.14 + probe = cbuild.build('#include \n#if PY_VERSION_HEX < 0x030E0000\n#error not 3.14\n#endif\n' + 'PyMODINIT_FUNC PyInit_tp_probe(void) { return NULL; }\n', + "tp_probe", src.parent, runtime_dir=runtime_dir(), + python_include=EMBEDDED_314, ext_suffix=".cpython-314-darwin.so") + assert probe.is_file() diff --git a/python-multiplatform-ksp/src/test/python/test_cli.py b/python-multiplatform-ksp/src/test/python/test_cli.py new file mode 100644 index 000000000..4a7e003a8 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_cli.py @@ -0,0 +1,60 @@ +"""`python -m typedpython check` exit codes: 0 clean, 1 errors, 2 tool failure.""" +import json +import os +import subprocess +import sys +from pathlib import Path + +SRC = Path(__file__).parents[2] / "main" / "python" + + +def run(*args, env_extra=None): + env = dict(os.environ, PYTHONPATH=str(SRC), **(env_extra or {})) + return subprocess.run( + [sys.executable, "-m", "typedpython", "check", *map(str, args)], + capture_output=True, text=True, env=env, + ) + + +def test_clean_exits_0(write): + path = write("user.py", "def f(x: int) -> int:\n return x\n") + result = run(path) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_error_exits_1(write): + path = write("user.py", "def f(x):\n return x\n") + result = run(path) + assert result.returncode == 1, result.stdout + result.stderr + assert "implicit-any-parameter" in result.stdout + + +def test_warnings_alone_exit_0(write): + path = write("user.py", "def f(s: str) -> None:\n eval(s)\n") + assert run(path).returncode == 0 + assert run("--mode", "compiled", path).returncode == 1 + + +def test_json_output(write): + path = write("user.py", "def f(x):\n return x\n") + result = run("--format", "json", path) + data = json.loads(result.stdout) + assert {"path", "line", "column", "rule", "severity", "message"} <= set(data[0]) + + +def test_missing_pyrefly_exits_2(write): + path = write("user.py", "def f(x: int) -> int:\n return x\n") + result = run(path, env_extra={"TYPEDPYTHON_PYREFLY": "/nonexistent/pyrefly"}) + assert result.returncode == 2, result.stdout + result.stderr + + +def test_a_directory_is_checked_file_by_file(write, tmp_path): + write("proj/pkg/__init__.py", "") + write("proj/pkg/ok.py", "def f(x: int) -> int:\n return x\n") + write("proj/pkg/bad.py", "def g(x):\n return x\n") + write("proj/.venv/lib/junk.py", "def h(x):\n return x\n") # hidden: skipped + write("proj/pkg/__pycache__/stale.py", "def k(x):\n return x\n") # cache: skipped + result = run(tmp_path / "proj") + assert result.returncode == 1, result.stdout + result.stderr + assert "bad.py" in result.stdout + assert "junk.py" not in result.stdout and "stale.py" not in result.stdout diff --git a/python-multiplatform-ksp/src/test/python/test_forbidden.py b/python-multiplatform-ksp/src/test/python/test_forbidden.py new file mode 100644 index 000000000..b672a22f8 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_forbidden.py @@ -0,0 +1,54 @@ +"""The §3.1 forbidden list: warning under `checked`, error under `compiled`.""" +import pytest + +from conftest import rules +from typedpython import forbidden, gate + +FLAGGED = { + "forbidden/eval": "def f(s: str) -> None:\n eval(s)\n", + "forbidden/exec": "def f(s: str) -> None:\n exec(s)\n", + "forbidden/dynamic-attr": "def f(o: object, n: str) -> None:\n getattr(o, n)\n", + "forbidden/monkeypatch": "import json\n\ndef f() -> None:\n json.dumps = print\n", +} + + +@pytest.mark.parametrize("rule", sorted(FLAGGED)) +def test_rule_is_found(rule): + assert rule in {f.rule for f in forbidden.scan(FLAGGED[rule], "user.py")} + + +@pytest.mark.parametrize("call", ["setattr(o, n, 1)", "delattr(o, n)"]) +def test_other_dynamic_attribute_calls(call): + source = f"def f(o: object, n: str) -> None:\n {call}\n" + assert {f.rule for f in forbidden.scan(source, "user.py")} == {"forbidden/dynamic-attr"} + + +def test_monkeypatching_a_module_level_class(): + source = ( + "class Greeter:\n pass\n\n" + "def patch() -> None:\n Greeter.hello = 1\n" + ) + assert {f.rule for f in forbidden.scan(source, "user.py")} == {"forbidden/monkeypatch"} + + +@pytest.mark.parametrize("source", [ + "def f(o: object) -> None:\n getattr(o, 'name')\n", + "class C:\n def __init__(self) -> None:\n self.x = 1\n", + "def f() -> None:\n class Local:\n pass\n obj = Local()\n obj.y = 2\n", +]) +def test_static_forms_are_not_flagged(source): + assert forbidden.scan(source, "user.py") == [] + + +def test_findings_carry_a_location(): + [finding] = forbidden.scan("x = 1\neval('x')\n", "user.py") + assert (finding.line, finding.column) == (2, 1) + + +def test_severity_follows_the_mode(write): + path = write("user.py", "def f(s: str) -> None:\n eval(s)\n") + checked = gate.check([path], mode="checked") + compiled = gate.check([path], mode="compiled") + assert [d.severity for d in checked if d.rule == "forbidden/eval"] == ["warning"] + assert [d.severity for d in compiled if d.rule == "forbidden/eval"] == ["error"] + assert rules(checked) == rules(compiled) diff --git a/python-multiplatform-ksp/src/test/python/test_frontend.py b/python-multiplatform-ksp/src/test/python/test_frontend.py new file mode 100644 index 000000000..f82cd9379 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_frontend.py @@ -0,0 +1,1054 @@ +"""TypedPython front end: Python AST + Pyrefly types -> typed IR (#41, SPEC N-8). + +Each test writes a small module and asserts on the IR `frontend.lower` produces, or on the reason +a function was left interpreted. The IR's own docstrings (typedpython/ir.py) are the contract: +mixed int/float gets `ToFloat`, chains evaluate the middle operand once, an impure function has +no node that can deopt. +""" +import dataclasses +import shutil +import textwrap +from pathlib import Path + +import pytest + +from typedpython import frontend +from typedpython.ir import ( + And, ArrayParam, Assign, BinOp, BinOpKind, Box, Break, Call, CallObject, Compare, CompareKind, + CompareObj, Const, CopyArray, NewArray, Tuple, ExprStmt, While, ForRange, GetAttr, Global, If, Index, Len, Local, MathCall, + MathFunc, ObjToFloat, Param, Return, StoreIndex, ToFloat, Truth, Type, UnaryOp, UnaryOpKind, +) + +I64, F64, BOOL, OBJ = Type.I64, Type.F64, Type.BOOL, Type.OBJ +BENCH = Path("/Volumes/macMini/thisisthepy/PythonMultiplatform/.worktrees/typedpython-bench" + "/benchmarks/typedpython/py") + + +def lower(tmp_path, source, name="mod.py"): + path = tmp_path / name + path.write_text(textwrap.dedent(source).lstrip("\n")) + return frontend.lower(path) + + +def function(module, name): + found = [f for f in module.functions if f.name == name] + assert found, f"{name} was not lowered: {module.skipped.get(name)!r}" + return found[0] + + +def nodes(root, cls): + """Every IR node of class `cls` under `root` (a Function, statement or expression).""" + found, stack = [], [root] + while stack: + x = stack.pop() + if isinstance(x, (tuple, list)): + stack.extend(x) + elif dataclasses.is_dataclass(x) and not isinstance(x, type): + if isinstance(x, cls): + found.append(x) + stack.extend(getattr(x, f.name) for f in dataclasses.fields(x)) + return found + + +# --- the basic shape ----------------------------------------------------------------------------- + +def test_scalar_loop_lowers_to_for_range(tmp_path): + m = lower(tmp_path, """ + # typedpython: compiled + def total(n: int, x: float) -> float: + acc: float = 0.0 + for k in range(n): + acc += x + return acc + """) + assert m.skipped == {} + f = function(m, "total") + assert f.params == (Param("n", I64), Param("x", F64)) + assert f.returns == F64 + assert f.locals == {"acc": F64, "k": I64} + assert f.body == ( + Assign("acc", Const(F64, 0.0)), + ForRange("k", Const(I64, 0), Local(I64, "n"), Const(I64, 1), ( + Assign("acc", BinOp(F64, BinOpKind.ADD, Local(F64, "acc"), Local(F64, "x"))), + )), + Return(Local(F64, "acc")), + ) + assert f.pure and not f.may_deopt + assert f.source_line == 2 + + +def test_int_locals_in_a_pure_function_are_i64_and_may_deopt(tmp_path): + # `c` is unannotated: its type comes from Pyrefly. + m = lower(tmp_path, """ + @compiled + def f(a: int, b: int) -> int: + c = a + b + return c * 2 + """) + f = function(m, "f") + assert f.locals == {"c": I64} + assert f.body[0] == Assign("c", BinOp(I64, BinOpKind.ADD, Local(I64, "a"), Local(I64, "b"))) + assert f.pure and f.may_deopt + + +def test_unannotated_local_with_two_types_is_skipped(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(a: int) -> float: + c = a + c = 1.5 + return c + """) + assert m.functions == () + assert "`c`" in m.skipped["f"] and "line " in m.skipped["f"] + + +def test_impure_function_keeps_an_unbounded_int_local_as_a_python_int(tmp_path): + # Impure: a deopt could not be redone, so `m` is a CPython int object, not a checked i64. + m = lower(tmp_path, """ + @compiled + def g(a: list[float], n: int) -> None: + m: int = n + m += 1 + a[0] = 1.0 + """) + f = function(m, "g") + assert f.locals == {"m": OBJ} + assert f.body[:2] == ( + Assign("m", Box(OBJ, Local(I64, "n"))), + Assign("m", BinOp(OBJ, BinOpKind.ADD, Local(OBJ, "m"), Box(OBJ, Const(I64, 1)))), + ) + assert not f.pure and not f.may_deopt + + +def test_impure_function_with_bounded_range_indices_lowers_without_deopt(tmp_path): + m = lower(tmp_path, """ + @compiled + def h(a: list[float], n: int) -> None: + for i in range(n): + for j in range(i + 1, n): + a[j] += a[i] + """) + f = function(m, "h") + assert not f.pure and not f.may_deopt + assert f.params == (ArrayParam("a", Type.F64_ARRAY, True), Param("n", I64)) + + +def test_impure_function_with_unproved_int_arithmetic_is_skipped(tmp_path): + m = lower(tmp_path, """ + @compiled + def h(a: list[float], n: int) -> None: + a[n * 2] = 1.0 + """) + # `n * 2` can overflow i64 and the function is impure, so it is an int object: no native index. + assert m.functions == () + assert "line 3" in m.skipped["h"] and "n * 2" in m.skipped["h"] + + +def test_range_index_arithmetic_is_proven(tmp_path): + # ir.BinOp.proven / UnaryOp.proven: the interval proof is recorded in the node. + m = lower(tmp_path, """ + @compiled + def shift(a: list[float]) -> None: + for i in range(4): + a[i + 1] = a[i] - float(-i) + float(i * 2 - 1) + """) + f = function(m, "shift") + assert not f.pure and not f.may_deopt + ints = [b for b in nodes(f, BinOp) if b.type == I64] + assert ints and all(b.proven for b in ints) + assert BinOp(I64, BinOpKind.ADD, Local(I64, "i"), Const(I64, 1), proven=True) in ints + assert nodes(f, UnaryOp) == [UnaryOp(I64, UnaryOpKind.NEG, Local(I64, "i"), proven=True)] + + +def test_unbounded_int_arithmetic_is_not_proven(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(a: int, b: int) -> int: + return -(a + b) + """) + f = function(m, "f") + assert f.pure and f.may_deopt + assert f.body == (Return(UnaryOp(I64, UnaryOpKind.NEG, BinOp( + I64, BinOpKind.ADD, Local(I64, "a"), Local(I64, "b")))),) + assert not any(b.proven for b in nodes(f, BinOp)) and not nodes(f, UnaryOp)[0].proven + + +def test_float_arithmetic_is_never_marked_proven(tmp_path): + # `proven` is for I64 ADD/SUB/MUL/NEG only (F64 never deopts). + m = lower(tmp_path, """ + @compiled + def f(x: float) -> float: + return -(x + 1.0) + """) + f = function(m, "f") + assert not any(n.proven for n in nodes(f, BinOp) + nodes(f, UnaryOp)) + + +@pytest.mark.parametrize("expr", ["i // 2", "i / 2"]) +def test_int_division_cannot_be_recorded_as_proven_so_it_deopts(tmp_path, expr): + # ir.BinOp.proven exists for ADD/SUB/MUL only; the verifier cannot re-prove // and / on i64, + # so they count as deopting: in a pure function may_deopt, in an impure one a CPython int. + m = lower(tmp_path, f""" +@compiled +def f(n: int) -> float: + t: float = 0.0 + for i in range(10): + t += {expr} + return t +""") + assert function(m, "f").may_deopt + + +# --- conversions --------------------------------------------------------------------------------- + +def test_mixed_int_float_arithmetic_inserts_to_float(tmp_path): + m = lower(tmp_path, """ + @compiled + def mix(n: int, x: float) -> float: + return n * x + 1 + """) + f = function(m, "mix") + assert f.body == (Return(BinOp( + F64, BinOpKind.ADD, + BinOp(F64, BinOpKind.MUL, ToFloat(F64, Local(I64, "n")), Local(F64, "x")), + ToFloat(F64, Const(I64, 1)), + )),) + + +def test_int_value_into_a_float_local_is_skipped(tmp_path): + # CPython keeps the int: `y` would be an int at run time, not a float. + m = lower(tmp_path, """ + @compiled + def f(n: int) -> float: + y: float = n + return y + """) + assert m.functions == () + assert "line 3" in m.skipped["f"] + + +def test_int_true_division_gives_float(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(a: int, b: int) -> float: + return a / b + """) + assert function(m, "f").body == ( + Return(BinOp(F64, BinOpKind.TRUEDIV, Local(I64, "a"), Local(I64, "b"))),) + + +def test_float_of_int_is_to_float(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(a: int) -> float: + return float(a) + """) + assert function(m, "f").body == (Return(ToFloat(F64, Local(I64, "a"))),) + + +# --- chained comparison -------------------------------------------------------------------------- + +def test_chained_compare_of_locals(tmp_path): + m = lower(tmp_path, """ + @compiled + def between(lo: int, x: int, hi: int) -> bool: + return lo < x <= hi + """) + assert function(m, "between").body == (Return(And( + BOOL, + Compare(BOOL, CompareKind.LT, Local(I64, "lo"), Local(I64, "x")), + Compare(BOOL, CompareKind.LE, Local(I64, "x"), Local(I64, "hi")), + )),) + + +def test_chained_compare_evaluates_the_middle_once(tmp_path): + m = lower(tmp_path, """ + @compiled + def sq(v: float) -> float: + return v * v + + @compiled + def within(lo: float, x: float, hi: float) -> bool: + return lo < sq(x) < hi + """) + f = function(m, "within") + assert len(f.body) == 2 + first, ret = f.body + assert isinstance(first, Assign) and first.value == Call(F64, "sq", (Local(F64, "x"),)) + t = first.target + assert f.locals[t] == F64 and t not in ("lo", "x", "hi") + assert ret == Return(And( + BOOL, + Compare(BOOL, CompareKind.LT, Local(F64, "lo"), Local(F64, t)), + Compare(BOOL, CompareKind.LT, Local(F64, t), Local(F64, "hi")), + )) + + +# --- arrays -------------------------------------------------------------------------------------- + +def test_augmented_store_into_an_array_element(tmp_path): + m = lower(tmp_path, """ + @compiled + def bump(a: list[float], i: int, e: float) -> None: + a[i] += e + """) + assert function(m, "bump").body == (StoreIndex( + "a", Local(I64, "i"), + BinOp(F64, BinOpKind.ADD, Index(F64, "a", Local(I64, "i")), Local(F64, "e")), + ),) + + +def test_augmented_store_evaluates_a_computed_index_once(tmp_path): + m = lower(tmp_path, """ + @compiled + def bump_last(a: list[float], e: float) -> None: + a[len(a) - 1] += e + """) + f = function(m, "bump_last") + first, store = f.body + assert isinstance(first, Assign) + assert first.value == BinOp(I64, BinOpKind.SUB, Len(I64, "a"), Const(I64, 1), proven=True) + t = first.target + assert store == StoreIndex( + "a", Local(I64, t), + BinOp(F64, BinOpKind.ADD, Index(F64, "a", Local(I64, t)), Local(F64, "e")), + ) + assert not f.pure and not f.may_deopt + + +def test_list_params_become_array_params_with_stored(tmp_path): + m = lower(tmp_path, """ + @compiled + def scale(a: list[float], b: list[float], k: float, c: list[int]) -> None: + for i in range(len(a)): + a[i] = b[i] * k + """) + f = function(m, "scale") + assert f.params == ( + ArrayParam("a", Type.F64_ARRAY, True), ArrayParam("b", Type.F64_ARRAY, False), + Param("k", F64), ArrayParam("c", Type.I64_ARRAY, False), + ) + assert f.body[0].stop == Len(I64, "a") + + +def test_array_param_used_whole_is_skipped(tmp_path): + m = lower(tmp_path, """ + @compiled + def first(a: list[float]) -> float: + return a[0] + + @compiled + def caller(a: list[float]) -> float: + return first(a) + """) + assert [f.name for f in m.functions] == ["first"] + assert "`a`" in m.skipped["caller"] and "line 7" in m.skipped["caller"] + + +def test_int_stored_into_a_float_array_is_skipped(tmp_path): + m = lower(tmp_path, """ + @compiled + def zero(a: list[float]) -> None: + a[0] = 0 + """) + assert m.functions == () and "line 3" in m.skipped["zero"] + + +# --- math ---------------------------------------------------------------------------------------- + +def test_math_sqrt_of_stdlib_math(tmp_path): + m = lower(tmp_path, """ + import math + + @compiled + def norm(x: float, y: float) -> float: + return math.sqrt(x * x + y * y) + """) + assert function(m, "norm").body == (Return(MathCall(F64, MathFunc.SQRT, (BinOp( + F64, BinOpKind.ADD, + BinOp(F64, BinOpKind.MUL, Local(F64, "x"), Local(F64, "x")), + BinOp(F64, BinOpKind.MUL, Local(F64, "y"), Local(F64, "y")), + ),)),),) + + +@pytest.mark.parametrize("binding", ["import cmath as math", "import math\nmath = 3"]) +def test_shadowed_math_is_skipped(tmp_path, binding): + m = lower(tmp_path, binding + """ + +@compiled +def norm(x: float) -> float: + return math.sqrt(x) +""") + assert m.functions == () + assert "math" in m.skipped["norm"] + + +# --- calls and purity ---------------------------------------------------------------------------- + +def test_impure_caller_redoes_a_deopting_pure_callee(tmp_path): + # ir.Call.redo: on a deopt only the (pure) callee is redone; the caller does not deopt. + m = lower(tmp_path, """ + @compiled + def tri(i: int, j: int) -> float: + return 1.0 / ((i + j) * (i + j + 1) // 2 + i + 1) + + @compiled + def fill(n: int, out: list[float]) -> None: + for i in range(n): + out[i] = tri(i, i) + """) + tri = function(m, "tri") + assert tri.pure and tri.may_deopt + fill = function(m, "fill") + assert not fill.pure and not fill.may_deopt + assert nodes(fill, Call) == [Call(F64, "tri", (Local(I64, "i"), Local(I64, "i")), redo=True)] + assert nodes(fill, CallObject) == [] + + +@pytest.mark.parametrize("returns, body, use", [ + ("bool", "return i + j > 0", "if tri(i, i):\n out[i] = 1.0"), + ("None", "k = i + j", "tri(i, i)\n out[i] = 1.0"), +]) +def test_redo_call_for_bool_and_none_callees(tmp_path, returns, body, use): + m = lower(tmp_path, f""" +@compiled +def tri(i: int, j: int) -> {returns}: + {body} + +@compiled +def fill(n: int, out: list[float]) -> None: + for i in range(n): + {use} +""") + assert function(m, "tri").may_deopt + fill = function(m, "fill") + assert not fill.pure and not fill.may_deopt + [call] = nodes(fill, Call) + assert call.function == "tri" and call.redo + + +def test_impure_caller_of_an_int_returning_deopting_callee_calls_the_global(tmp_path): + # An I64 result of a redo could be a big int: such a callee is still called as an object. + m = lower(tmp_path, """ + @compiled + def add(i: int, j: int) -> int: + return i + j + + @compiled + def fill(n: int, out: list[float]) -> None: + for i in range(n): + out[i] = float(add(i, i)) + """) + fill = function(m, "fill") + assert not fill.pure and not fill.may_deopt + assert nodes(fill, Call) == [] + assert nodes(fill, CallObject) == [ + CallObject(OBJ, Global(OBJ, "add"), (Box(OBJ, Local(I64, "i")), Box(OBJ, Local(I64, "i"))))] + + +def test_pure_caller_of_a_deopting_callee_does_not_redo(tmp_path): + m = lower(tmp_path, """ + @compiled + def tri(i: int, j: int) -> float: + return 1.0 / (i + j) + + @compiled + def twice(i: int) -> float: + return tri(i, i) * 2.0 + """) + twice = function(m, "twice") + assert twice.pure and twice.may_deopt + assert [c.redo for c in nodes(twice, Call)] == [False] + + +def test_pure_caller_of_a_deopting_function_lowers(tmp_path): + m = lower(tmp_path, """ + @compiled + def add(i: int, j: int) -> int: + return i + j + + @compiled + def twice(i: int) -> int: + return add(i, i) + """) + twice = function(m, "twice") + assert twice.body == (Return(Call(I64, "add", (Local(I64, "i"), Local(I64, "i")))),) + assert twice.pure and twice.may_deopt + + +def test_caller_of_an_interpreted_function_calls_it_as_an_object(tmp_path): + m = lower(tmp_path, """ + @compiled + def bad(x: float) -> float: + try: + return x + except ValueError: + return 0.0 + + @compiled + def good(x: float) -> object: + return bad(x) + + @compiled + def typed(x: float) -> float: + return bad(x) + """) + assert [f.name for f in m.functions] == ["good"] + assert "line 3" in m.skipped["bad"] and "try" in m.skipped["bad"] + assert function(m, "good").body == ( + Return(CallObject(OBJ, Global(OBJ, "bad"), (Box(OBJ, Local(F64, "x")),))),) + # The result is an object; a float return would need a conversion CPython does not make. + assert "bad(x)" in m.skipped["typed"] and "line 14" in m.skipped["typed"] + + +# --- what is (not) lowered ----------------------------------------------------------------------- + +@pytest.mark.parametrize("body, what", [ + (" try:\n pass\n except ValueError:\n pass\n", "try"), + (" with x:\n pass\n", "with"), + (" yield x\n", "yield"), + (" global q\n", "global"), + (" y = [x]\n", "list"), + (" y = (lambda: x)\n", "lambda"), +]) +def test_unsupported_construct_is_skipped_with_its_line(tmp_path, body, what): + m = lower(tmp_path, "@compiled\ndef f(x: float) -> None:\n" + body) + assert m.functions == () + assert "line 3" in m.skipped["f"] and what in m.skipped["f"] + + +@pytest.mark.parametrize("signature", [ + "def f(x: float = 1.0) -> float:", "def f(*x: float) -> float:", "def f(x) -> float:", + "def f(x: float):", "def f(*, x: float) -> float:", "def f(x: float, /) -> float:", + "@staticmethod\ndef f(x: float) -> float:", +]) +def test_unsupported_signature_is_skipped(tmp_path, signature): + m = lower(tmp_path, f"@compiled\n{signature}\n return 1.0\n") + assert m.functions == () and "f" in m.skipped + + +def test_global_read_in_a_function_that_runs_user_code_is_a_global_node(tmp_path): + # `print(x)` runs user code, which could rebind K: K is read where it is used (LOAD_GLOBAL). + m = lower(tmp_path, """ + K: float = 2.0 + + @compiled + def f(x: float) -> object: + print(x) + return x * K + + @compiled + def g(x: float) -> float: + print(x) + return x * K + """) + f = function(m, "f") + assert f.body[1] == Return( + BinOp(OBJ, BinOpKind.MUL, Box(OBJ, Local(F64, "x")), Global(OBJ, "K"))) + assert f.entry_globals == () + assert not f.pure and not f.may_deopt + assert "x * K" in m.skipped["g"] + + +def test_scalar_globals_in_a_closed_function_are_entry_globals(tmp_path): + m = lower(tmp_path, """ + PI: float = 3.141592653589793 + SOLAR_MASS: float = 4 * PI * PI + DAYS = 365.24 + N = 3 + ON: bool = True + + @compiled + def scale(a: list[float]) -> None: + for i in range(N): + if ON: + a[i] = a[i] / SOLAR_MASS * DAYS + + @compiled + def k(x: float) -> float: + return x * SOLAR_MASS + """) + scale = function(m, "scale") + assert scale.entry_globals == (Param("N", I64), Param("ON", BOOL), Param("SOLAR_MASS", F64), + Param("DAYS", F64)) + assert nodes(scale, Global) == [] + assert Local(F64, "SOLAR_MASS") in nodes(scale, Local) + assert not scale.pure and not scale.may_deopt + k = function(m, "k") + assert k.entry_globals == (Param("SOLAR_MASS", F64),) + assert k.body == (Return(BinOp(F64, BinOpKind.MUL, Local(F64, "x"), Local(F64, "SOLAR_MASS"))),) + assert k.pure and not k.may_deopt + assert "SOLAR_MASS" not in k.locals + + +def test_closedness_follows_calls(tmp_path): + m = lower(tmp_path, """ + K: float = 2.0 + + @compiled + def noisy(x: float) -> float: + print(x) + return x + + @compiled + def quiet(x: float) -> float: + return x + 1.0 + + @compiled + def via_noisy(x: float) -> object: + return noisy(x) * K + + @compiled + def via_quiet(x: float) -> float: + return quiet(x) * K + """) + assert function(m, "via_noisy").entry_globals == () + assert Global(OBJ, "K") in nodes(function(m, "via_noisy"), Global) + assert function(m, "via_quiet").entry_globals == (Param("K", F64),) + + +@pytest.mark.parametrize("module", [ + "K: float = 2.0\nK = 3.0\n", # assigned twice + "K: float = 2.0\n\ndef reset() -> None:\n global K\n K = 1.0\n", # `global K` elsewhere + "import os\nif os.sep:\n K = 2.0\n", # not at top level + "for K in (1.0,):\n pass\n", # a loop target + "import os\nK = os.cpu_count()\n", # neither annotated nor literal + "K: str = 'a'\n", # not a scalar + "K: float = 2.0\ndel K\n", # deleted +]) +def test_a_global_that_may_be_rebound_is_never_an_entry_global(tmp_path, module): + m = lower(tmp_path, module + """ + +@compiled +def f(x: float) -> object: + return x * K +""") + f = function(m, "f") + assert f.entry_globals == () + assert Global(OBJ, "K") in nodes(f, Global) + + +def test_module_marker_compiles_every_module_level_def(tmp_path): + m = lower(tmp_path, """ + + # typedpython: compiled + def a(x: float) -> float: + return x + + def b(x: float) -> float: + return -x + + @staticmethod + def c(x: float) -> float: + return x + """) + assert [f.name for f in m.functions] == ["a", "b"] + assert set(m.skipped) == {"c"} and "decorator" in m.skipped["c"] + + +def test_without_marker_only_compiled_functions_are_lowered(tmp_path): + m = lower(tmp_path, """ + x: int = 1 + # typedpython: compiled + def a(x: float) -> float: + return x + + @compiled + def b(x: float) -> float: + return -x + """) + assert [f.name for f in m.functions] == ["b"] + assert m.skipped == {} + + +# --- objects: Kotlin interop and any Python value ------------------------------------------------- + +def test_compiled_code_calls_imported_callables_around_native_float_math(tmp_path): + (tmp_path / "kotlinlib.py").write_text(textwrap.dedent(""" + class Canvas: + pass + + def make_canvas(n: int) -> Canvas: + return Canvas() + + def draw(c: Canvas, x: float, alpha: float) -> None: + pass + """)) + m = lower(tmp_path, """ + import math + from kotlinlib import draw, make_canvas + + @compiled + def render(n: int, scale: float) -> float: + canvas = make_canvas(n) + total: float = 0.0 + hits: int = 0 + for i in range(n): + x: float = i * scale + total += math.sqrt(x) + draw(canvas, x, alpha=0.5) + hits += 1 + if hits > n: + break + return total + """) + f = function(m, "render") + assert not f.pure and not f.may_deopt + assert f.locals == {"canvas": OBJ, "total": F64, "hits": OBJ, "i": I64, "x": F64} + assert f.body[0] == Assign("canvas", CallObject( + OBJ, Global(OBJ, "make_canvas"), (Box(OBJ, Local(I64, "n")),))) + loop = f.body[3] + assert isinstance(loop, ForRange) and loop.var == "i" + assert loop.body == ( + Assign("x", BinOp(F64, BinOpKind.MUL, ToFloat(F64, Local(I64, "i")), Local(F64, "scale"))), + Assign("total", BinOp(F64, BinOpKind.ADD, Local(F64, "total"), + MathCall(F64, MathFunc.SQRT, (Local(F64, "x"),)))), + ExprStmt(CallObject(OBJ, Global(OBJ, "draw"), ( + Local(OBJ, "canvas"), Box(OBJ, Local(F64, "x")), Box(OBJ, Const(F64, 0.5)), + ), ("alpha",))), + Assign("hits", BinOp(OBJ, BinOpKind.ADD, Local(OBJ, "hits"), Box(OBJ, Const(I64, 1)))), + If(CompareObj(BOOL, CompareKind.GT, Local(OBJ, "hits"), Box(OBJ, Local(I64, "n"))), + (Break(),)), + ) + + +def test_object_truth_float_and_method_calls(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(o: object, x: float) -> float: + if o: + return float(o.scale(x)) + return x + """) + assert function(m, "f").body == ( + If(Truth(BOOL, Local(OBJ, "o")), (Return(ObjToFloat(F64, CallObject( + OBJ, GetAttr(OBJ, Local(OBJ, "o"), "scale"), (Box(OBJ, Local(F64, "x")),)))),)), + Return(Local(F64, "x")), + ) + + +def test_object_comparison_outside_a_condition_is_skipped(tmp_path): + # `a < b` on objects returns whatever __lt__ returns, which need not be a bool. + m = lower(tmp_path, """ + @compiled + def f(o: object, x: float) -> bool: + return o < x + """) + assert m.functions == () and "line 3" in m.skipped["f"] + + +@pytest.mark.parametrize("body", ["return not (o < x)", "r: bool = c and o < x\n return r"]) +def test_object_comparison_not_directly_a_condition_is_skipped(tmp_path, body): + m = lower(tmp_path, f"@compiled\ndef f(o: object, x: float, c: bool) -> bool:\n {body}\n") + assert m.functions == () and "line 3" in m.skipped["f"] + + +def test_object_comparison_as_an_operand_of_a_condition(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(o: object, x: float, c: bool) -> int: + if c and o < x: + return 1 + return 0 + """) + assert function(m, "f").body[0].cond == And( + BOOL, Local(BOOL, "c"), + CompareObj(BOOL, CompareKind.LT, Local(OBJ, "o"), Box(OBJ, Local(F64, "x")))) + + +def test_while_condition_with_a_temporary_reevaluates_it_each_iteration(tmp_path): + m = lower(tmp_path, """ + @compiled + def sq(v: float) -> float: + return v * v + + @compiled + def shrink(lo: float, x: float, hi: float) -> float: + while lo < sq(x) < hi: + x = x * 0.5 + return x + """) + loop = function(m, "shrink").body[0] + assert isinstance(loop, While) and loop.cond == Const(BOOL, True) + temp, test = loop.body[0], loop.body[1] + assert isinstance(temp, Assign) and temp.value == Call(F64, "sq", (Local(F64, "x"),)) + # The condition stays an If condition (never under `not`), and exits through its else. + assert isinstance(test, If) and isinstance(test.cond, And) + assert test.then == () and test.orelse == (Break(),) + + +@pytest.mark.parametrize("source", [ + "@compiled\ndef f(n: int) -> int:\n return f(n)\n", + "@compiled\ndef f(n: int) -> int:\n return g(n)\n\n" + "@compiled\ndef g(n: int) -> int:\n return f(n)\n", +]) +def test_recursion_is_lowered_not_skipped(tmp_path, source): + # #57: a call cycle is compiled; the run-time depth guard replaces the old refusal. + m = lower(tmp_path, source) + assert {f.name for f in m.functions} >= {"f"} and not any("recurs" in r for r in m.skipped.values()) + + +def test_int_modulo_cannot_be_recorded_as_proven_so_it_deopts(tmp_path): + # ir.BinOp.proven covers ADD/SUB/MUL only and the verifier counts an unproven i64 `%` as a + # deopting node: in a pure function that is may_deopt, in an impure one a CPython int. + m = lower(tmp_path, """ + @compiled + def pure_mod(i: int, n: int) -> int: + return i % n + + @compiled + def put(a: list[float], i: int, n: int) -> None: + a[i % n] = 1.0 + """) + f = function(m, "pure_mod") + assert f.pure and f.may_deopt + assert not any(b.proven for b in nodes(f, BinOp)) + assert "i % n" in m.skipped["put"] + + +def test_locals_exclude_parameters(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(n: int) -> int: + n = n + 1 + return n + """) + assert function(m, "f").locals == {} + + +# --- benchmarks ---------------------------------------------------------------------------------- + +@pytest.mark.skipif(not BENCH.exists(), reason="benchmark worktree not present") +def test_nbody_kernels_lower(tmp_path): + path = tmp_path / "nbody.py" + shutil.copy(BENCH / "nbody.py", path) + path.write_text("# typedpython: compiled\n" + path.read_text()) + m = frontend.lower(path) + for name in ("advance", "energy", "offset_momentum"): + function(m, name) + advance = function(m, "advance") + assert not advance.pure and not advance.may_deopt + assert BinOp(I64, BinOpKind.ADD, Local(I64, "i"), Const(I64, 1), proven=True) \ + in nodes(advance, BinOp) + energy = function(m, "energy") + assert energy.pure and not energy.may_deopt + offset = function(m, "offset_momentum") + assert not offset.pure and not offset.may_deopt + assert offset.entry_globals == (Param("SOLAR_MASS", F64),) + assert nodes(offset, Global) == [] + + +@pytest.mark.skipif(not BENCH.exists(), reason="benchmark worktree not present") +def test_spectral_norm_kernels_lower(tmp_path): + path = tmp_path / "spectral_norm.py" + path.write_text("# typedpython: compiled\n" + (BENCH / "spectral_norm.py").read_text()) + m = frontend.lower(path) + eval_a = function(m, "eval_a") + assert eval_a.pure and eval_a.may_deopt + for name in ("mul_av", "mul_atv"): + f = function(m, name) + assert not f.pure and not f.may_deopt + [call] = nodes(f, Call) + assert call.function == "eval_a" and call.redo + + +# --- local fixed-size arrays and tuple returns (ir.NewArray / CopyArray / Tuple, #41 M2) --------- + +def test_list_range_local_is_an_iota_array(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(n: int) -> int: + x: list[int] = list(range(n)) + return x[0] + """) + f = function(m, "f") + assert f.locals == {"x": Type.I64_ARRAY} + assert f.body == ( + Assign("x", NewArray(Type.I64_ARRAY, Local(I64, "n"), None, True)), + Return(Index(I64, "x", Const(I64, 0))), + ) + assert f.pure and not f.may_deopt + + +def test_fill_locals_are_new_arrays_of_their_element_type(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(n: int) -> float: + a: list[int] = [0] * n + b = [1.5] * n + return b[a[0]] + """) + f = function(m, "f") + assert f.locals == {"a": Type.I64_ARRAY, "b": Type.F64_ARRAY} + assert f.body[:2] == ( + Assign("a", NewArray(Type.I64_ARRAY, Local(I64, "n"), Const(I64, 0))), + Assign("b", NewArray(Type.F64_ARRAY, Local(I64, "n"), Const(F64, 1.5))), + ) + assert f.pure + + +def test_slice_copy_of_a_local_and_of_a_parameter(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(p: list[int], n: int) -> int: + x: list[int] = list(range(n)) + y = x[:] + z: list[int] = p[:] + return y[0] + z[0] + """) + f = function(m, "f") + assert f.locals == {"x": Type.I64_ARRAY, "y": Type.I64_ARRAY, "z": Type.I64_ARRAY} + assert f.body[1] == Assign("y", CopyArray(Type.I64_ARRAY, "x")) + assert f.body[2] == Assign("z", CopyArray(Type.I64_ARRAY, "p")) + assert f.params[0] == ArrayParam("p", Type.I64_ARRAY, False) + + +@pytest.mark.parametrize("what, source, snippet", [ + ("returned", "return x", "return x"), + ("passed to a call", "print(x)\n return 0", "print(x)"), + ("compared", "return x == y", "x == y"), + ("iterated over", "for v in x:\n pass\n return 0", "x"), + ("sliced other than [:]", "w = x[1:]\n return 0", "x[1:]"), + ("appended to", "x.append(1)\n return 0", "x.append(1)"), + ("aliased", "w = x\n return 0", "w = x"), + ("copied outside an assignment", "return len(x[:])", "x[:]"), + ("rebound by a non-array value", "x = 5\n return 0", "x"), +]) +def test_local_array_escapes_skip_the_function(tmp_path, what, source, snippet): + source = source.replace("\n ", "\n ") # the f-string below is dedented + m = lower(tmp_path, f""" + @compiled + def f(n: int) -> int: + x: list[int] = list(range(n)) + y: list[int] = [0] * n + {source} + """) + assert "f" not in [fn.name for fn in m.functions], what + reason = m.skipped["f"] + assert snippet in reason, (what, reason) + assert "array" in reason, (what, reason) + + +@pytest.mark.parametrize("annotation, value", [ + ("list[str]", '["a"] * n'), + ("list[bool]", "[True] * n"), + ("list[list[int]]", "[[0]] * n"), +]) +def test_local_array_element_type_must_be_exactly_int_or_float(tmp_path, annotation, value): + m = lower(tmp_path, f""" + @compiled + def f(n: int) -> int: + x: {annotation} = {value} + return 0 + """) + assert "f" in m.skipped + assert "exactly int or float" in m.skipped["f"], m.skipped["f"] + + +def test_int_fill_of_a_float_array_is_skipped(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(n: int) -> int: + x: list[float] = [0] * n + return 0 + """) + assert "f" in m.skipped and "float" in m.skipped["f"] + + +def test_function_with_only_local_array_stores_is_pure(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(n: int) -> int: + x: list[int] = [0] * n + for i in range(n): + x[i] = i * i + x[i] += 1 + return x[n - 1] * n + """) + f = function(m, "f") + assert nodes(f, StoreIndex) and f.pure + # pure, so the unbounded int arithmetic is native with deopt, not a CPython int + assert f.may_deopt + assert f.locals["x"] == Type.I64_ARRAY and f.returns == I64 + + +def test_function_storing_into_an_array_param_is_still_impure(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(p: list[int], n: int) -> int: + x: list[int] = [0] * n + p[0] = 1 + x[0] = 2 + return x[0] + """) + f = function(m, "f") + assert not f.pure and not f.may_deopt + assert f.params[0] == ArrayParam("p", Type.I64_ARRAY, True) + + +def test_storing_a_local_array_into_nothing_but_locals_keeps_calls_impure(tmp_path): + # An object call is an effect whatever the arrays do. + m = lower(tmp_path, """ + @compiled + def f(n: int, g: object) -> int: + x: list[int] = [0] * n + x[0] = 1 + g(1) + return x[0] + """) + assert not function(m, "f").pure + + +def test_tuple_return_boxes_each_element(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(a: int, b: float) -> tuple[int, float]: + return a, b + """) + f = function(m, "f") + assert f.returns == OBJ + assert f.body == (Return(Tuple(OBJ, (Box(OBJ, Local(I64, "a")), Box(OBJ, Local(F64, "b"))))),) + assert f.pure + + +def test_tuple_return_with_object_element_and_deopting_arithmetic(tmp_path): + m = lower(tmp_path, """ + @compiled + def f(a: int, b: int, o: object) -> tuple[int, object]: + return a // b, o + """) + f = function(m, "f") + assert f.pure and f.may_deopt + [ret] = nodes(f, Return) + assert isinstance(ret.value, Tuple) and ret.value.elements[1] == Local(OBJ, "o") + + +@pytest.mark.parametrize("annotation, value, snippet", [ + ("tuple[int, float]", "a, b", "float"), + ("object", "a, b", "tuple["), + ("tuple[int, int, int]", "a, b", "declares 3"), + ("tuple[int, ...]", "a, b", "..."), +]) +def test_tuple_return_must_match_its_annotation(tmp_path, annotation, value, snippet): + m = lower(tmp_path, f""" + @compiled + def f(a: int, b: int) -> {annotation}: + return {value} + """) + assert "f" in m.skipped + assert snippet in m.skipped["f"], m.skipped["f"] + + +@pytest.mark.skipif(not BENCH.exists(), reason="benchmark worktree not present") +def test_fannkuch_lowers(tmp_path): + path = tmp_path / "fannkuch.py" + path.write_text("# typedpython: compiled\n" + (BENCH / "fannkuch.py").read_text()) + m = frontend.lower(path) + assert "fannkuch" in [f.name for f in m.functions], m.skipped.get("fannkuch") + f = function(m, "fannkuch") + assert f.pure and f.may_deopt + assert f.returns == OBJ and f.params == (Param("n", I64),) + assert f.locals["perm1"] == f.locals["count"] == f.locals["perm"] == Type.I64_ARRAY + assert len(nodes(f, NewArray)) == 2 and len(nodes(f, CopyArray)) == 1 + assert nodes(f, Tuple) diff --git a/python-multiplatform-ksp/src/test/python/test_imports.py b/python-multiplatform-ksp/src/test/python/test_imports.py new file mode 100644 index 000000000..4a9a9a876 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_imports.py @@ -0,0 +1,31 @@ +"""User code inside a package must resolve its own imports (defect found 2026-10-03). + +The gate writes its Pyrefly config into a temporary directory. Pyrefly infers the import root +from the config's location, so without help a package's relative and absolute self-imports did +not resolve: every value from them became `Unknown` and failed the gate as `any-flow`. +""" +from conftest import errors +from typedpython import gate + + +def package(write): + write("src/pkg/__init__.py", "") + write("src/pkg/b.py", "def make() -> int:\n return 1\n") + + +def test_relative_import_inside_a_package(write): + package(write) + path = write("src/pkg/a.py", "from .b import make\n\ndef f() -> int:\n x = make()\n return x\n") + assert errors(gate.check([path])) == [] + + +def test_absolute_import_of_the_own_package(write): + package(write) + path = write("src/pkg/c.py", "from pkg.b import make\n\ndef f() -> int:\n return make()\n") + assert errors(gate.check([path])) == [] + + +def test_sibling_module_without_a_package(write): + write("flat/helper.py", "def make() -> int:\n return 1\n") + path = write("flat/main.py", "from helper import make\n\ndef f() -> int:\n return make()\n") + assert errors(gate.check([path])) == [] diff --git a/python-multiplatform-ksp/src/test/python/test_incremental.py b/python-multiplatform-ksp/src/test/python/test_incremental.py new file mode 100644 index 000000000..d4effcc4e --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_incremental.py @@ -0,0 +1,211 @@ +"""Incremental compilation (SPEC N-10, #41): module cache keyed by source + dependency interface hashes.""" +from __future__ import annotations + +import dataclasses +import re +import shutil +import time +import uuid +from pathlib import Path + +import pytest + +from typedpython import ir +from typedpython.incremental import BuildCache, dependencies, interface_hash + +TMP_ROOT = Path(__file__).resolve().parents[4] / ".tmp" +TYPES = {"int": ir.Type.I64, "float": ir.Type.F64, "bool": ir.Type.BOOL, "None": ir.Type.NONE} +DEF = re.compile(r"^def (\w+)\((.*?)\) -> (\w+):", re.M) + + +@pytest.fixture +def work(): + root = TMP_ROOT / f"incr-{uuid.uuid4().hex[:8]}" + root.mkdir(parents=True) + yield root + shutil.rmtree(root, ignore_errors=True) + + +class Fakes: + """Counting stand-ins for the front end, the C back end and the C compiler.""" + + def __init__(self): + self.lowered, self.generated, self.compiled = [], [], [] + + def lower(self, path: Path) -> ir.Module: + self.lowered.append(path.name) + params_of = lambda text: tuple( + ir.Param(n.strip().split(":")[0], TYPES[n.strip().split(":")[1].strip()]) + for n in text.split(",") if n.strip()) + funcs = tuple( + ir.Function(m[1], params_of(m[2]), TYPES[m[3]], {}, (), True, False, 1) + for m in DEF.finditer(path.read_text())) + return ir.Module(path.stem, funcs) + + def generate(self, module: ir.Module, path: Path) -> str: + self.generated.append(path.name) + return f"/* {module.name} */ {path.read_text()!r}" + + def compile_c(self, c_source: str, name: str, out_dir: Path) -> Path: + self.compiled.append(name) + out = Path(out_dir) / f"{name}.so" + out.write_text("OBJ:" + c_source) + return out + + def counts(self): + return (len(self.lowered), len(self.generated), len(self.compiled)) + + def reset(self): + self.lowered.clear(); self.generated.clear(); self.compiled.clear() + + +def build(cache, fakes, root, files, **over): + args = dict(lower=fakes.lower, generate=fakes.generate, compile_c=fakes.compile_c, + compiler_version="1", platform="linux-x86_64", flags=["-O2"]) + args.update(over) + return cache.build_project([root / f for f in files], root, **args) + + +def project(root: Path): + (root / "a.py").write_text("def f(x: int) -> int:\n return x + 1\n") + (root / "b.py").write_text("from a import f\ndef g(y: int) -> int:\n return f(y)\n") + (root / "c.py").write_text("def h(z: float) -> float:\n return z\n") + return ["a.py", "b.py", "c.py"] + + +def status(report): + return {Path(m.path).name: m.status for m in report.modules} + + +def test_1_first_builds_all_second_builds_nothing(work): + files, fakes, cache = project(work), Fakes(), BuildCache(work / "cache") + r1 = build(cache, fakes, work, files) + assert fakes.counts() == (3, 3, 3) + assert set(status(r1).values()) == {"rebuilt"} + fakes.reset() + t = time.perf_counter() + r2 = build(cache, fakes, work, files) + elapsed = time.perf_counter() - t + assert fakes.counts() == (0, 0, 0) + assert set(status(r2).values()) == {"reused"} + print(f"unchanged rebuild wall time: {elapsed * 1000:.2f} ms") + assert elapsed < 0.5 + assert all(m.artifact.exists() for m in r2.modules) + + +def test_2_body_change_rebuilds_only_that_module(work): + files, fakes, cache = project(work), Fakes(), BuildCache(work / "cache") + build(cache, fakes, work, files) + fakes.reset() + (work / "a.py").write_text("def f(x: int) -> int:\n return x + 2\n") + r = build(cache, fakes, work, files) + assert fakes.compiled == ["a"] + assert status(r) == {"a.py": "rebuilt", "b.py": "reused", "c.py": "reused"} + assert "source" in next(m for m in r.modules if m.path.name == "a.py").reasons + + +def test_3_signature_change_rebuilds_dependents_not_independents(work): + files, fakes, cache = project(work), Fakes(), BuildCache(work / "cache") + build(cache, fakes, work, files) + fakes.reset() + (work / "a.py").write_text("def f(x: float) -> float:\n return x + 1\n") + r = build(cache, fakes, work, files) + assert sorted(fakes.compiled) == ["a", "b"] + assert status(r) == {"a.py": "rebuilt", "b.py": "rebuilt", "c.py": "reused"} + b = next(m for m in r.modules if m.path.name == "b.py") + assert any("dependency interface" in x and "a.py" in x for x in b.reasons), b.reasons + + +@pytest.mark.parametrize("over,component", [ + ({"compiler_version": "2"}, "compiler_version"), + ({"platform": "darwin-arm64"}, "platform"), + ({"flags": ["-O0"]}, "flags"), +]) +def test_4_toolchain_change_rebuilds_everything(work, over, component): + files, fakes, cache = project(work), Fakes(), BuildCache(work / "cache") + build(cache, fakes, work, files) + fakes.reset() + r = build(cache, fakes, work, files, **over) + assert fakes.counts() == (3, 3, 3) + assert set(status(r).values()) == {"rebuilt"} + assert all(component in m.reasons for m in r.modules), [m.reasons for m in r.modules] + + +def test_5_corrupt_entry_is_a_miss_others_reused(work): + files, fakes, cache = project(work), Fakes(), BuildCache(work / "cache") + r1 = build(cache, fakes, work, files) + victim = next(m for m in r1.modules if m.path.name == "c.py").artifact + victim.write_text("OBJ:trunc") # damaged contents + fakes.reset() + r2 = build(cache, fakes, work, files) + assert fakes.compiled == ["c"] + assert status(r2) == {"a.py": "reused", "b.py": "reused", "c.py": "rebuilt"} + assert "corrupt" in next(m for m in r2.modules if m.path.name == "c.py").reasons + # and a deleted artefact, and garbage metadata + fakes.reset() + for m in r2.modules: + if m.path.name == "a.py": + m.artifact.unlink() + if m.path.name == "b.py": + (m.artifact.parent / "meta.json").write_text("{not json") + r3 = build(cache, fakes, work, files) + assert sorted(fakes.compiled) == ["a", "b"] + assert status(r3)["c.py"] == "reused" + + +def test_6_relative_import_in_package_detected(work): + pkg = work / "pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "base.py").write_text("def f(x: int) -> int:\n return x\n") + (pkg / "sub").mkdir() + (pkg / "sub" / "__init__.py").write_text("") + (pkg / "sub" / "leaf.py").write_text("") + (pkg / "user.py").write_text("from .base import f\nfrom .sub import leaf\nimport os, json\n") + (pkg / "deep.py").write_text("from ..pkg import base\nfrom . import base as b2\nimport pkg.sub.leaf\n") + d = dependencies(pkg / "user.py", work) + assert pkg / "base.py" in d and pkg / "sub" / "leaf.py" in d + assert not any(p.name in ("os.py", "json.py") for p in d) + d2 = dependencies(pkg / "deep.py", work) + assert pkg / "base.py" in d2 and pkg / "sub" / "leaf.py" in d2 + assert pkg / "deep.py" not in d2 + + +def test_6b_relative_dependency_drives_rebuild(work): + pkg = work / "pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "base.py").write_text("def f(x: int) -> int:\n return x\n") + (pkg / "user.py").write_text("from .base import f\ndef g(x: int) -> int:\n return f(x)\n") + files, fakes, cache = ["pkg/base.py", "pkg/user.py"], Fakes(), BuildCache(work / "cache") + build(cache, fakes, work, files) + fakes.reset() + (pkg / "base.py").write_text("def f(x: float) -> int:\n return 1\n") + build(cache, fakes, work, files) + assert sorted(fakes.compiled) == ["pkg.base", "pkg.user"] + + +def fn(name="f", params=(ir.Param("x", ir.Type.I64),), ret=ir.Type.I64, body=(), locals_=None, line=1): + return ir.Function(name, tuple(params), ret, locals_ or {}, tuple(body), True, False, line) + + +def test_7_interface_hash(): + base = ir.Module("m", (fn(),)) + h = interface_hash(base) + body = (ir.Return(ir.Const(ir.Type.I64, 1)),) + assert interface_hash(ir.Module("m", (fn(body=body, locals_={"t": ir.Type.F64}, line=99),))) == h + assert interface_hash(ir.Module("m", (dataclasses.replace(fn(), pure=False, may_deopt=True),))) == h + assert interface_hash(ir.Module("m", (fn(params=(ir.Param("x", ir.Type.F64),)),))) != h + assert interface_hash(ir.Module("m", (fn(params=(ir.Param("y", ir.Type.I64),)),))) != h + assert interface_hash(ir.Module("m", (fn(ret=ir.Type.F64),))) != h + assert interface_hash(ir.Module("m", (fn(name="g"),))) != h + arr = lambda t, s: fn(params=(ir.ArrayParam("x", t, s),)) + assert interface_hash(ir.Module("m", (arr(ir.Type.F64_ARRAY, False),))) != \ + interface_hash(ir.Module("m", (arr(ir.Type.I64_ARRAY, False),))) + assert interface_hash(ir.Module("m", (fn(),), {"z": "why"})) != h # skipped names count + assert interface_hash(ir.Module("m", (fn(),), {"z": "why"})) == \ + interface_hash(ir.Module("m", (fn(),), {"z": "other reason"})) + # order of params matters, order of functions does too (it is the module's order) + p2 = (ir.Param("a", ir.Type.I64), ir.Param("b", ir.Type.F64)) + assert interface_hash(ir.Module("m", (fn(params=p2),))) != \ + interface_hash(ir.Module("m", (fn(params=p2[::-1]),))) diff --git a/python-multiplatform-ksp/src/test/python/test_measurement.py b/python-multiplatform-ksp/src/test/python/test_measurement.py new file mode 100644 index 000000000..45e5a15b2 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_measurement.py @@ -0,0 +1,50 @@ +"""What the gate costs on top of a plain `pyrefly check` (design §7, AGENTS.md §14). + +The extra work is the per-expression type report (`--report-pysa`) and the AST pass. The numbers +are printed so a run records them; only a loose sanity bound is asserted. +""" +import subprocess +import sys +import time + +import pytest + +from typedpython import gate + +FUNCTIONS = 300 + + +def synthetic_module() -> str: + lines = [] + for i in range(FUNCTIONS): + lines += [ + f"def f{i}(a: int, b: int) -> int:", + " total = 0", + " for k in range(a):", + " total += k * b", + " return total", + "", + ] + return "\n".join(lines) + + +@pytest.mark.measurement +def test_gate_cost_against_plain_pyrefly(write): + path = write("big.py", synthetic_module()) + lines = synthetic_module().count("\n") + 1 + + start = time.perf_counter() + subprocess.run( + [sys.executable, "-m", "pyrefly", "check", str(path), "--summary=none"], + capture_output=True, check=False, + ) + plain = time.perf_counter() - start + + start = time.perf_counter() + found = gate.check([path]) + gated = time.perf_counter() - start + + print(f"\n[measure] {lines} lines: plain pyrefly {plain * 1000:.0f} ms, " + f"gate {gated * 1000:.0f} ms, ratio {gated / plain:.2f}x") + assert found == [] + assert gated < 60 diff --git a/python-multiplatform-ksp/src/test/python/test_pipeline.py b/python-multiplatform-ksp/src/test/python/test_pipeline.py new file mode 100644 index 000000000..a835153c9 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_pipeline.py @@ -0,0 +1,102 @@ +"""End to end: source -> frontend -> verify -> cgen -> cbuild -> import (SPEC N-8, #41).""" +import importlib.util +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from typedpython import pipeline + +SRC = Path(__file__).parents[2] / "main" / "python" + + +def interpreted(path: Path): + spec = importlib.util.spec_from_file_location(path.stem + "_interp", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +SCALAR = """\ +# typedpython: compiled + + +def total(n: int) -> float: + acc: float = 0.0 + for k in range(n): + acc += k * 0.5 + return acc + + +def fact(n: int) -> int: + r: int = 1 + for k in range(2, n + 1): + r = r * k + return r +""" + + +def test_scalar_module_compiles_and_matches_cpython(write, tmp_path): + path = write("scalar.py", SCALAR) + result = pipeline.compile_module(path, tmp_path / "out") + assert result.extension is not None, result.skipped + assert {f.name for f in result.module.functions} >= {"total", "fact"}, result.skipped + compiled = pipeline.load(result) + ref = interpreted(path) + assert compiled.total(1000) == ref.total(1000) + assert compiled.fact(30) == ref.fact(30) # promotes past i64 + assert compiled.__typedpython_deopts__ >= 1 + + +def test_unsupported_functions_stay_interpreted_with_a_reason(write, tmp_path): + path = write("mixed.py", SCALAR + """ + +def greet(name: str) -> str: + return "hi " + name +""") + result = pipeline.compile_module(path, tmp_path / "out") + assert "greet" in result.skipped and result.skipped["greet"] + assert pipeline.load(result).greet("x") == "hi x" + + +def test_a_module_with_nothing_to_compile_produces_no_extension(write, tmp_path): + path = write("plain.py", "def f(x: int) -> int:\n return x\n") + result = pipeline.compile_module(path, tmp_path / "out") + assert result.extension is None + + +def test_gate_errors_block_compilation(write, tmp_path): + path = write("bad.py", "# typedpython: compiled\n\ndef f(x):\n return x\n") + with pytest.raises(pipeline.CompileError, match="implicit-any-parameter"): + pipeline.compile_module(path, tmp_path / "out") + + +DEMO = """\ +# typedpython: compiled +import sys + + +def total(n: int) -> float: + acc: float = 0.0 + for k in range(n): + acc += k * 0.5 + return acc + + +def main() -> None: + print(f"{total(int(sys.argv[1])):.3f}") +""" + + +def test_demo_reports_identical_output(write, tmp_path): + path = write("demo_mod.py", DEMO) + env = dict(os.environ, PYTHONPATH=str(SRC)) + result = subprocess.run( + [sys.executable, "-m", "typedpython", "demo", "--out", str(tmp_path / "b"), str(path), "1000"], + capture_output=True, text=True, env=env, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "identical output" in result.stdout and "249750.000" in result.stdout + assert "compiled functions: total" in result.stdout diff --git a/python-multiplatform-ksp/src/test/python/test_pipeline_incremental.py b/python-multiplatform-ksp/src/test/python/test_pipeline_incremental.py new file mode 100644 index 000000000..9eef09304 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_pipeline_incremental.py @@ -0,0 +1,171 @@ +"""compile_project: the incremental cache wired into the real pipeline (SPEC N-10, #41). + +Real Pyrefly, real C builds. The three-module project: `a` imports `b`; `c` is independent. +""" +import importlib.util +import textwrap +import time +from pathlib import Path + +import pytest + +from typedpython import cbuild, frontend, pipeline + +HEADER = "# typedpython: compiled\n" + +B = HEADER + textwrap.dedent("""\ + def twice(n: int) -> int: + return n * 2 + """) +A = HEADER + textwrap.dedent("""\ + import b + + + def inc(n: int) -> int: + return n + 1 + """) +C = HEADER + textwrap.dedent("""\ + def half(x: float) -> float: + return x * 0.5 + """) + + +def interpreted(path: Path): + spec = importlib.util.spec_from_file_location(path.stem + "_interp", path) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +@pytest.fixture +def project(tmp_path): + root = tmp_path / "proj" + root.mkdir() + for name, src in (("a", A), ("b", B), ("c", C)): + (root / f"{name}.py").write_text(src) + return root + + +@pytest.fixture +def counts(monkeypatch): + """Count calls into the compile steps (the work an unchanged rebuild must not do).""" + n = {"lower": 0, "build": 0} + real_lower, real_build = frontend.lower, cbuild.build + + def lower(*a, **k): + n["lower"] += 1 + return real_lower(*a, **k) + + def build(*a, **k): + n["build"] += 1 + return real_build(*a, **k) + + monkeypatch.setattr(frontend, "lower", lower) + monkeypatch.setattr(cbuild, "build", build) + return n + + +def run(project, tmp_path): + return pipeline.compile_project( + [project / "a.py", project / "b.py", project / "c.py"], project, tmp_path / "cache") + + +def status(result): + return {m.path.stem: m.status for m in result.modules} + + +def test_first_build_compiles_all_and_unchanged_rebuild_does_nothing(project, tmp_path, counts): + first = run(project, tmp_path) + assert status(first) == {"a": "rebuilt", "b": "rebuilt", "c": "rebuilt"} + assert all(m.extension is not None for m in first.modules) + assert counts == {"lower": 3, "build": 3} + + counts.update(lower=0, build=0) + start = time.perf_counter() + second = run(project, tmp_path) + elapsed = time.perf_counter() - start + print(f"\nunchanged rebuild of 3 modules: {elapsed:.3f} s (compile steps skipped)") + assert status(second) == {"a": "reused", "b": "reused", "c": "reused"} + assert counts == {"lower": 0, "build": 0} + assert [m.extension for m in second.modules] == [m.extension for m in first.modules] + + +def test_body_change_rebuilds_that_module_only(project, tmp_path, counts): + run(project, tmp_path) + (project / "b.py").write_text(B.replace("n * 2", "n * 3")) + r = run(project, tmp_path) + assert status(r) == {"a": "reused", "b": "rebuilt", "c": "reused"} + assert "source" in next(m for m in r.modules if m.path.stem == "b").reasons + + +def test_signature_change_rebuilds_dependents(project, tmp_path, counts): + run(project, tmp_path) + (project / "b.py").write_text(B.replace("n: int) -> int", "n: int, k: int) -> int") + .replace("n * 2", "n * k")) + r = run(project, tmp_path) + assert status(r) == {"a": "rebuilt", "b": "rebuilt", "c": "reused"} + a = next(m for m in r.modules if m.path.stem == "a") + assert any("dependency interface" in x for x in a.reasons), a.reasons + + +def test_compiler_version_change_rebuilds_everything(project, tmp_path, counts, monkeypatch): + run(project, tmp_path) + monkeypatch.setattr(pipeline, "COMPILER_VERSION", "something-else") + r = run(project, tmp_path) + assert status(r) == {"a": "rebuilt", "b": "rebuilt", "c": "rebuilt"} + assert all("compiler_version" in m.reasons for m in r.modules) + + +def test_rebuilt_and_reused_extensions_compute_the_interpreted_results(project, tmp_path, monkeypatch): + monkeypatch.syspath_prepend(str(project)) # a's `import b` runs at load + run(project, tmp_path) + (project / "b.py").write_text(B.replace("n * 2", "n * 3")) + r = run(project, tmp_path) + mods = {m.path.stem: m for m in r.modules} + b = cbuild.load("b", mods["b"].extension) + assert b.twice(7) == interpreted(project / "b.py").twice(7) == 21 + a = cbuild.load("a", mods["a"].extension) # reused + assert a.inc(4) == 5 + c = cbuild.load("c", mods["c"].extension) + assert c.half(5.0) == interpreted(project / "c.py").half(5.0) + + +def test_module_with_nothing_compiled_has_no_extension_and_is_reused(tmp_path): + root = tmp_path / "proj" + root.mkdir() + (root / "plain.py").write_text(HEADER + "def f(s: str) -> str:\n return s + 'x'\n") + cache = tmp_path / "cache" + first = pipeline.compile_project([root / "plain.py"], root, cache) + m = first.modules[0] + assert m.extension is None and m.status == "rebuilt" and m.skipped + second = pipeline.compile_project([root / "plain.py"], root, cache) + assert second.modules[0].status == "reused" and second.modules[0].reasons == [] + assert second.modules[0].extension is None + + +def test_interface_describes_the_verified_module_not_the_lowered_one(tmp_path, monkeypatch): + """A function the verifier rejects stays interpreted, so it must not be in the interface.""" + import dataclasses + + from typedpython import incremental, ir + + root = tmp_path / "proj" + root.mkdir() + (root / "dep.py").write_text(HEADER + "def ok(n: int) -> int:\n return n + 1\n") + real_lower = frontend.lower + seen = {} + + def lower_with_a_bad_function(path): + m = real_lower(path) + good = m.functions[0] + bad = dataclasses.replace(good, name="bad", returns=ir.Type.F64) # verify/type: wrong return + seen["lowered"] = dataclasses.replace(m, functions=(*m.functions, bad)) + return seen["lowered"] + + monkeypatch.setattr(frontend, "lower", lower_with_a_bad_function) + r = pipeline.compile_project([root / "dep.py"], root, tmp_path / "cache") + dep = r.modules[0] + assert "bad" in dep.skipped, dep.skipped + assert [f.name for f in seen["lowered"].functions] == ["ok", "bad"] + unverified = incremental.interface_hash(seen["lowered"]) + assert dep.interface != unverified diff --git a/python-multiplatform-ksp/src/test/python/test_pyrefly_kinds.py b/python-multiplatform-ksp/src/test/python/test_pyrefly_kinds.py new file mode 100644 index 000000000..f7e864dfb --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_pyrefly_kinds.py @@ -0,0 +1,69 @@ +"""Each Pyrefly Any-leak kind the gate raises to error (design §4.2).""" +import pytest + +from conftest import errors, rules +from typedpython import gate + +CASES = { + "implicit-any-parameter": """ + def f(x, y: int) -> int: + return y + """, + "unannotated-return": """ + def f(y: int): + return y + """, + "unknown-variable-type": """ + import json + def f() -> None: + d = json.loads("1") + z = d["k"] + """, + "no-any-return-implicit": """ + import json + def f() -> int: + d = json.loads("1") + return d["k"] + """, + "no-any-return-explicit": """ + from typing import Any + def f(v: Any) -> int: + return v + """, + "explicit-any": """ + from typing import Any + def f(v: Any) -> None: + pass + """, +} + + +@pytest.mark.parametrize("kind", sorted(CASES)) +def test_kind_is_an_error(write, kind): + path = write("user.py", CASES[kind]) + found = gate.check([path]) + assert f"pyrefly/{kind}" in rules(errors(found)), found + + +def test_ordinary_type_error_is_an_error(write): + path = write("user.py", """ + def f(y: int) -> int: + return y + "a" + """) + assert errors(gate.check([path])), "a plain type error must still fail the gate" + + +def test_fully_typed_code_is_clean(write): + path = write("user.py", """ + def area(w: int, h: int) -> int: + scale = 2 + return w * h * scale + + class Box: + def __init__(self, w: int) -> None: + self.w = w + + def double(self) -> int: + return self.w * 2 + """) + assert gate.check([path]) == [] diff --git a/python-multiplatform-ksp/src/test/python/test_rebinding.py b/python-multiplatform-ksp/src/test/python/test_rebinding.py new file mode 100644 index 000000000..731707041 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_rebinding.py @@ -0,0 +1,184 @@ +"""Rebinding a name to a different type, and mixed-element containers (design §3.1, #22). + +Both are forbidden-list items: warnings under `checked`, errors under `compiled`. They need the +inferred type of an assignment target, which Pyrefly's type report does not give directly; the +gate reads each target back through a probe load (see typedpython.rebinding). +""" +import pytest + +from typedpython import gate, rebinding + +REBIND = "forbidden/rebinding" +MIXED = "forbidden/mixed-container" + + +def found(write, source, mode="checked"): + return gate.check([write("user.py", source)], mode=mode) + + +def by_rule(diagnostics, rule): + return [d for d in diagnostics if d.rule == rule] + + +def test_rebinding_to_another_type_is_reported(write): + result = found(write, """ + def f() -> None: + x = 1 + x = "a" + print(x) + """) + [d] = by_rule(result, REBIND) + assert (d.line, d.severity) == (4, "warning") + assert "int" in d.message and "str" in d.message + + +def test_compiled_mode_makes_it_an_error(write): + result = found(write, """ + def f() -> None: + x = 1 + x = "a" + print(x) + """, mode="compiled") + assert [d.severity for d in by_rule(result, REBIND)] == ["error"] + + +def test_augmented_assignment_that_changes_the_type(write): + result = found(write, """ + def f() -> float: + total = 0 + total += 0.5 + return total + """) + assert by_rule(result, REBIND), result + + +def test_module_level_rebinding(write): + result = found(write, """ + limit = 10 + limit = "ten" + print(limit) + """) + assert by_rule(result, REBIND), result + + +@pytest.mark.parametrize("source", [ + # same type again + "def f() -> None:\n x = 1\n x = 2\n print(x)\n", + # a declared union governs; Pyrefly checks each assignment against it + "def f() -> None:\n x: int | str = 1\n x = 'a'\n print(x)\n", + # an annotated parameter reassigned within its type + "def f(n: int) -> int:\n n = n + 1\n return n\n", +]) +def test_not_rebinding(write, source): + assert by_rule(gate.check([write("user.py", source)]), REBIND) == [] + + +def test_mixed_list_is_reported(write): + result = found(write, """ + def f() -> None: + mixed = [1, "a"] + print(mixed) + """) + [d] = by_rule(result, MIXED) + assert d.line == 3 and "int | str" in d.message + + +def test_mixed_dict_values_are_reported(write): + result = found(write, """ + def f(a: int) -> None: + d = {"k": a, "j": "s"} + print(d) + """) + assert by_rule(result, MIXED), result + + +@pytest.mark.parametrize("source", [ + "def f() -> None:\n nums = [1, 2]\n print(nums)\n", + "def f() -> None:\n pair = (1, 'a')\n print(pair)\n", # tuples are typed per slot + "def f() -> None:\n xs: list[int | str] = [1, 'a']\n print(xs)\n", # declared on purpose +]) +def test_not_mixed(write, source): + assert by_rule(gate.check([write("user.py", source)]), MIXED) == [] + + +def test_probe_keeps_every_line_number(): + source = "def f() -> None:\n x = 1 # one\n x = 'a'\n y, z = 1, 2\n" + probed, probes = rebinding.probe(source) + assert probed.count("\n") == source.count("\n") + assert {p.name for p in probes} >= {"x", "y", "z"} + for p in probes: + line = probed.splitlines()[p.line - 1] + assert line[p.column - 1:].startswith(p.name), (p, line) + + +def test_no_candidates_means_no_second_pyrefly_run(write, monkeypatch): + calls = [] + real = gate.pyrefly.run + + def counting(*args, **kwargs): + calls.append(args) + return real(*args, **kwargs) + + monkeypatch.setattr(gate.pyrefly, "run", counting) + gate.check([write("user.py", "def f(a: int) -> int:\n b = a + 1\n return b\n")]) + assert len(calls) == 1 + + +def test_rebinding_inside_a_package_with_relative_imports(write): + write("pkg/__init__.py", "") + write("pkg/b.py", "def make() -> int:\n return 1\n") + path = write("pkg/a.py", """ + from .b import make + + def f() -> None: + x = make() + x = "s" + print(x) + """) + result = gate.check([path]) + assert by_rule(result, REBIND), result + assert not [d for d in result if d.severity == "error"], result + + +def test_pyrefly_runs_once_when_a_file_has_a_candidate(write, monkeypatch): + """The probed copy is the only Pyrefly run: errors, any-flow and types all come from it.""" + calls = [] + real = gate.pyrefly.run + + def counting(*args, **kwargs): + calls.append(args) + return real(*args, **kwargs) + + monkeypatch.setattr(gate.pyrefly, "run", counting) + result = found(write, """ + def f() -> None: + x = 1 + x = "a" + print(x) + """) + assert by_rule(result, REBIND) + assert len(calls) == 1 + + +def test_error_column_after_a_probe_points_at_the_original_source(write): + """`x = 1; y = undefined_name`: the probe goes after `x = 1`, left of the error.""" + source = "x = 1\nx = 2\nx = 1; y = undefined_name\n" + path = write("user.py", source) + result = gate.check([path]) + [d] = [d for d in result if d.rule == "pyrefly/unknown-name"] + original_column = source.splitlines()[2].index("undefined_name") + 1 + assert (d.line, d.column) == (3, original_column) + + +def test_error_left_of_a_probe_keeps_its_column(write): + source = "x = 1\nx = 2\nprint(undefined_name); x = 3\n" + path = write("user.py", source) + [d] = [d for d in gate.check([path]) if d.rule == "pyrefly/unknown-name"] + assert (d.line, d.column) == (3, source.splitlines()[2].index("undefined_name") + 1) + + +def test_probe_loads_are_not_any_flow_findings(write): + """The probe load `; x` has type Unknown here, but it is a discarded value, not a finding.""" + path = write("user.py", "x = eval('1')\nx = eval('2')\nprint(x)\n") + flows = {(d.line, d.column) for d in gate.check([path]) if d.rule == "any-flow"} + assert flows == {(3, 7)} # the user's own load of x, not the probe loads on lines 1 and 2 diff --git a/python-multiplatform-ksp/src/test/python/test_recursion.py b/python-multiplatform-ksp/src/test/python/test_recursion.py new file mode 100644 index 000000000..db716afec --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_recursion.py @@ -0,0 +1,308 @@ +"""TypedPython recursion (issue #57, SPEC N-8): direct and mutual call cycles are compiled, and +compiled recursion raises CPython's RecursionError, never crashes the process. + +Differential: every result is compared with the same source run by the interpreter. Builds go +through the whole pipeline (source -> gate -> frontend -> verify -> cgen -> cbuild), in +`.tmp/` of the worktree via `tmp_path`. +""" +import importlib.util +import os +import subprocess +import sys +import sysconfig +import textwrap +from pathlib import Path + +import pytest + +from typedpython import pipeline + +PACKAGE = Path(__file__).resolve().parents[2] / "main" / "python" + +SOURCE = """\ +# typedpython: compiled + + +def fib(n: int) -> int: + if n < 2: + return n + return fib(n - 1) + fib(n - 2) + + +def ack(m: int, n: int) -> int: + if m == 0: + return n + 1 + if n == 0: + return ack(m - 1, 1) + return ack(m - 1, ack(m, n - 1)) + + +def is_even(n: int) -> bool: + if n == 0: + return True + return is_odd(n - 1) + + +def is_odd(n: int) -> bool: + if n == 0: + return False + return is_even(n - 1) + + +def down(n: int) -> int: + if n == 0: + return 0 + return 1 + down(n - 1) + + +def pow2(n: int) -> int: + if n == 0: + return 1 + return 2 * pow2(n - 1) + + +def fact(n: int) -> int: + if n < 2: + return 1 + return n * fact(n - 1) +""" + +NAMES = ("fib", "ack", "is_even", "is_odd", "down", "pow2", "fact") + + +def load_plain(path: Path): + spec = importlib.util.spec_from_file_location(path.stem + "_plain", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def built(tmp_path_factory): + d = tmp_path_factory.mktemp("rec") + path = d / "rec_mod.py" + path.write_text(SOURCE) + result = pipeline.compile_module(path, d / "out") + compiled = pipeline.load(result) if result.extension else None + return result, compiled, load_plain(path) + + +@pytest.fixture +def mod(built): + result, compiled, plain = built + assert result.extension is not None, result.skipped + return compiled, plain + + +def test_every_recursive_function_is_compiled(built): + result = built[0] + assert {f.name for f in result.module.functions} == set(NAMES), result.skipped + assert not any("recurs" in why for why in result.skipped.values()), result.skipped + + +def test_fib_matches_the_interpreter(mod): + compiled, plain = mod + for n in (0, 1, 2, 10, 20): + assert compiled.fib(n) == plain.fib(n) + + +def test_ackermann_matches_the_interpreter(mod): + compiled, plain = mod + for m, n in ((0, 0), (1, 3), (2, 3), (3, 3)): + assert compiled.ack(m, n) == plain.ack(m, n) + + +def test_mutual_recursion_matches_the_interpreter(mod): + compiled, plain = mod + for n in (0, 1, 2, 7, 100, 400): + assert compiled.is_even(n) is plain.is_even(n) + assert compiled.is_odd(n) is plain.is_odd(n) + + +def test_a_pure_recursive_int_function_that_overflows_returns_the_big_int(mod): + compiled, plain = mod + before = compiled.__typedpython_deopts__ + assert compiled.pow2(100) == plain.pow2(100) == 2 ** 100 + assert compiled.fact(25) == plain.fact(25) + assert compiled.__typedpython_deopts__ > before # it really deopted + assert compiled.pow2(10) == 1024 # and the fast path still works + + +def raised(fn, *args): + with pytest.raises(RecursionError) as e: + fn(*args) + return str(e.value) + + +def test_recursion_past_the_limit_raises_the_same_error_as_the_interpreter(mod): + compiled, plain = mod + assert raised(compiled.down, 10 ** 6) == raised(plain.down, 10 ** 6) == \ + "maximum recursion depth exceeded" + assert raised(compiled.is_even, 10 ** 6) == "maximum recursion depth exceeded" + + +def test_recursion_past_a_raised_limit_raises_too(mod): + compiled, plain = mod + old = sys.getrecursionlimit() + sys.setrecursionlimit(old + 3000) + try: + assert compiled.down(old + 1000) == old + 1000 # allowed by the raised limit + assert raised(compiled.down, 10 ** 6) == "maximum recursion depth exceeded" + assert raised(plain.down, 10 ** 6) == "maximum recursion depth exceeded" + finally: + sys.setrecursionlimit(old) + + +def test_a_lowered_limit_applies_to_compiled_frames(mod): + compiled, plain = mod + old = sys.getrecursionlimit() + base = len(_stack()) + sys.setrecursionlimit(base + 50) + try: + assert compiled.down(10) == 10 + assert raised(compiled.down, 200) == "maximum recursion depth exceeded" + assert raised(plain.down, 200) == "maximum recursion depth exceeded" + finally: + sys.setrecursionlimit(old) + + +def _stack(): + import inspect + return inspect.stack(0) + + +def test_the_depth_counter_is_balanced_after_every_error_exit(mod): + # Each RecursionError leaves ~1000 compiled frames; a frame whose exit skipped tp_leave_call + # would stay counted, and after a few errors even a shallow call would fail. + compiled, _ = mod + for _ in range(8): + raised(compiled.down, 10 ** 6) + raised(compiled.is_even, 10 ** 6) + assert compiled.down(500) == 500 + assert compiled.fib(15) == 610 + + +def test_the_depth_counter_is_balanced_after_a_deopt_unwinds(mod): + compiled, _ = mod + for _ in range(2000): # more frames than the limit if any deopt leaked a frame + assert compiled.pow2(70) == 2 ** 70 + assert compiled.down(500) == 500 + + +def max_ok(fn, hi=20_000): + """The largest n for which fn(n) does not raise RecursionError (binary search).""" + lo = 0 + while lo < hi: + mid = (lo + hi + 1) // 2 + try: + fn(mid) + lo = mid + except RecursionError: + hi = mid - 1 + return lo + + +def test_depth_at_which_recursion_error_fires_is_recorded(mod, capsys): + """Measures where RecursionError fires for interpreted, compiled, and mixed frames. Asserts + only that both raise and nothing crashes; the offsets are recorded in the test output + and in docs/design/typedpython.md.""" + compiled, plain = mod + interp_only = max_ok(plain.down) + compiled_only = max_ok(compiled.down) + + def mixed(k): + def run(n): + def inner(j): + if j == 0: + return compiled.down(n) + return inner(j - 1) + return inner(k) + return run + + results = {"interpreted": interp_only, "compiled": compiled_only} + for k in (1, 10, 100): + results[f"mixed k={k} interpreted frames"] = max_ok(mixed(k)) + with capsys.disabled(): + print("\nRECURSION-DEPTH-MEASUREMENT", results) + assert interp_only > 0 and compiled_only > 0 + assert all(v > 0 for v in results.values()) + # both kinds do raise past their depth + raised(plain.down, interp_only + 5) + raised(compiled.down, compiled_only + 5) + + +# --- sanitizers: the RecursionError path --------------------------------------------------------- + +ASAN_SCRIPT = textwrap.dedent('''\ + import sys + sys.path.insert(0, {pkg!r}) + from typedpython import cbuild + m = cbuild.load({name!r}, {so!r}) + for _ in range(50): + for f in (m.down, m.is_even, m.fib): + try: + f(10 ** 6) + except RecursionError as e: + assert str(e) == "maximum recursion depth exceeded", e + assert m.down(300) == 300 and m.fib(15) == 610 and m.pow2(80) == 2 ** 80 + sys.setrecursionlimit(200000) # the C-stack guard must stop it before the stack ends + try: + m.down(10 ** 7) + except RecursionError: + pass + print("ASAN-RUN-OK") + ''') + + +def test_recursion_error_path_under_asan_and_ubsan(tmp_path): + from typedpython import cbuild, cgen, frontend, verify + + cc = sysconfig.get_config_var("CC") or "cc" + if sys.platform != "darwin": + pytest.skip("sanitizer preload recipe written for macOS only (as test_cgen.py)") + probe = subprocess.run(cc.split() + ["-print-file-name=libclang_rt.asan_osx_dynamic.dylib"], + capture_output=True, text=True) + if probe.returncode != 0 or not Path(probe.stdout.strip()).is_file(): + pytest.skip("host compiler has no ASan runtime") + path = tmp_path / "rec_asan.py" + path.write_text(SOURCE) + proved, _ = verify.verify(frontend.lower(path)) + assert {f.name for f in proved.functions} == set(NAMES), proved.skipped + flags = list(cbuild.DEFAULT_FLAGS) + ["-O1", "-g", "-fno-omit-frame-pointer", + "-fsanitize=address,undefined", + "-fno-sanitize-recover=undefined"] + so = cbuild.build(cgen.generate(proved, path), "rec_asan", tmp_path, flags=flags) + launcher_c = tmp_path / "asan_python.c" + launcher_c.write_text("#include \nint main(int argc, char **argv) " + "{ return Py_BytesMain(argc, argv); }\n") + launcher = tmp_path / "asan_python" + link = subprocess.run( + cc.split() + ["-fsanitize=address,undefined", "-g", f"-I{sysconfig.get_paths()['include']}", + str(launcher_c), f"-L{sysconfig.get_config_var('LIBDIR')}", + f"-lpython{sysconfig.get_config_var('LDVERSION')}", "-o", str(launcher)], + capture_output=True, text=True) + if link.returncode != 0: + pytest.skip(f"cannot link a sanitized launcher: {link.stderr[-1500:]}") + env = dict(os.environ, PYTHONHOME=sys.base_prefix, + ASAN_OPTIONS="detect_leaks=0:abort_on_error=0:halt_on_error=1", + UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1") + env.pop("DYLD_INSERT_LIBRARIES", None) + script = ASAN_SCRIPT.format(pkg=str(PACKAGE), name="rec_asan", so=str(so)) + run = subprocess.run([str(launcher), "-c", script], capture_output=True, text=True, env=env) + out = run.stdout + run.stderr + if "violates platform policy" in out or "Interceptors are not working" in out: + pytest.skip(f"ASan runtime could not be loaded: {out[-1500:]}") + assert run.returncode == 0 and "ASAN-RUN-OK" in run.stdout, out[-5000:] + linked = subprocess.run(["otool", "-L", str(so)], capture_output=True, text=True).stdout + assert "libclang_rt.asan" in linked, linked + assert "AddressSanitizer" not in out and "runtime error" not in out, out[-3000:] + + +# --- generated code uses the helpers ------------------------------------------------------------- + +def test_every_compiled_function_enters_and_leaves(built): + from typedpython import cgen + result = built[0] + c = cgen.generate(result.module, result.source) + assert c.count("tp_enter_call()") >= len(NAMES) + assert c.count("tp_leave_call()") >= len(NAMES) diff --git a/python-multiplatform-ksp/src/test/python/test_runtime.py b/python-multiplatform-ksp/src/test/python/test_runtime.py new file mode 100644 index 000000000..c213475a9 --- /dev/null +++ b/python-multiplatform-ksp/src/test/python/test_runtime.py @@ -0,0 +1,1206 @@ +"""Differential tests of tp_runtime.h against CPython (design §4.3, #41). + +`runtime_shim/tp_shim.c` is a tiny extension that exposes every helper of +`typedpython/runtime/tp_runtime.h`; each test calls the helper and CPython's own operation on the +same inputs and compares the outcome exactly: + + * results bit-for-bit (floats compared by their IEEE bits, so -0.0 and 0.0 differ; NaN is + compared as "any NaN", payload and sign of a NaN are not part of the contract); + * exceptions by exact type AND message, taken from the interpreter running the test (so the + 3.13 and 3.14 messages are each checked against the real thing); + * the return convention: 0 ok / 1 deopt / -1 error. The shim turns a convention violation (-1 + without an exception, 0/1 with an exception set) into AssertionError. + +Deopt (rc 1) is allowed only where API.md names it; each test says when it expects it. + +Environment: set TP_SANITIZE=1 (with DYLD_INSERT_LIBRARIES pointing at the ASan runtime on macOS, +or LD_PRELOAD on Linux) to build the shim with AddressSanitizer + UBSan. +""" +from __future__ import annotations + +import importlib.util +import itertools +import math +import operator +import os +import random +import shlex +import subprocess +import struct +import sys +import sysconfig +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +KSP = HERE.parents[2] +REPO_ROOT = KSP.parent +RUNTIME_DIR = KSP / "src" / "main" / "python" / "typedpython" / "runtime" +SHIM_C = HERE / "runtime_shim" / "tp_shim.c" +BUILD_DIR = REPO_ROOT / ".tmp" / "runtime_shim_build" +HEAVY = REPO_ROOT.parent.parent / ".tmp" / "heavy.sh" + +I64_MIN = -(2**63) +I64_MAX = 2**63 - 1 +INF = math.inf +NAN = math.nan + + +# --- building the shim ------------------------------------------------------------------------ + +def _lock_already_held() -> bool: + """True if an ancestor process is heavy.sh (the whole pytest run then already holds the lock; + taking it again would deadlock).""" + pid = os.getpid() + for _ in range(40): + out = subprocess.run(["ps", "-o", "ppid=,command=", "-p", str(pid)], + capture_output=True, text=True).stdout.strip() + if not out: + return False + ppid, _, cmd = out.partition(" ") + if "heavy.sh" in cmd: + return True + pid = int(ppid) + if pid <= 1: + return False + return False + + +def _run_compile(cmd: list[str]) -> None: + if HEAVY.exists() and not _lock_already_held(): + cmd = [str(HEAVY)] + cmd + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError("shim build failed:\n" + " ".join(cmd) + "\n" + proc.stdout + proc.stderr) + + +def _build(variant: str): + suffix = sysconfig.get_config_var("EXT_SUFFIX") + sanitize = os.environ.get("TP_SANITIZE") == "1" + name = f"tp_shim_{variant}" + ("_san" if sanitize else "") + BUILD_DIR.mkdir(parents=True, exist_ok=True) + out = BUILD_DIR / (name + suffix) + cc = shlex.split(sysconfig.get_config_var("CC") or "cc") + cmd = cc + ["-std=c99", "-O1", "-g", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + "-shared", "-fPIC", f"-DTP_SHIM_NAME={name}", + "-I", sysconfig.get_paths()["include"], "-I", str(RUNTIME_DIR)] + if variant == "portable": + cmd.append("-DTP_RUNTIME_FORCE_PORTABLE_OVERFLOW") + if sanitize: + cmd += ["-fsanitize=address,undefined", "-fno-sanitize-recover=undefined", + "-fno-omit-frame-pointer"] + res = subprocess.run(cc + ["-print-resource-dir"], capture_output=True, text=True).stdout.strip() + if sys.platform == "darwin" and res: + cmd += ["-Wl,-rpath," + res + "/lib/darwin"] + if sys.platform == "darwin": + cmd += ["-undefined", "dynamic_lookup"] + cmd += [str(SHIM_C), "-o", str(out)] + _run_compile(cmd) + spec = importlib.util.spec_from_file_location(name, out) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="session", params=["default", "portable"]) +def rt(request): + """The compiled shim; built once per variant per session. `portable` forces the fallback for + the overflow builtins so both code paths of the header are exercised.""" + mod = _build(request.param) + info = mod.info() + assert bool(info["portable"]) == (request.param == "portable") + return mod + + +# --- comparison helpers ----------------------------------------------------------------------- + +def norm(v): + if isinstance(v, bool): + return ("b", v) + if isinstance(v, float): + return ("nan",) if v != v else ("f", struct.pack(" bool: + return I64_MIN <= v <= I64_MAX + + +INT_VALUES = sorted({0, 1, -1, 2, -2, 7, -7, 2**31, -(2**31), 2**31 - 1, 2**32, 2**53, -(2**53), + 2**53 + 1, -(2**53) - 1, 2**53 - 1, 2**62, -(2**62), 2**62 + 1, I64_MIN, + I64_MAX, I64_MIN + 1, I64_MAX - 1, 3, -3, 10, -10, 1000003, -1000003}) + +FLOAT_VALUES = [0.0, -0.0, 1.0, -1.0, 1.5, -1.5, 0.5, -0.5, 2.5, -2.5, 3.0, -3.0, 7.0, 0.1, -0.1, + INF, -INF, NAN, 5e-324, -5e-324, 2.2250738585072014e-308, 1e-300, -1e-300, 1e300, + -1e300, 1.7976931348623157e308, -1.7976931348623157e308, 2.0**53, -(2.0**53), + 2.0**63, -(2.0**63), 1e22, 4.5e15] + + +# --- ints ------------------------------------------------------------------------------------- + +@pytest.mark.parametrize("name,pyop", [("add_i64", operator.add), ("sub_i64", operator.sub), + ("mul_i64", operator.mul)]) +def test_checked_arithmetic_matches_bigint_and_deopts_exactly_on_overflow(rt, name, pyop): + fn = getattr(rt, name) + cases = [] + for a, b in itertools.product(INT_VALUES, repeat=2): + exact = pyop(a, b) + cases.append(((a, b), ("i", exact) if fits(exact) else ("deopt",), sh(fn, a, b))) + check(cases, name) + + +def test_neg_i64_deopts_only_for_int64_min(rt): + cases = [] + for a in INT_VALUES: + cases.append(((a,), ("i", -a) if fits(-a) else ("deopt",), sh(rt.neg_i64, a))) + check(cases, "neg_i64") + assert sh(rt.neg_i64, I64_MIN) == ("deopt",) + + +def test_fuzz_checked_arithmetic_against_bigint(rt): + rnd = random.Random(20261003) + cases = [] + for _ in range(4000): + bits_a, bits_b = rnd.choice([8, 31, 32, 53, 62, 63]), rnd.choice([8, 31, 32, 53, 62, 63]) + a = rnd.randint(-(2**bits_a), 2**bits_a) + b = rnd.randint(-(2**bits_b), 2**bits_b) + a, b = max(I64_MIN, min(I64_MAX, a)), max(I64_MIN, min(I64_MAX, b)) + for name, pyop in (("add_i64", operator.add), ("sub_i64", operator.sub), + ("mul_i64", operator.mul)): + exact = pyop(a, b) + cases.append(((name, a, b), ("i", exact) if fits(exact) else ("deopt",), + sh(getattr(rt, name), a, b))) + check(cases, "fuzz") + + +def test_floordiv_i64_is_python_floor_division(rt): + cases = [] + for a, b in itertools.product(INT_VALUES, repeat=2): + ref = py(operator.floordiv, a, b) + if ref[0] == "i" and not fits(ref[1]): + ref = ("deopt",) # only MIN // -1 + cases.append(((a, b), ref, sh(rt.floordiv_i64, a, b))) + check(cases, "floordiv_i64") + assert sh(rt.floordiv_i64, I64_MIN, -1) == ("deopt",) + + +def test_mod_i64_has_the_sign_of_the_divisor(rt): + cases = [((a, b), py(operator.mod, a, b), sh(rt.mod_i64, a, b)) + for a, b in itertools.product(INT_VALUES, repeat=2)] + check(cases, "mod_i64") + assert sh(rt.mod_i64, -7, 2) == ("i", 1) + assert sh(rt.mod_i64, 7, -2) == ("i", -1) + assert sh(rt.mod_i64, I64_MIN, -1) == ("i", 0) # CPython: 0 (the C % would trap) + + +def test_int_division_by_zero_messages_are_cpythons(rt): + for fn, op in ((rt.floordiv_i64, operator.floordiv), (rt.mod_i64, operator.mod), + (rt.truediv_i64, operator.truediv)): + for a in (0, 1, -5, I64_MAX, I64_MIN): + exp, got = py(op, a, 0), sh(fn, a, 0) + assert exp[0] == "exc" and exp[1] is ZeroDivisionError + assert got == exp, (op, a, exp, got) + + +def test_fuzz_floordiv_mod_i64(rt): + rnd = random.Random(7) + cases = [] + for _ in range(4000): + a = rnd.randint(-(2**rnd.choice([4, 31, 63])), 2**rnd.choice([4, 31, 63]) - 1) + b = rnd.randint(-(2**rnd.choice([2, 9, 31, 63])), 2**rnd.choice([2, 9, 31, 63]) - 1) + a, b = max(I64_MIN, min(I64_MAX, a)), max(I64_MIN, min(I64_MAX, b)) + for name, op in (("floordiv_i64", operator.floordiv), ("mod_i64", operator.mod)): + ref = py(op, a, b) + if ref[0] == "i" and not fits(ref[1]): + ref = ("deopt",) + cases.append(((name, a, b), ref, sh(getattr(rt, name), a, b))) + check(cases, "fuzz floordiv/mod") + + +TRUEDIV_INTS = INT_VALUES + [2**53 - 2, 2**53 + 2, -(2**53) + 1, 2**53 + 3, 9007199254740993, 5, + -5, 6, 49, 355, 113] + + +def test_truediv_i64_is_correctly_rounded_or_deopts_above_2_53(rt): + cases = [] + for a, b in itertools.product(TRUEDIV_INTS, repeat=2): + ref = py(operator.truediv, a, b) + if ref[0] != "exc" and (abs(a) > 2**53 or abs(b) > 2**53): + ref = ("deopt",) + cases.append(((a, b), ref, sh(rt.truediv_i64, a, b))) + check(cases, "truediv_i64") + # the boundary itself is exact-rounded by the C division + assert sh(rt.truediv_i64, 2**53, 3) == py(operator.truediv, 2**53, 3) + assert sh(rt.truediv_i64, 2**53 + 1, 3) == ("deopt",) + assert sh(rt.truediv_i64, 1, -(2**53) - 1) == ("deopt",) + assert sh(rt.truediv_i64, 0, -5) == ("f", struct.pack(" +0.0 (sign of divisor) + assert sh(rt.mod_f64, 1.0, -1.0) == ("f", nz) + assert sh(rt.mod_f64, -7.5, 2.0) == norm(-7.5 % 2.0) + assert sh(rt.floordiv_f64, -0.0, 5.0) == norm(-0.0 // 5.0) + assert sh(rt.floordiv_f64, 1.0, -INF) == norm(1.0 // -INF) + + +# --- int/float exact comparison --------------------------------------------------------------- + +OPS = [(0, operator.lt), (1, operator.le), (2, operator.eq), (3, operator.ne), (4, operator.gt), + (5, operator.ge)] +CMP_INTS = INT_VALUES + [2**53 + 2, 2**63 - 1024, -(2**63) + 1024, 2**63 - 513, 9223372036854774784, + 4503599627370497, 9007199254740992] +CMP_FLOATS = FLOAT_VALUES + [float(2**53), float(2**53 + 2), 9.223372036854776e18, + -9.223372036854776e18, 9.223372036854775e18, 9223372036854774784.0, + 0.1 + 0.2, 2.0**62, 4503599627370496.5, -4503599627370496.5, + 9007199254740993.0, 1e19, -1e19, 2.0**63 - 1024] + + +def test_int_float_comparison_is_exact_for_every_operator(rt): + assert op_name_sanity() + cases = [] + for a, b in itertools.product(CMP_INTS, CMP_FLOATS): + for code, pyop in OPS: + cases.append(((a, b, code), int(pyop(a, b)), rt.cmp_i64_f64(a, b, code))) + cases.append(((b, a, code), int(pyop(b, a)), rt.cmp_f64_i64(b, a, code))) + check(cases, "cmp") + + +def op_name_sanity(): + import _operator # noqa: F401 + return True + + +def test_int_float_comparison_named_hard_cases(rt): + # 2**53 + 1 is not equal to float(2**53), although (double)(2**53+1) == 2**53 + assert rt.cmp_i64_f64(2**53 + 1, float(2**53), 2) == 0 + assert rt.cmp_i64_f64(2**53 + 1, float(2**53), 4) == 1 + assert rt.cmp_i64_f64(I64_MAX, 9.223372036854776e18, 0) == 1 # INT64_MAX < 2**63 + assert rt.cmp_i64_f64(I64_MAX, 9.223372036854776e18, 2) == 0 + assert rt.cmp_i64_f64(I64_MIN, -9.223372036854776e18, 2) == 1 # -2**63 == -2.0**63 + for code, _ in OPS: + assert rt.cmp_i64_f64(1, NAN, code) == (1 if code == 3 else 0) + assert rt.cmp_f64_i64(NAN, 1, code) == (1 if code == 3 else 0) + assert rt.cmp_i64_f64(I64_MAX, INF, 0) == 1 and rt.cmp_i64_f64(I64_MIN, -INF, 4) == 1 + + +# --- math ------------------------------------------------------------------------------------- + +MATH1 = ["sqrt", "exp", "log", "sin", "cos", "tan", "fabs"] +MATH_VALUES = FLOAT_VALUES + [1000.0, -1000.0, 709.0, 709.782712893384, 709.7827128933841, -745.0, + -746.0, -1e5, 1e5, 0.5 * math.pi, math.pi, 1e22, 1e15, 100.0, + -100.0, 710.0] + + +@pytest.mark.parametrize("name", MATH1) +def test_math_one_argument_matches_math_module_including_messages(rt, name): + ref_fn = getattr(math, name) + cases = [((x,), py(ref_fn, x), sh(rt.math1, name, x)) for x in MATH_VALUES] + check(cases, "math." + name) + + +@pytest.mark.parametrize("name", ["sqrt", "exp", "log", "sin", "cos", "tan"]) +def test_math_one_argument_fuzz_is_bit_exact(rt, name): + rnd = random.Random(hash(name) & 0xFFFF) + ref_fn = getattr(math, name) + xs = [rnd.uniform(-1, 1) * 10.0 ** rnd.randint(-10, 10) for _ in range(3000)] + cases = [((x,), py(ref_fn, x), sh(rt.math1, name, x)) for x in xs] + check(cases, "fuzz math." + name) + + +def test_math_named_error_cases(rt): + # the rows named in the task, spelled out: type+message are whatever this interpreter says + for name, x in [("sqrt", -1.0), ("sqrt", -0.0), ("exp", 1000.0), ("log", 0.0), ("log", -1.0), + ("log", -INF), ("sin", INF), ("cos", -INF), ("tan", INF), ("sqrt", -INF)]: + exp, got = py(getattr(math, name), x), sh(rt.math1, name, x) + assert got == exp, (name, x, exp, got) + assert sh(rt.math1, "sqrt", -0.0) == ("f", struct.pack(" 0) or +# [lo(stop)+1, hi(start)] (step < 0); Len [0, 2**60-1]; ADD/SUB/MUL/NEG/POS by interval arithmetic; +# MOD by a divisor whose interval is positive [0, hi(divisor)-1]; FLOORDIV by a positive divisor +# interval by its endpoints; everything else (params, entry globals, other locals, Index, Unbox, +# Call) the full i64 range. + +def _ranged(op, start=0, stop=5, step=1, impure=True, pure=False, may_deopt=False, params=()): + """for k in range(start, stop, step): t = in an (impure) function""" + body = ([IMPURE] if impure else []) + [ + ForRange("k", i(start) if isinstance(start, int) else start, + i(stop) if isinstance(stop, int) else stop, i(step), (Assign("t", op(li("k"))),)), + Return(), + ] + return fn(params=params, locals_={"k": I64, "t": I64}, pure=pure, may_deopt=may_deopt, + body=body) + + +def test_proven_add_of_a_small_range_var_is_accepted_in_an_impure_function(): + assert_accepted(_ranged(lambda k: padd(k, i(1)))) + + +def test_proven_add_of_two_i64_params_is_rejected(): + ps = [Param("a", I64), Param("b", I64)] + assert_rejected(PROVEN, fn(params=ps, returns=I64, body=[IMPURE, Return(padd(li("a"), li("b")))])) + assert_rejected(PROVEN, fn(params=ps, returns=I64, pure=True, + body=[Return(padd(li("a"), li("b")))])) + + +def test_unproven_i64_ops_still_deopt_in_an_impure_function(): + assert_rejected(DEOPT, _ranged(lambda k: add(k, i(1)))) + assert_rejected(DEOPT, _ranged(lambda k: UnaryOp(I64, UnaryOpKind.NEG, k))) + + +def test_a_proven_op_is_not_a_deopting_node(): + # pure function whose only i64 op is proven: it cannot deopt, so may_deopt must be False + ok = _ranged(lambda k: padd(k, i(1)), impure=False, pure=True, may_deopt=False) + assert_accepted(ok) + assert_rejected(FLAG_DEOPT, dataclasses.replace(ok, may_deopt=True)) + + +@pytest.mark.parametrize("op", [ + lambda k: psub(k, i(1)), + lambda k: pmul(k, k), + lambda k: pneg(k), + lambda k: UnaryOp(I64, UnaryOpKind.NEG, k, proven=True), + lambda k: padd(padd(k, i(1)), pmul(k, i(3))), # proven ops nest +]) +def test_other_provable_ops_are_accepted(op): + assert_accepted(_ranged(op)) + + +def test_proven_neg_of_a_param_is_rejected(): + # -(-2**63) overflows + assert_rejected(PROVEN, fn(params=[Param("n", I64)], returns=I64, body=[IMPURE, Return(pneg(li("n")))])) + + +def test_proven_on_a_node_that_has_no_overflow_is_rejected(): + ps = [Param("x", F64), Param("n", I64)] + assert_rejected(PROVEN, fn(params=ps, returns=F64, + body=[Return(BinOp(F64, BinOpKind.ADD, lf("x"), lf("x"), proven=True))])) + assert_rejected(PROVEN, fn(params=ps, returns=I64, pure=True, may_deopt=True, body=[ + Return(BinOp(I64, BinOpKind.FLOORDIV, li("n"), i(2), proven=True))])) + assert_rejected(PROVEN, fn(params=ps, returns=I64, body=[ + Return(UnaryOp(I64, UnaryOpKind.POS, li("n"), proven=True))])) + + +def test_a_non_bool_proven_flag_is_rejected(): + out, diags = run(_ranged(lambda k: BinOp(I64, BinOpKind.ADD, k, i(1), proven=1))) + assert "f" not in kept(out) + + +def test_range_var_interval_uses_the_stop_bound(): + assert_accepted(_ranged(lambda k: padd(k, i(2 ** 63 - 5)), stop=5)) # k <= 4 + assert_rejected(PROVEN, _ranged(lambda k: padd(k, i(2 ** 63 - 4)), stop=5)) # 4 + (2**63-4) + + +def test_range_var_interval_uses_the_start_bound(): + # range(-5, 5): k >= -5 + assert_accepted(_ranged(lambda k: psub(k, i(2 ** 63 - 5)), start=-5)) # -5 - (2**63-5) + assert_rejected(PROVEN, _ranged(lambda k: psub(k, i(2 ** 63 - 4)), start=-5)) + + +def test_negative_step_bounds(): + # range(10, -1, -1): k in [0, 10] + assert_accepted(_ranged(lambda k: psub(k, i(2 ** 63 - 1)), start=10, stop=-1, step=-1)) + assert_rejected(PROVEN, _ranged(lambda k: padd(k, i(2 ** 63 - 10)), start=10, stop=-1, step=-1)) + # range(n, 0, -1) with n a param: k in [1, 2**63-1]: k - 1 is provable, k + 1 is not + ps = [Param("n", I64)] + assert_accepted(_ranged(lambda k: psub(k, i(1)), start=li("n"), stop=0, step=-1, params=ps)) + assert_rejected(PROVEN, _ranged(lambda k: padd(k, i(1)), start=li("n"), stop=0, step=-1, + params=ps)) + + +def test_range_up_to_a_param_bounds_the_var_by_the_param_range(): + # range(0, n): k <= 2**63-2, so k + 1 fits but k + 2 may not + ps = [Param("n", I64)] + assert_accepted(_ranged(lambda k: padd(k, i(1)), stop=li("n"), params=ps)) + assert_rejected(PROVEN, _ranged(lambda k: padd(k, i(2)), stop=li("n"), params=ps)) + + +def test_len_bound_is_zero_to_two_to_the_62(): + a = [ArrayParam("a", F64A, stored=False)] + L = Len(I64, "a") + assert_accepted(fn(params=a, returns=I64, body=[IMPURE, Return(padd(L, i(2 ** 62 - 1)))])) + assert_rejected(PROVEN, fn(params=a, returns=I64, + body=[IMPURE, Return(padd(L, i(2 ** 62)))])) + assert_rejected(PROVEN, fn(params=a, returns=I64, body=[IMPURE, Return(pmul(L, i(2)))])) + assert_accepted(fn(params=a, returns=I64, body=[IMPURE, Return(pmul(L, i(-2)))])) # -2**63 + assert_rejected(PROVEN, fn(params=a, returns=I64, body=[IMPURE, Return(pmul(L, i(-3)))])) + + +def test_mod_and_floordiv_are_full_range_so_nothing_is_provable_from_them(): + ps = [Param("n", I64)] + m = BinOp(I64, BinOpKind.MOD, li("n"), i(7)) + assert_rejected(PROVEN, fn(params=ps, returns=I64, pure=True, may_deopt=True, + body=[Return(padd(m, i(1)))])) + + +def test_what_the_intervals_cannot_prove(): + # a local assigned a constant is still full range (locals other than loop vars are not tracked) + assert_rejected(PROVEN, fn(locals_={"x": I64}, returns=I64, + body=[IMPURE, Assign("x", i(1)), Return(padd(li("x"), i(1)))])) + # the loop var after its loop is full range + assert_rejected(PROVEN, fn(locals_={"k": I64}, returns=I64, body=[ + IMPURE, Assign("k", i(0)), ForRange("k", i(0), i(5), i(1), ()), Return(padd(li("k"), i(1)))])) + # an i64 array element is full range + assert_rejected(PROVEN, fn(params=[ArrayParam("a", I64A, stored=False)], returns=I64, + body=[IMPURE, Return(padd(Index(I64, "a", i(0)), i(1)))])) + + +def test_proven_flag_survives_verification(): + out = assert_accepted(_ranged(lambda k: padd(k, i(1)))) + loop = out.functions[0].body[1] + assert loop.body[0].value.proven is True + + +# --- 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`.""" + 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)]) + if not pure: + body = [IMPURE] + body + return fn(name, params=[Param("n", I64)], returns=returns, pure=pure, may_deopt=may_deopt, + body=body) + + +def _redo_caller(returns=F64, pure=False, may_deopt=False, redo=True): + call = Call(returns, "g", (i(3),), redo=redo) + body = [ExprStmt(call), Return()] + if not pure: + body = [IMPURE] + body + return fn("f", pure=pure, may_deopt=may_deopt, body=body) + + +@pytest.mark.parametrize("returns", [F64, BOOL, NONE]) +def test_redo_from_an_impure_caller_to_a_pure_may_deopt_callee_is_accepted(returns): + assert_accepted(_redo_caller(returns), _callee(returns=returns)) + + +def test_redo_to_an_i64_returning_callee_is_rejected(): + out, _ = assert_rejected(REDO, _redo_caller(I64), _callee(returns=I64)) + assert "g" in kept(out) + + +def test_redo_in_a_pure_caller_is_rejected(): + assert_rejected(REDO, _redo_caller(pure=True, may_deopt=True), _callee()) + assert_rejected(REDO, _redo_caller(pure=True, may_deopt=False), _callee()) + + +def test_redo_to_a_callee_that_cannot_deopt_is_rejected(): + assert_rejected(REDO, _redo_caller(), _callee(may_deopt=False)) + + +def test_redo_to_an_impure_callee_is_rejected(): + assert_rejected(REDO, _redo_caller(), _callee(pure=False, may_deopt=False)) + + +def test_redo_does_not_make_the_caller_may_deopt(): + assert_rejected(FLAG_DEOPT, _redo_caller(may_deopt=True), _callee()) + + +def test_the_same_call_without_redo_is_a_deopt_in_an_impure_caller(): + assert_rejected(DEOPT, _redo_caller(redo=False), _callee()) + + +def test_a_non_bool_redo_flag_is_rejected(): + out, _ = run(dataclasses.replace(_redo_caller(), body=( + IMPURE, ExprStmt(Call(F64, "g", (i(3),), redo=1)), Return())), _callee()) + assert "f" not in kept(out) + + +# --- 9. Function.entry_globals -------------------------------------------------------------------- + +def _closed(entry=(Param("N", I64),), extra=(), returns=F64, name="f"): + """A closed, impure function: for k in range(0, N): a[k] = a[k] * SCALE (SCALE an entry global + when listed).""" + a = ArrayParam("a", F64A, stored=True) + return fn(name, params=[a], entry_globals=entry, locals_={"k": I64}, returns=returns, body=[ + *extra, + ForRange("k", i(0), Len(I64, "a"), i(1), ( + StoreIndex("a", li("k"), BinOp(F64, BinOpKind.MUL, Index(F64, "a", li("k")), + ToFloat(F64, li("N")))),)), + Return(f(0.0)) if returns is F64 else Return(), + ]) + + +def test_entry_global_in_a_closed_function_is_accepted(): + assert_accepted(_closed()) + + +def test_entry_globals_are_assigned_at_entry(): + assert_accepted(fn(entry_globals=[Param("G", F64), Param("B", BOOL)], returns=F64, + body=[If(lb("B"), (Return(lf("G")),)), Return(f(1.0))])) + + +def test_entry_global_in_a_pure_function_is_accepted(): + assert_accepted(fn(entry_globals=[Param("G", F64)], returns=F64, pure=True, + body=[Return(lf("G"))])) + + +@pytest.mark.parametrize("node", [ + ExprStmt(CallObject(OBJ, Global(OBJ, "print"), (), ())), + ExprStmt(GetAttr(OBJ, Global(OBJ, "m"), "x")), + If(Truth(BOOL, Global(OBJ, "m")), ()), + If(CompareObj(BOOL, CompareKind.EQ, Global(OBJ, "m"), Box(OBJ, Const(I64, 1))), ()), + ExprStmt(ObjToFloat(F64, Global(OBJ, "m"))), + ExprStmt(BinOp(OBJ, BinOpKind.ADD, Global(OBJ, "m"), Global(OBJ, "m"))), +]) +def test_entry_global_in_a_function_running_user_code_is_rejected(node): + assert_rejected(ENTRY_OPEN, _closed(extra=[node])) + + +def test_the_same_open_node_without_entry_globals_is_accepted(): + # The open node alone is fine: only entry globals need the function closed. + a = ArrayParam("a", F64A, stored=False) + assert_accepted(fn(params=[a], returns=F64, body=[ + ExprStmt(CallObject(OBJ, Global(OBJ, "print"), (), ())), Return(Index(F64, "a", i(0)))])) + + +def test_global_read_and_box_keep_a_function_closed(): + assert_accepted(_closed(extra=[ExprStmt(Global(OBJ, "m")), ExprStmt(Box(OBJ, li("N")))])) + + +def test_entry_global_needs_every_callee_closed_transitively(): + h_open = fn("h", body=[IMPURE, Return()]) + h_closed = fn("h", body=[Return()]) + g = fn("g", body=[ExprStmt(Call(NONE, "h", ())), Return()]) + caller = _closed(extra=[ExprStmt(Call(NONE, "g", ()))]) + assert_accepted(caller, g, h_closed) + out, _ = assert_rejected(ENTRY_OPEN, caller, g, h_open) + assert "g" in kept(out) and "h" in kept(out) + + +def test_entry_global_must_be_a_scalar_param(): + assert_rejected(TYPE, _closed(entry=(Param("N", OBJ),))) + assert_rejected(TYPE, fn(entry_globals=[Param("A", F64A)], body=[Return()])) + assert_rejected(TYPE, fn(entry_globals=[Param("A", NONE)], body=[Return()])) + out, _ = run(fn(entry_globals=[ArrayParam("A", F64A, stored=False)], body=[Return()])) + assert "f" not in kept(out) + out, _ = run(fn(entry_globals=["N"], body=[Return()])) + assert "f" not in kept(out) + + +def test_entry_global_names_must_not_collide(): + assert_rejected(STRUCTURE, _closed(entry=(Param("a", I64),))) # an array param + assert_rejected(STRUCTURE, fn(params=[Param("N", I64)], entry_globals=[Param("N", I64)], + body=[Return()])) + assert_rejected(STRUCTURE, fn(locals_={"N": I64}, entry_globals=[Param("N", I64)], + body=[Return()])) + assert_rejected(STRUCTURE, fn(entry_globals=[Param("N", I64), Param("N", I64)], + body=[Return()])) + + +def test_entry_global_is_read_as_a_local_of_its_type(): + assert_rejected(TYPE, fn(entry_globals=[Param("G", F64)], returns=I64, + body=[Return(li("G"))])) + + +def test_assigning_an_entry_global_is_rejected(): + assert_rejected(STRUCTURE, fn(entry_globals=[Param("G", F64)], + body=[Assign("G", f(1.0)), Return()])) + assert_rejected(STRUCTURE, fn(entry_globals=[Param("N", I64)], body=[ + ForRange("N", i(0), i(3), i(1), ()), Return()])) + + +def test_entry_global_i64_is_full_range(): + assert_rejected(PROVEN, _closed(extra=[ExprStmt(padd(li("N"), i(1)))])) + + +def test_call_of_a_function_with_entry_globals_from_an_impure_caller(): + # The callee's entry guard can fail in the middle of the caller: that is a deopt there, + # unless the caller already guarded the same global (and, being closed, cannot rebind it). + g = fn("g", entry_globals=[Param("G", F64)], returns=F64, pure=True, body=[Return(lf("G"))]) + impure = fn("f", returns=F64, body=[IMPURE, Return(Call(F64, "g", ()))]) + assert_rejected(DEOPT, impure, g) + covered = fn("f", params=[ArrayParam("a", F64A, stored=True)], + entry_globals=[Param("G", F64)], returns=F64, + body=[StoreIndex("a", i(0), Call(F64, "g", ())), Return(lf("G"))]) + assert_accepted(covered, g) + wrong_type = dataclasses.replace(covered, entry_globals=(Param("G", I64),), + body=(StoreIndex("a", i(0), Call(F64, "g", ())), + Return(f(0.0)))) + assert_rejected(DEOPT, wrong_type, g) + assert_accepted(fn("f", returns=F64, pure=True, may_deopt=True, + body=[Return(Call(F64, "g", ()))]), g) + + +# --- "nothing slips through": seeded IR mutation ------------------------------------------------- + +def _base_functions(): + """Two correct functions exercising every node kind; the mutator breaks them.""" + pure = fn("dot", params=[ArrayParam("a", F64A, stored=False), + ArrayParam("w", F64A, stored=False), Param("n", I64)], + locals_={"s": F64, "k": I64, "c": I64, "t": OBJ}, returns=F64, pure=True, + may_deopt=True, line=3, body=[ + Assign("s", f(0.0)), + Assign("c", i(0)), + Assign("t", g_("TOLERANCE")), + ForRange("k", i(0), Len(I64, "a"), i(1), ( + If(And(BOOL, lt(li("k"), li("n")), lt(Index(F64, "a", li("k")), f(1e9))), + (Assign("s", add(lf("s"), BinOp(F64, BinOpKind.MUL, + Index(F64, "a", li("k")), + Index(F64, "w", i(0))), F64)),), + (Continue(),)), + Assign("c", add(li("c"), i(1))), + )), + Return(add(lf("s"), MathCall(F64, MathFunc.SQRT, (ToFloat(F64, li("c")),)), + F64)), + ]) + impure = fn("scale", params=[ArrayParam("a", F64A, stored=True), Param("x", F64), + Param("o", OBJ), Param("c", BOOL)], + locals_={"k": I64, "r": OBJ, "y": F64}, returns=F64, line=20, body=[ + Assign("r", call_obj(GetAttr(OBJ, g_("Modifier"), "padding"), + Box(OBJ, lf("x")), lo("o"), kw=("dp",))), + Assign("y", Unbox(F64, lo("r"))), + If(Truth(BOOL, lo("r")), (Assign("y", ObjToFloat(F64, lo("o"))),)), + ForRange("k", i(0), Len(I64, "a"), i(1), ( + StoreIndex("a", li("k"), BinOp(F64, BinOpKind.MUL, + Index(F64, "a", li("k")), lf("y"))), + )), + While(And(BOOL, Compare(BOOL, CompareKind.GT, lf("y"), f(1.0)), + CompareObj(BOOL, CompareKind.NE, lo("o"), lo("r"))), ( + Assign("y", BinOp(F64, BinOpKind.TRUEDIV, lf("y"), f(2.0))), + Assign("r", BinOp(OBJ, BinOpKind.ADD, lo("r"), lo("o"))), + If(UnaryOp(BOOL, UnaryOpKind.NOT, lt(lf("y"), f(100.0))), (Break(),)), + )), + Return(lf("y")), + ]) + return pure, impure + + +def _paths(node, pred, path=()): + """Every (path, node) below `node` whose node satisfies pred. A path is a tuple of + field names / tuple indices.""" + out = [] + if pred(node): + out.append((path, node)) + if dataclasses.is_dataclass(node) and not isinstance(node, type): + for fl in dataclasses.fields(node): + out += _paths(getattr(node, fl.name), pred, path + (fl.name,)) + elif isinstance(node, tuple): + for k, x in enumerate(node): + out += _paths(x, pred, path + (k,)) + return out + + +def _replace(node, path, new): + if not path: + return new + head, rest = path[0], path[1:] + if isinstance(node, tuple): + return node[:head] + (_replace(node[head], rest, new),) + node[head + 1:] + return dataclasses.replace(node, **{head: _replace(getattr(node, head), rest, new)}) + + +def _is_expr(n): return isinstance(n, ir.Expr) + + +def _replace_body(g, path, new): + return dataclasses.replace(g, body=_replace(g.body, path, new)) + + +def _breaking_mutations(rng, g): + """Return (description, mutant, rules): the mutant breaks a property by construction; rules + is the set of rule ids any one of which is an acceptable report.""" + exprs = _paths(g.body, _is_expr) + path, e = rng.choice(exprs) + kind = rng.randrange(14) + if kind == 0: # read of an undeclared/unassigned local of the right type + return "unassigned read", _replace_body(g, path, Local(e.type, "zz_unset")), { + DA, TYPE, STRUCTURE} + if kind == 1: # change the declared type of a node + other = rng.choice([t for t in (I64, F64, BOOL, OBJ) if t is not e.type]) + return "retype", _replace_body(g, path, dataclasses.replace(e, type=other)), { + TYPE, OWNERSHIP, STRUCTURE} + if kind == 2: # substitute an unknown node + return "unknown", _replace_body(g, path, Weird(e.type)), {UNKNOWN, STRUCTURE} + if kind == 3: # flip pure + return "flip pure", dataclasses.replace(g, pure=not g.pure), {FLAG_PURE, DEOPT, FLAG_DEOPT} + if kind == 4: # flip may_deopt + return "flip may_deopt", dataclasses.replace(g, may_deopt=not g.may_deopt), { + FLAG_DEOPT, DEOPT} + if kind == 5: # flip stored on the first array + k = next(j for j, p in enumerate(g.params) if isinstance(p, ArrayParam)) + p = g.params[k] + ps = g.params[:k] + (dataclasses.replace(p, stored=not p.stored),) + g.params[k + 1:] + return "flip stored", dataclasses.replace(g, params=ps), {ARRAY_STORED, FLAG_PURE} + if kind == 6: # change the return type + other = rng.choice([t for t in (I64, F64, BOOL, OBJ, NONE) if t is not g.returns]) + return "returns", dataclasses.replace(g, returns=other), {TYPE, OWNERSHIP, MISSING_RETURN} + if kind == 7: # drop a declared local + name = rng.choice(sorted(g.locals)) + loc = {k: v for k, v in g.locals.items() if k != name} + return "drop local", dataclasses.replace(g, locals=loc), {TYPE, STRUCTURE} + if kind == 8: # call an unknown function + return "unknown call", _replace_body(g, path, Call(e.type, "nowhere", ())), { + UNRESOLVED, STRUCTURE} + if kind == 9: # a store into the pure function, or an I64 add into the impure one + if g.pure: + st = StoreIndex(next(p.name for p in g.params if isinstance(p, ArrayParam)), i(0), + f(0.0)) + return "store in pure", dataclasses.replace(g, body=(st,) + g.body), { + FLAG_PURE, ARRAY_STORED} + st = ExprStmt(add(i(1), i(2))) + return "deopt in impure", dataclasses.replace(g, body=(st,) + g.body), {DEOPT} + if kind == 10: # a Break/Continue at function level + return "stray break", dataclasses.replace( + g, body=(rng.choice([Break(), Continue()]),) + g.body), {STRUCTURE} + if kind == 11: # an effecting object node into the pure function / a value-position CompareObj + if g.pure: + node = rng.choice([GetAttr(OBJ, lo("t"), "v"), call_obj(lo("t")), Truth(BOOL, lo("t")), + ObjToFloat(F64, lo("t")), BinOp(OBJ, BinOpKind.ADD, lo("t"), + lo("t"))]) + return "effect in pure", dataclasses.replace( + g, body=g.body[:3] + (ExprStmt(node),) + g.body[3:]), {FLAG_PURE} + cmp = CompareObj(BOOL, CompareKind.EQ, lo("o"), lo("o")) + return "compare_obj value", dataclasses.replace( + g, locals={**g.locals, "q": BOOL}, body=(Assign("q", cmp),) + g.body), {CONDITION_ONLY} + if kind == 12: # a scalar into an OBJ slot (unbox removed / box removed) + boxes = _paths(g.body, lambda n: type(n) in (Box, Unbox, ObjToFloat)) + if boxes: + bpath, bx = rng.choice(boxes) + 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 + # 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} + + +def _preserving_mutations(rng, g): + """Rewrites that keep every property; the verifier must still accept them.""" + kind = rng.randrange(4) + if kind == 0: # wrap the body in `if True:` on both branches + return "if-true wrap", dataclasses.replace(g, body=(If(b(True), g.body, g.body),)) + if kind == 1: # an extra declared, assigned, unused local + return "dead local", dataclasses.replace( + g, locals={**g.locals, "zz": F64}, body=(Assign("zz", f(0.5)),) + g.body) + if kind == 2: # a dead statement after the return + return "dead tail", dataclasses.replace(g, body=g.body + (ExprStmt(f(1.0)),)) + # swap the operands of a Compare / CompareObj (types stay valid) + cmps = _paths(g.body, lambda n: type(n) in (Compare, CompareObj)) + path, c = rng.choice(cmps) + flipped = {CompareKind.LT: CompareKind.GT, CompareKind.GT: CompareKind.LT}.get(c.op, c.op) + return "flip compare", _replace_body(g, path, type(c)(BOOL, flipped, c.right, c.left)) + + +def test_base_functions_are_accepted(): + out = assert_accepted(*_base_functions()) + dot, scale = out.functions + assert [a.proven for a in _accesses(dot)] == [True, True, False] + assert [a.proven for a in _accesses(scale)] == [True, True] + + +@pytest.mark.parametrize("seed", [20261003]) +def test_no_breaking_mutation_slips_through(seed): + rng = random.Random(seed) + base = _base_functions() + missed, seen = [], set() + for n in range(200): + g = base[rng.randrange(2)] + desc, mutant, allowed = _breaking_mutations(rng, g) + seen.add(desc) + others = [h for h in base if h.name != g.name] + out, diags = run(*others, mutant) + got = rules_for(diags, g.name) + if g.name in kept(out) or not (got & allowed): + missed.append((n, desc, g.name, sorted(got))) + assert missed == [] + assert len(seen) >= 12 # the seed exercised most mutation kinds + + +@pytest.mark.parametrize("seed", [7]) +def test_preserving_rewrites_are_accepted(seed): + rng = random.Random(seed) + base = _base_functions() + rejected = [] + for n in range(100): + g = base[rng.randrange(2)] + desc, mutant = _preserving_mutations(rng, g) + out, diags = run(*[h for h in base if h.name != g.name], mutant) + if g.name not in kept(out): + rejected.append((n, desc, [dataclasses.astuple(d) for d in diags])) + assert rejected == [] + + +# --- mutation of proven / redo / entry_globals --------------------------------------------------- + +def _proof_functions(): + """Correct functions exercising proven ops, redo and entry globals; the mutator breaks them.""" + h = fn("h", params=[Param("n", I64)], returns=F64, pure=True, may_deopt=True, line=1, + body=[Return(ToFloat(F64, BinOp(I64, BinOpKind.MUL, li("n"), i(2))))]) + q = fn("q", params=[Param("n", I64)], returns=I64, pure=True, may_deopt=True, line=2, + body=[Return(add(li("n"), i(1)))]) + pc = fn("pc", returns=F64, pure=True, may_deopt=True, line=3, + body=[Return(Call(F64, "h", (i(3),)))]) + fill = fn("fill", params=[ArrayParam("a", F64A, stored=True)], + entry_globals=[Param("N", I64), Param("S", F64)], + locals_={"k": I64, "t": I64, "v": I64, "u": F64}, returns=F64, line=4, body=[ + ForRange("k", i(0), Len(I64, "a"), i(1), ( + Assign("t", psub(pmul(li("k"), i(2)), i(1))), + Assign("v", pneg(padd(li("k"), i(1)))), + StoreIndex("a", li("k"), BinOp(F64, BinOpKind.MUL, Index(F64, "a", li("k")), + ToFloat(F64, li("t")))), + )), + Assign("u", Call(F64, "h", (i(3),), redo=True)), + Return(BinOp(F64, BinOpKind.ADD, BinOp(F64, BinOpKind.ADD, lf("u"), lf("S")), + ToFloat(F64, li("N")))), + ]) + talk = fn("talk", returns=NONE, line=5, body=[IMPURE, Return()]) + return {x.name: x for x in (h, q, pc, fill, talk)} + + +def _proof_mutations(rng, base): + """(description, mutated functions by name, the function that must be rejected, the rules any + one of which is an acceptable report).""" + b = dict(base) + kind = rng.randrange(14) + if kind == 0: # widen a proven op's operand to a full-range entry global + paths = _paths(b["fill"].body, lambda n: type(n) is BinOp and n.proven) + path, node = rng.choice(paths) + wide = BinOp(I64, rng.choice([BinOpKind.ADD, BinOpKind.MUL]), node.left, li("N"), + proven=True) + b["fill"] = _replace_body(b["fill"], path, wide) + return "widen proven", b, "fill", {PROVEN} + if kind == 1: # proven set on an op that can overflow + b["q"] = _replace_body(b["q"], (0, "value"), + BinOp(I64, BinOpKind.ADD, li("n"), i(1), proven=True)) + return "proven on param add", b, "q", {PROVEN} + if kind == 2: # proven set on a node that has no i64 overflow + node = rng.choice([BinOp(F64, BinOpKind.ADD, f(1.0), f(2.0), proven=True), + BinOp(I64, BinOpKind.FLOORDIV, i(4), i(2), proven=True), + UnaryOp(I64, UnaryOpKind.POS, i(1), proven=True), + BinOp(I64, BinOpKind.ADD, i(1), i(2), proven=1)]) + t = Type.F64 if node.type is F64 else Type.I64 + b["q"] = dataclasses.replace(b["q"], returns=t, may_deopt=node.type is I64 and + getattr(node, "op", None) is BinOpKind.FLOORDIV, + body=(Return(node),)) + return "proven on wrong node", b, "q", {PROVEN, TYPE} + if kind == 3: # un-prove a proven op in the impure function: it deopts + paths = _paths(b["fill"].body, lambda n: type(n) in (BinOp, UnaryOp) and n.proven) + path, node = rng.choice(paths) + b["fill"] = _replace_body(b["fill"], path, dataclasses.replace(node, proven=False)) + return "unprove", b, "fill", {DEOPT} + if kind == 4: # un-redo + paths = _paths(b["fill"].body, lambda n: type(n) is Call) + path, node = paths[0] + b["fill"] = _replace_body(b["fill"], path, dataclasses.replace(node, redo=False)) + return "drop redo", b, "fill", {DEOPT} + if kind == 5: # redo from a pure caller + b["pc"] = dataclasses.replace(b["pc"], body=(Return(Call(F64, "h", (i(3),), redo=True)),)) + return "redo in pure", b, "pc", {REDO} + if kind == 6: # the callee stops being may_deopt / pure / F64-or-BOOL-or-NONE + how = rng.randrange(3) + h = b["h"] + if how == 0: + b["h"] = dataclasses.replace(h, may_deopt=False) + return "redo callee not may_deopt", b, "fill", {REDO} + if how == 1: + b["h"] = dataclasses.replace(h, pure=False, may_deopt=False, body=(IMPURE,) + ( + Return(ToFloat(F64, li("n"))),)) + return "redo callee impure", b, "fill", {REDO} + b["h"] = dataclasses.replace(h, returns=I64, body=(Return(BinOp( + I64, BinOpKind.MUL, li("n"), i(2))),)) + b["fill"] = _replace_body(b["fill"], (1, "value"), Call(I64, "h", (i(3),), + redo=True)) + return "redo to i64", b, "fill", {REDO, TYPE} + if kind == 7: # an open node in the function with entry globals + node = rng.choice([GetAttr(OBJ, g_("m"), "x"), pycall("f"), Truth(BOOL, g_("m")), + ObjToFloat(F64, g_("m")), BinOp(OBJ, BinOpKind.ADD, g_("m"), g_("m")), + CompareObj(BOOL, CompareKind.EQ, g_("m"), g_("m"))]) + node = ExprStmt(node) if type(node) is not CompareObj else If(node, ()) + b["fill"] = dataclasses.replace(b["fill"], body=(node,) + b["fill"].body) + return "open node", b, "fill", {ENTRY_OPEN} + if kind == 8: # entry globals on a function that is open + b["talk"] = dataclasses.replace(b["talk"], entry_globals=(Param("N", I64),)) + return "entry global on open", b, "talk", {ENTRY_OPEN} + if kind == 9: # a callee that runs user code + b["h"] = dataclasses.replace(b["h"], pure=False, may_deopt=False, body=(IMPURE,) + ( + Return(ToFloat(F64, li("n"))),)) + return "open callee", b, "fill", {ENTRY_OPEN} + if kind == 10: # entry global removed while the body reads it + keep = rng.choice([("N",), ("S",), ()]) + eg = tuple(p for p in b["fill"].entry_globals if p.name in keep) + b["fill"] = dataclasses.replace(b["fill"], entry_globals=eg) + return "drop entry global", b, "fill", {TYPE} + if kind == 11: # a bad entry global: wrong type / collision / duplicate / array + bad = rng.choice([(Param("N", OBJ), TYPE), (Param("a", I64), STRUCTURE), + (Param("k", I64), STRUCTURE), (Param("N", I64), STRUCTURE), + (Param("W", F64A), TYPE), (Param("W", NONE), TYPE)]) + b["fill"] = dataclasses.replace(b["fill"], entry_globals=b["fill"].entry_globals + (bad[0],)) + return "bad entry global", b, "fill", {bad[1]} + if kind == 12: # assign an entry global + tgt = rng.choice(["N", "S"]) + v = i(1) if tgt == "N" else f(1.0) + b["fill"] = dataclasses.replace(b["fill"], body=(Assign(tgt, v),) + b["fill"].body) + return "assign entry global", b, "fill", {STRUCTURE} + # kind 13: a function with entry globals called, unguarded, from an impure caller + caller = fn("fill2", params=[ArrayParam("a", F64A, stored=True)], returns=F64, line=6, + body=[StoreIndex("a", i(0), f(1.0)), Return(Call(F64, "pc2", ()))]) + callee = fn("pc2", entry_globals=[Param("S", F64)], returns=F64, pure=True, line=7, + body=[Return(lf("S"))]) + b["fill2"], b["pc2"] = caller, callee + return "unguarded entry-global call", b, "fill2", {DEOPT} + + +def test_proof_base_functions_are_accepted(): + assert_accepted(*_proof_functions().values()) + + +@pytest.mark.parametrize("seed", [20261004]) +def test_no_proof_breaking_mutation_slips_through(seed): + rng = random.Random(seed) + base = _proof_functions() + missed, seen = [], set() + for n in range(300): + desc, funcs, target, allowed = _proof_mutations(rng, base) + seen.add(desc) + out, diags = run(*funcs.values()) + got = rules_for(diags, target) + if target in kept(out) or not (got & allowed): + missed.append((n, desc, target, sorted(got))) + assert missed == [] + + +def test_each_proof_mutation_kind_was_exercised(): + rng = random.Random(20261004) + base = _proof_functions() + kinds = {_proof_mutations(rng, base)[0] for _ in range(300)} + assert len(kinds) == 16, sorted(kinds) # 14 kinds; kind 6 has three variants + + +# --- 10. local arrays and tuples (NewArray / CopyArray / Tuple; issue #41, N-9) ------------------- + +def na(t, n, fill=None, iota=False): return NewArray(t, n, fill, iota) +def la(name, t=F64A): return Local(t, name) +def tup(*elements): return Tuple(OBJ, tuple(elements)) + + +def _local_sum(body=None, pure=True, may_deopt=False, params=(), locals_=None, returns=F64): + """t = [0.0] * 8; for k in range(len(t)): t[k] = 1.0; s += t[k]; return s""" + body = body if body is not None else [ + Assign("t", na(F64A, i(8), f(0.0))), + Assign("s", f(0.0)), + ForRange("k", i(0), Len(I64, "t"), i(1), ( + StoreIndex("t", li("k"), f(1.0)), + Assign("s", add(lf("s"), Index(F64, "t", li("k")), F64)), + )), + Return(lf("s")), + ] + return fn(params=params, locals_={"t": F64A, "s": F64, "k": I64, **(locals_ or {})}, + returns=returns, pure=pure, may_deopt=may_deopt, body=body) + + +def _flags(function): + out = assert_accepted(function) + return [a.proven for a in _accesses(out.functions[0])] + + +# 10.1 what a local array may be and where an allocation may stand + +def test_a_pure_function_with_a_local_array_is_accepted(): + assert_accepted(_local_sum()) + + +def test_storing_into_a_local_array_is_not_an_effect(): + assert_accepted(_local_sum(pure=True)) + assert_accepted(_local_sum(pure=False)) # a conservative flag is also fine + + +def test_storing_into_an_array_param_still_makes_a_function_impure(): + g = _local_sum(params=[ArrayParam("a", F64A, stored=True)], body=[ + Assign("t", na(F64A, i(8), f(0.0))), + StoreIndex("t", i(0), f(1.0)), + StoreIndex("a", i(0), f(1.0)), + Return(f(0.0)), + ]) + assert_rejected(FLAG_PURE, g) + assert_accepted(dataclasses.replace(g, pure=False)) + + +def test_reading_a_local_array_does_not_make_the_stored_flag_wrong(): + assert_accepted(_local_sum(params=[ArrayParam("a", F64A, stored=False)])) + + +def test_may_deopt_flag_is_recomputed_with_local_arrays(): + deopting = _local_sum(body=[ + Assign("t", na(I64A, i(4), i(0))), + Assign("x", add(Index(I64, "t", i(0)), i(1))), + Return(ToFloat(F64, li("x"))), + ], locals_={"t": I64A, "x": I64}, may_deopt=True) + assert_accepted(deopting) + assert_rejected(FLAG_DEOPT, dataclasses.replace(deopting, may_deopt=False)) + assert_rejected(FLAG_DEOPT, dataclasses.replace(_local_sum(), may_deopt=True)) + assert_rejected(DEOPT, dataclasses.replace(deopting, pure=False)) + + +def test_each_allocation_form_is_accepted(): + for t, alloc, deopts in [(F64A, na(F64A, i(3), f(1.5)), False), + (I64A, na(I64A, i(3), i(7)), False), + (I64A, na(I64A, li("n"), None, True), False), + (F64A, na(F64A, add(li("n"), i(1)), f(0.0)), True)]: + assert_accepted(fn(params=[Param("n", I64)], locals_={"t": t}, returns=NONE, pure=True, + may_deopt=deopts, body=[Assign("t", alloc), Return()])) + + +@pytest.mark.parametrize("place", ["expr-stmt", "return", "call-arg", "box", "tuple", "fill", + "length", "condition-free-assign"]) +def test_an_allocation_anywhere_but_an_assign_to_a_local_array_is_rejected(place): + alloc = na(F64A, i(2), f(0.0)) + stmt = { + "expr-stmt": ExprStmt(alloc), + "return": Return(alloc), + "call-arg": ExprStmt(Call(NONE, "g", (alloc,))), + "box": Assign("o", Box(OBJ, alloc)), + "tuple": Assign("o", tup(alloc)), + "fill": Assign("t", na(F64A, i(2), alloc)), + "length": Assign("t", na(F64A, alloc, f(0.0))), + "condition-free-assign": Assign("o", alloc), + }[place] + g = fn(locals_={"t": F64A, "o": OBJ}, returns=NONE, body=[stmt, Return()] if place != "return" + else [stmt]) + out, diags = run(g, fn("g", params=[Param("x", OBJ)], returns=NONE, body=[Return()])) + assert "f" not in kept(out) + assert ESCAPE in rules_for(diags, "f"), [dataclasses.astuple(d) for d in diags] + + +def test_a_copy_anywhere_but_an_assign_to_a_local_array_is_rejected(): + for stmt in [ExprStmt(CopyArray(F64A, "a")), Return(CopyArray(F64A, "a")), + Assign("o", CopyArray(F64A, "a")), Assign("x", CopyArray(F64A, "a"))]: + g = fn(params=[ArrayParam("a", F64A, stored=False)], locals_={"o": OBJ, "x": F64}, + returns=NONE, body=[stmt, Return()]) + out, diags = run(g) + assert "f" not in kept(out) + assert ESCAPE in rules_for(diags, "f"), (stmt, [dataclasses.astuple(d) for d in diags]) + + +def test_an_allocation_into_an_array_param_is_rejected(): + assert_rejected(ARRAY_USE, fn(params=[ArrayParam("a", F64A, stored=True)], returns=NONE, body=[ + Assign("a", na(F64A, i(2), f(0.0))), Return()])) + + +def test_the_allocation_type_must_match_the_declared_local(): + assert_rejected(TYPE, fn(locals_={"t": I64A}, returns=NONE, body=[ + Assign("t", na(F64A, i(2), f(0.0))), Return()])) + assert_rejected(TYPE, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", CopyArray(I64A, "a")), Return()], + params=[ArrayParam("a", I64A, stored=False)])) + + +def test_the_node_type_must_be_an_array_type(): + assert_rejected(TYPE, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", NewArray(I64, i(2), i(0))), Return()])) + + +def test_a_local_array_may_only_be_assigned_an_allocation(): + assert_rejected(TYPE, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", f(1.0)), Return()])) + assert_rejected(TYPE, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", Const(NONE, None)), Return()])) + + +def test_new_array_length_must_be_i64(): + assert_rejected(TYPE, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", na(F64A, f(2.0), f(0.0))), Return()])) + assert_rejected(OWNERSHIP, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", na(F64A, g_("N"), f(0.0))), Return()])) + + +def test_new_array_fill_must_match_the_element_type(): + assert_rejected(TYPE, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", na(F64A, i(2), i(0))), Return()])) + assert_rejected(TYPE, fn(locals_={"t": I64A}, returns=NONE, body=[ + Assign("t", na(I64A, i(2), f(0.0))), Return()])) + assert_rejected(OWNERSHIP, fn(locals_={"t": I64A}, returns=NONE, body=[ + Assign("t", na(I64A, i(2), g_("zero"))), Return()])) + + +def test_new_array_fill_is_absent_exactly_when_iota(): + assert_rejected(TYPE, fn(locals_={"t": I64A}, returns=NONE, body=[ + Assign("t", na(I64A, i(2))), Return()])) + assert_rejected(TYPE, fn(locals_={"t": I64A}, returns=NONE, body=[ + Assign("t", na(I64A, i(2), i(0), True)), Return()])) + + +def test_iota_is_only_for_i64_arrays_and_must_be_a_bool(): + assert_rejected(TYPE, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", na(F64A, i(2), None, True)), Return()])) + assert_rejected(TYPE, fn(locals_={"t": I64A}, returns=NONE, body=[ + Assign("t", NewArray(I64A, i(2), None, 1)), Return()])) + + +def test_copy_array_source_must_be_an_array_of_the_same_type(): + ok = fn(params=[ArrayParam("a", F64A, stored=False)], locals_={"t": F64A}, returns=NONE, + pure=True, body=[Assign("t", CopyArray(F64A, "a")), Return()]) + assert_accepted(ok) + assert_rejected(ARRAY_USE, dataclasses.replace(ok, params=(Param("a", F64),))) + assert_rejected(ARRAY_USE, fn(locals_={"t": F64A, "x": F64}, returns=NONE, body=[ + Assign("x", f(1.0)), Assign("t", CopyArray(F64A, "x")), Return()])) + assert_rejected(ARRAY_USE, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", CopyArray(F64A, "nowhere")), Return()])) + assert_rejected(TYPE, dataclasses.replace( + ok, params=(ArrayParam("a", I64A, stored=False),))) + + +def test_copy_of_a_local_array_is_accepted(): + assert_accepted(fn(locals_={"t": I64A, "u": I64A}, returns=NONE, pure=True, body=[ + Assign("t", na(I64A, i(4), None, True)), Assign("u", CopyArray(I64A, "t")), Return()])) + + +def test_local_array_declarations_are_checked(): + assert_rejected(TYPE, fn(params=[Param("t", I64)], locals_={"t": I64A}, returns=NONE, + body=[Return()])) + assert_rejected(ARRAY_USE, fn(params=[ArrayParam("t", I64A, stored=False)], + locals_={"t": I64A}, returns=NONE, body=[Return()])) + assert_rejected(STRUCTURE, fn(entry_globals=[Param("t", I64)], locals_={"t": I64A}, + returns=NONE, body=[Return()])) + assert_rejected(TYPE, fn(locals_={"t": Type.NONE}, returns=NONE, body=[Return()])) + + +# 10.2 no escape + +def _escapes(use, ret=NONE): + """f(): t = [0] * 2; ; with t an i64 local array; g takes an OBJ.""" + t = la("t", I64A) + stmt = use(t) + g = fn(locals_={"t": I64A, "o": OBJ, "x": I64, "a": F64A, "u": I64A}, returns=ret, body=[ + Assign("t", na(I64A, i(2), i(0))), stmt] + ([Return()] if ret is NONE else [])) + out, diags = run(g, fn("g", params=[Param("x", OBJ)], returns=NONE, body=[Return()])) + assert "f" not in kept(out) + assert ESCAPE in rules_for(diags, "f"), [dataclasses.astuple(d) for d in diags] + + +@pytest.mark.parametrize("use", [ + lambda t: ExprStmt(t), + lambda t: ExprStmt(Call(NONE, "g", (t,))), + lambda t: ExprStmt(call_obj(g_("h"), t)), + lambda t: ExprStmt(call_obj(t)), + lambda t: Assign("o", Box(OBJ, t)), + lambda t: Assign("o", tup(t)), + lambda t: Assign("o", tup(Box(OBJ, i(1)), t)), + lambda t: Assign("u", t), + lambda t: Assign("o", t), + lambda t: Assign("x", Unbox(I64, t)), + lambda t: Assign("o", GetAttr(OBJ, t, "x")), + lambda t: If(Truth(BOOL, t), ()), + lambda t: If(CompareObj(BOOL, CompareKind.EQ, t, lo("o")), ()), +]) +def test_a_local_array_used_as_a_value_is_an_escape(use): + _escapes(use) + + +def test_returning_a_local_array_is_an_escape(): + for returns in (OBJ, NONE): + g = fn(locals_={"t": I64A}, returns=returns, body=[ + Assign("t", na(I64A, i(2), i(0))), Return(la("t", I64A))]) + out, diags = run(g) + assert "f" not in kept(out) + assert ESCAPE in rules_for(diags, "f") + + +def test_a_local_array_is_not_a_loop_variable_or_an_array_param_argument(): + assert_rejected(TYPE, fn(locals_={"t": I64A}, returns=NONE, body=[ + Assign("t", na(I64A, i(2), i(0))), + ForRange("t", i(0), i(2), i(1), ()), Return()])) + callee = fn("g", params=[ArrayParam("a", I64A, stored=False)], returns=NONE, body=[Return()]) + caller = fn(locals_={"t": I64A}, returns=NONE, body=[ + Assign("t", na(I64A, i(2), i(0))), ExprStmt(Call(NONE, "g", (la("t", I64A),))), + Return()]) + out, diags = run(callee, caller) + assert "f" not in kept(out) and ESCAPE in rules_for(diags, "f") + + +def test_index_store_len_and_copy_source_are_the_only_uses_and_are_accepted(): + assert_accepted(fn(locals_={"t": I64A, "u": I64A, "x": I64}, returns=I64, pure=True, + may_deopt=True, body=[ + Assign("t", na(I64A, i(4), i(0))), + StoreIndex("t", i(1), i(5)), + Assign("u", CopyArray(I64A, "t")), + Assign("x", add(Index(I64, "u", i(1)), Len(I64, "t"))), + Return(li("x")), + ])) + + +def test_index_and_store_name_a_declared_array(): + assert_rejected(ARRAY_USE, fn(locals_={"x": I64}, returns=I64, body=[ + Assign("x", Index(I64, "nowhere", i(0))), Return(li("x"))])) + assert_rejected(ARRAY_USE, fn(locals_={"x": I64}, returns=NONE, body=[ + Assign("x", i(1)), StoreIndex("x", i(0), i(1)), Return()])) + + +def test_store_value_must_match_the_local_element_type(): + assert_rejected(TYPE, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", na(F64A, i(2), f(0.0))), StoreIndex("t", i(0), i(1)), Return()])) + assert_rejected(TYPE, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", na(F64A, i(2), f(0.0))), StoreIndex("t", f(0.0), f(1.0)), Return()])) + + +# 10.3 bounds + +def test_loop_over_len_of_a_local_array_is_proven(): + assert _flags(_local_sum()) == [True, True] + + +def test_loop_over_len_of_a_local_array_of_unknown_length_is_proven(): + g = _local_sum(params=[Param("n", I64)], body=[ + Assign("t", na(F64A, li("n"), f(0.0))), + ForRange("k", i(0), Len(I64, "t"), i(1), (StoreIndex("t", li("k"), f(1.0)),)), + Return(f(0.0)), + ]) + assert _flags(g) == [True] + + +def test_other_indices_into_an_array_of_unknown_length_are_unproven(): + g = _local_sum(params=[Param("n", I64)], body=[ + Assign("t", na(F64A, li("n"), f(0.0))), + StoreIndex("t", i(0), f(1.0)), + ForRange("k", i(0), li("n"), i(1), (StoreIndex("t", li("k"), f(1.0)),)), + Return(f(0.0)), + ]) + assert _flags(g) == [False, False] + + +def test_reassigning_the_array_inside_the_loop_defeats_the_loop_proof(): + g = _local_sum(body=[ + Assign("t", na(F64A, i(8), f(0.0))), + Assign("s", f(0.0)), + ForRange("k", i(0), Len(I64, "t"), i(1), ( + Assign("t", na(F64A, i(1), f(0.0))), + Assign("s", add(lf("s"), Index(F64, "t", li("k")), F64)), + )), + Return(lf("s")), + ]) + assert _flags(g) == [False] + + +def test_reassigning_the_array_in_a_nested_statement_also_defeats_it(): + g = _local_sum(params=[Param("c", BOOL)], body=[ + Assign("t", na(F64A, i(8), f(0.0))), + Assign("s", f(0.0)), + ForRange("k", i(0), Len(I64, "t"), i(1), ( + If(lb("c"), (Assign("t", na(F64A, i(1), f(0.0))),)), + Assign("s", add(lf("s"), Index(F64, "t", li("k")), F64)), + )), + Return(lf("s")), + ]) + assert _flags(g) == [False] + + +def _const_index(length, *indices, extra=()): + """t = [0.0] * length; x = t[i0] + t[i1] + ... -> the proven flags in order""" + reads = [Index(F64, "t", i(ix)) for ix in indices] + total = reads[0] + for r in reads[1:]: + total = add(total, r, F64) + return fn(locals_={"t": F64A}, returns=F64, pure=True, body=[ + *extra, Assign("t", na(F64A, i(length), f(0.0))), Return(total)]) + + +def test_constant_indices_below_a_known_length_are_proven(): + assert _flags(_const_index(8, 0, 7)) == [True, True] + + +def test_constant_indices_outside_a_known_length_are_not_proven(): + assert _flags(_const_index(8, 8, 100, -1, -8)) == [False, False, False, False] + assert _flags(_const_index(0, 0)) == [False] + assert _flags(_const_index(-3, 0)) == [False] # a negative length is an empty array + + +def test_an_index_expression_with_a_bounded_interval_is_proven(): + g = fn(locals_={"t": F64A, "s": F64, "k": I64}, returns=F64, pure=True, may_deopt=False, body=[ + Assign("t", na(F64A, i(10), f(0.0))), + Assign("s", f(0.0)), + ForRange("k", i(0), i(9), i(1), ( + Assign("s", add(lf("s"), Index(F64, "t", padd(li("k"), i(1))), F64)), + Assign("s", add(lf("s"), Index(F64, "t", psub(li("k"), i(1))), F64)), + )), + Return(lf("s")), + ]) + assert _flags(g) == [True, False] # k + 1 in [1, 9]; k - 1 in [-1, 7] may be negative + + +def test_the_length_interval_comes_from_the_length_expression(): + g = fn(locals_={"t": F64A, "j": I64, "s": F64}, returns=F64, pure=True, body=[ + Assign("s", f(0.0)), + ForRange("j", i(2), i(6), i(1), ( + Assign("t", na(F64A, li("j"), f(0.0))), + Assign("s", add(lf("s"), Index(F64, "t", i(0)), F64)), + Assign("s", add(lf("s"), Index(F64, "t", i(1)), F64)), + Assign("s", add(lf("s"), Index(F64, "t", i(2)), F64)), + )), + Return(lf("s")), + ]) + assert _flags(g) == [True, True, False] # j in [2, 5] -> the length is at least 2 + + +def test_a_twice_assigned_array_has_no_known_length(): + g = fn(params=[Param("c", BOOL)], locals_={"t": F64A}, returns=F64, pure=True, body=[ + Assign("t", na(F64A, i(8), f(0.0))), + If(lb("c"), (Assign("t", na(F64A, i(1), f(0.0))),)), + Return(Index(F64, "t", i(5))), + ]) + assert _flags(g) == [False] + + +def test_a_copy_inherits_the_length_of_a_known_source_only(): + g = fn(params=[ArrayParam("a", F64A, stored=False)], locals_={"t": F64A, "u": F64A, "v": F64A}, + returns=F64, pure=True, body=[ + Assign("t", na(F64A, i(4), f(0.0))), + Assign("u", CopyArray(F64A, "t")), + Assign("v", CopyArray(F64A, "a")), + Return(add(add(Index(F64, "u", i(3)), Index(F64, "u", i(4)), F64), + Index(F64, "v", i(0)), F64)), + ]) + assert _flags(g) == [True, False, False] + + +def test_a_proven_flag_from_the_input_is_not_trusted_for_local_arrays(): + g = fn(params=[Param("n", I64)], locals_={"t": F64A}, returns=F64, pure=True, body=[ + Assign("t", na(F64A, li("n"), f(0.0))), + Return(Index(F64, "t", i(0), proven=True)), + ]) + assert _flags(g) == [False] + + +def test_len_of_a_local_array_has_the_length_interval(): + def doubled(length): + return fn(params=[Param("n", I64)], locals_={"t": F64A, "x": I64}, returns=I64, pure=True, + body=[Assign("t", na(F64A, length, f(0.0))), + Assign("x", pmul(Len(I64, "t"), i(2 ** 60))), Return(li("x"))]) + assert_accepted(doubled(i(5))) # len == 5: 5 * 2**60 fits i64 (only if known) + assert_rejected(PROVEN, doubled(li("n"))) # unknown length: [0, 2**62] * 2**60 does not + assert_rejected(PROVEN, doubled(i(20))) # len == 20: 20 * 2**60 does not fit + + +# 10.4 definite assignment + +@pytest.mark.parametrize("use", [ + lambda: Assign("x", Index(F64, "t", i(0))), + lambda: StoreIndex("t", i(0), f(1.0)), + lambda: Assign("n", Len(I64, "t")), + lambda: Assign("u", CopyArray(F64A, "t")), + lambda: ForRange("k", i(0), Len(I64, "t"), i(1), ()), +]) +def test_a_local_array_must_be_assigned_before_any_use(use): + g = fn(params=[Param("c", BOOL)], locals_={"t": F64A, "u": F64A, "x": F64, "n": I64, "k": I64}, + returns=NONE, body=[ + If(lb("c"), (Assign("t", na(F64A, i(2), f(0.0))),)), + use(), Return()]) + assert_rejected(DA, g) + fixed = dataclasses.replace(g, body=(Assign("t", na(F64A, i(2), f(0.0))),) + g.body[1:]) + assert_accepted(dataclasses.replace(fixed, pure=True)) + + +def test_assigned_on_both_branches_a_local_array_is_defined(): + assert_accepted(fn(params=[Param("c", BOOL)], locals_={"t": F64A}, returns=F64, pure=True, + body=[If(lb("c"), (Assign("t", na(F64A, i(2), f(0.0))),), + (Assign("t", na(F64A, i(3), f(0.0))),)), + Return(Index(F64, "t", i(0)))])) + + +def test_a_local_array_assigned_in_a_loop_is_not_defined_after_it(): + assert_rejected(DA, fn(locals_={"t": F64A, "k": I64}, returns=F64, body=[ + ForRange("k", i(0), i(3), i(1), (Assign("t", na(F64A, i(2), f(0.0))),)), + Return(Index(F64, "t", i(0)))])) + + +def test_the_allocation_operands_are_read_before_the_array_is_assigned(): + assert_rejected(DA, fn(locals_={"t": F64A}, returns=NONE, body=[ + Assign("t", na(F64A, Len(I64, "t"), f(0.0))), Return()])) + + +# 10.5 tuples + +def test_a_tuple_of_boxed_scalars_and_objects_is_accepted(): + assert_accepted(fn(params=[Param("o", OBJ), Param("x", F64)], locals_={"r": OBJ}, returns=OBJ, + pure=True, body=[ + Assign("r", tup(Box(OBJ, lf("x")), lo("o"), Box(OBJ, i(3)))), Return(lo("r"))])) + + +def test_a_tuple_can_be_returned_directly_and_may_be_empty(): + assert_accepted(fn(returns=OBJ, pure=True, body=[Return(tup())])) + assert_accepted(fn(params=[Param("x", I64)], returns=OBJ, pure=True, + body=[Return(tup(Box(OBJ, li("x"))))])) + + +def test_a_tuple_of_local_array_elements_is_accepted_through_index(): + assert_accepted(fn(locals_={"t": I64A}, returns=OBJ, pure=True, body=[ + Assign("t", na(I64A, i(3), i(1))), + Return(tup(Box(OBJ, Index(I64, "t", i(0))), Box(OBJ, Len(I64, "t"))))])) + + +def test_a_scalar_tuple_element_needs_box(): + assert_rejected(OWNERSHIP, fn(params=[Param("x", F64)], returns=OBJ, body=[ + Return(tup(lf("x")))])) + assert_rejected(OWNERSHIP, fn(returns=OBJ, body=[Return(tup(i(1)))])) + + +def test_a_tuple_is_an_obj_and_only_an_obj(): + assert_rejected(TYPE, fn(returns=I64, body=[Return(Tuple(I64, ()))])) + assert_rejected(OWNERSHIP, fn(returns=I64, body=[Return(tup())])) + assert_rejected(OWNERSHIP, fn(locals_={"x": F64}, returns=NONE, body=[ + Assign("x", tup()), Return()])) + assert_rejected(TYPE, fn(returns=BOOL, body=[Return(Tuple(BOOL, ()))])) + + +def test_a_tuple_creates_a_reference(): + # an OBJ local assigned from a tuple is an owned reference, like Box/CallObject + assert_accepted(fn(locals_={"r": OBJ, "q": OBJ}, returns=OBJ, pure=True, body=[ + Assign("r", tup()), Assign("q", tup(lo("r"))), Return(lo("q"))])) + + +def test_tuple_elements_must_be_a_tuple_of_checked_expressions(): + assert_rejected(STRUCTURE, fn(returns=OBJ, body=[Return(Tuple(OBJ, [Box(OBJ, i(1))]))])) + assert_rejected(DA, fn(locals_={"o": OBJ}, returns=OBJ, body=[Return(tup(lo("o")))])) + assert_rejected(TYPE, fn(returns=OBJ, body=[Return(tup(Const(F64, 1)))])) + + +def test_a_tuple_is_not_an_effect_but_unboxing_its_elements_is_not_available(): + # a tuple creation is pure; the function may keep pure=True with a deopt-free body + assert_accepted(fn(returns=OBJ, pure=True, may_deopt=False, body=[Return(tup())])) + + +# 10.6 mutation: nothing slips through + +def _array_functions(): + """Correct functions with local arrays and tuples; the mutator breaks them.""" + fk = fn("fk", params=[Param("n", I64), ArrayParam("a", I64A, stored=False)], + locals_={"p": I64A, "q": I64A, "k": I64, "s": I64, "t": OBJ, "o": OBJ}, + returns=OBJ, pure=True, may_deopt=True, line=3, body=[ + Assign("p", na(I64A, i(8), None, True)), + Assign("q", CopyArray(I64A, "p")), + Assign("s", i(0)), + Assign("o", g_("OBJECT")), + ForRange("k", i(0), Len(I64, "p"), i(1), ( + StoreIndex("q", li("k"), add(Index(I64, "p", li("k")), i(1))), + Assign("s", add(li("s"), Index(I64, "q", li("k")))), + )), + Assign("t", tup(Box(OBJ, li("s")), Box(OBJ, Index(I64, "q", i(0))), lo("o"))), + Return(lo("t")), + ]) + ip = fn("ip", params=[ArrayParam("a", F64A, stored=True)], + locals_={"z": F64A, "k": I64, "r": OBJ}, returns=NONE, line=9, body=[ + Assign("z", na(F64A, Len(I64, "a"), f(0.0))), + ForRange("k", i(0), Len(I64, "a"), i(1), ( + StoreIndex("z", li("k"), Index(F64, "a", li("k"))), + StoreIndex("a", li("k"), BinOp(F64, BinOpKind.ADD, Index(F64, "z", li("k")), + f(1.0))), + )), + Assign("r", call_obj(g_("print"), Box(OBJ, Index(F64, "z", i(0))))), + Return(), + ]) + sink = fn("sink", params=[Param("x", OBJ)], returns=NONE, line=12, body=[Return()]) + return {x.name: x for x in (fk, ip, sink)} + + +def _array_mutations(rng, base): + """(description, functions by name, the function that must be rejected, the rules any one of + which is an acceptable report).""" + b = dict(base) + fk, ip = b["fk"], b["ip"] + kind = rng.randrange(16) + arr = rng.choice([("p", I64A), ("q", I64A)]) + leak = la(*arr) + insert = lambda g, *stmts: dataclasses.replace(g, body=g.body[:-1] + tuple(stmts) + g.body[-1:]) + if kind == 0: # return the array + b["fk"] = dataclasses.replace(fk, body=fk.body[:-1] + (Return(leak),)) + return "return array", b, "fk", {ESCAPE} + if kind == 1: # pass it to a compiled Call + b["fk"] = insert(fk, ExprStmt(Call(NONE, "sink", (leak,)))) + return "call array", b, "fk", {ESCAPE} + if kind == 2: # pass it to CallObject + b["fk"] = insert(fk, Assign("o", call_obj(g_("keep"), leak))) + return "callobject array", b, "fk", {ESCAPE} + if kind == 3: # box it + b["fk"] = insert(fk, Assign("o", Box(OBJ, leak))) + return "box array", b, "fk", {ESCAPE} + if kind == 4: # put it in a tuple + b["fk"] = insert(fk, Assign("o", tup(Box(OBJ, i(1)), leak))) + return "tuple array", b, "fk", {ESCAPE} + if kind == 5: # alias it + b["fk"] = insert(fk, Assign("q" if arr[0] == "p" else "p", leak)) + return "alias array", b, "fk", {ESCAPE} + if kind == 6: # an allocation where it may not stand + alloc = rng.choice([na(I64A, i(2), i(0)), CopyArray(I64A, "p")]) + stmt = rng.choice([ExprStmt(alloc), Assign("o", alloc), Assign("s", alloc), + Assign("o", Box(OBJ, alloc))]) + b["fk"] = insert(fk, stmt) + return "stray allocation", b, "fk", {ESCAPE} + if kind == 7: # pure flag set on an impure function / an extra effect in a pure one + if rng.random() < 0.5: + b["ip"] = dataclasses.replace(ip, pure=True) + return "pure on impure", b, "ip", {FLAG_PURE} + b["fk"] = insert(fk, StoreIndex("a", i(0), i(1))) + return "param store in pure", b, "fk", {FLAG_PURE} + if kind == 8: # pure flag cleared on a function that deopts / may_deopt flipped + if rng.random() < 0.5: + b["fk"] = dataclasses.replace(fk, pure=False) + return "drop pure", b, "fk", {DEOPT} + b["fk"] = dataclasses.replace(fk, may_deopt=False) + return "drop may_deopt", b, "fk", {FLAG_DEOPT} + if kind == 9: # an impurity added to the pure function + stmt = rng.choice([ExprStmt(pycall("print")), + Assign("o", GetAttr(OBJ, g_("m"), "x"))]) + b["fk"] = insert(fk, stmt) + return "impurity in pure", b, "fk", {FLAG_PURE} + if kind == 10: # the allocation is removed: every use reads an unassigned array + keep = [s for s in fk.body if not (type(s) is Assign and s.target == arr[0])] + b["fk"] = dataclasses.replace(fk, body=tuple(keep)) + return "drop allocation", b, "fk", {DA} + if kind == 11: # a malformed allocation + bad = rng.choice([na(I64A, i(8)), na(I64A, i(8), i(0), True), na(I64A, f(8.0), i(0)), + na(I64A, i(8), f(0.0)), NewArray(I64A, i(8), None, 1), + na(F64A, i(8), f(0.0)), NewArray(I64, i(8), i(0))]) + b["fk"] = dataclasses.replace(fk, body=(Assign("p", bad),) + fk.body[1:]) + return "bad allocation", b, "fk", {TYPE} + if kind == 12: # a bad copy source + src = rng.choice([("n", {ARRAY_USE}), ("nowhere", {ARRAY_USE})]) + b["fk"] = dataclasses.replace(fk, body=(fk.body[0], Assign("q", CopyArray(I64A, src[0])) + ) + fk.body[2:]) + return "bad copy source", b, "fk", src[1] | {DA} + if kind == 13: # a scalar tuple element without Box + b["fk"] = insert(fk, Assign("o", tup(li("s")))) + return "unboxed tuple element", b, "fk", {OWNERSHIP} + if kind == 14: # the local's declared type disagrees with the allocation + b["fk"] = dataclasses.replace(fk, locals={**fk.locals, "p": F64A}) + return "declared type", b, "fk", {TYPE} + # kind 15: a tuple typed as something else + b["fk"] = insert(fk, Assign("s", Tuple(I64, ()))) + return "tuple not obj", b, "fk", {TYPE, OWNERSHIP} + + +def test_array_base_functions_are_accepted(): + assert_accepted(*_array_functions().values()) + + +@pytest.mark.parametrize("seed", [20261005]) +def test_no_array_breaking_mutation_slips_through(seed): + rng = random.Random(seed) + base = _array_functions() + missed = [] + for n in range(400): + desc, funcs, target, allowed = _array_mutations(rng, base) + out, diags = run(*funcs.values()) + got = rules_for(diags, target) + if target in kept(out) or not (got & allowed): + missed.append((n, desc, target, sorted(got))) + assert missed == [] + + +def test_each_array_mutation_kind_was_exercised(): + rng = random.Random(20261005) + base = _array_functions() + kinds = {_array_mutations(rng, base)[0] for _ in range(400)} + assert len(kinds) == 18, sorted(kinds) # 16 kinds; kinds 7 and 8 have two variants diff --git a/python-multiplatform-ksp/tools/sanitizers.py b/python-multiplatform-ksp/tools/sanitizers.py new file mode 100644 index 000000000..c94018804 --- /dev/null +++ b/python-multiplatform-ksp/tools/sanitizers.py @@ -0,0 +1,189 @@ +"""ASan + UBSan setup for the TypedPython tests (SPEC N-8, issue #41). + + python tools/sanitizers.py env print KEY=VALUE lines (append to $GITHUB_ENV) + python tools/sanitizers.py selfcheck build a deliberately faulty extension and REQUIRE that + ASan and UBSan each report it; exit 1 otherwise + +Linux: the sanitizer runtime is LD_PRELOADed into the (unsanitized) interpreter. +macOS refuses to load the runtime into the framework Python, so selfcheck runs through a small +sanitized launcher (Py_BytesMain) instead -- the same recipe as test_cgen.py. CI uses Linux only; +the macOS path exists so the self-check logic can be exercised on a developer machine. + +TP_SANITIZE=1 makes conftest.py add the sanitizer flags to every extension TypedPython builds +(cbuild.compiler) and makes test_runtime.py build its shim sanitized. +""" +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +import sysconfig +import tempfile +import textwrap +from pathlib import Path + +SAN_FLAGS = ["-fsanitize=address,undefined", "-fno-sanitize-recover=undefined", "-g", "-O1", + "-fno-omit-frame-pointer"] +# detect_leaks=0: CPython itself leaks at exit. abort_on_error=0 keeps a plain nonzero exit code. +RUN_ENV = { + "ASAN_OPTIONS": "detect_leaks=0:abort_on_error=0:halt_on_error=1:allocator_may_return_null=1", + "UBSAN_OPTIONS": "halt_on_error=1:print_stacktrace=1", +} + + +def cc() -> list[str]: + return shlex.split(sysconfig.get_config_var("CC") or "cc") + + +def asan_runtime() -> str: + """Path of the shared ASan runtime belonging to the compiler that builds the extensions.""" + compiler = cc() + is_clang = "clang" in Path(compiler[0]).name + if sys.platform == "darwin": + names = ["libclang_rt.asan_osx_dynamic.dylib"] + elif is_clang: + import platform + names = [f"libclang_rt.asan-{platform.machine()}.so"] + else: + names = ["libasan.so"] + for name in names: + out = subprocess.run(compiler + [f"-print-file-name={name}"], capture_output=True, + text=True).stdout.strip() + if out and Path(out).is_file(): + return str(Path(out).resolve()) + raise SystemExit(f"no ASan runtime {names} found for compiler {compiler}") + + +def run_env() -> dict[str, str]: + env = dict(RUN_ENV) + if sys.platform.startswith("linux"): + env["LD_PRELOAD"] = asan_runtime() + return env + + +def pyrefly_wrapper() -> str: + """Pyrefly is a prebuilt Rust binary: keep the ASan preload out of it (and out of the launcher + case, where sys.executable is not the venv python). The gate honours TYPEDPYTHON_PYREFLY.""" + binary = Path(sys.prefix) / "bin" / "pyrefly" + if not binary.is_file(): + raise SystemExit(f"{binary} not found: install the test environment first") + wrapper = Path(tempfile.mkdtemp(prefix="tp-san-")) / "pyrefly" + wrapper.write_text(f'#!/bin/sh\nunset LD_PRELOAD DYLD_INSERT_LIBRARIES\nexec "{binary}" "$@"\n') + wrapper.chmod(0o755) + return str(wrapper) + + +def cmd_env() -> None: + env = {"TP_SANITIZE": "1", "TYPEDPYTHON_PYREFLY": pyrefly_wrapper(), **run_env()} + for key, value in env.items(): + print(f"{key}={value}") + + +FAULTY_C = textwrap.dedent('''\ + #include + #include + #include + + static PyObject *oob_read(PyObject *self, PyObject *arg) { + volatile long i = PyLong_AsLong(arg); /* runtime index: the read cannot be folded away */ + char *p = (char *)malloc(8); + p[0] = 1; + volatile char c = p[i]; /* heap-buffer-overflow when i >= 8 */ + free(p); + return PyLong_FromLong(c); + } + + static PyObject *overflow(PyObject *self, PyObject *arg) { + volatile int a = INT_MAX, b = (int)PyLong_AsLong(arg); + int r = a + b; /* signed-integer-overflow when b >= 1 */ + return PyLong_FromLong(r); + } + + static PyMethodDef methods[] = { + {"oob_read", oob_read, METH_O, ""}, {"overflow", overflow, METH_O, ""}, {NULL, NULL, 0, NULL}}; + static struct PyModuleDef moddef = {PyModuleDef_HEAD_INIT, "tp_faulty", NULL, -1, methods}; + PyMODINIT_FUNC PyInit_tp_faulty(void) { return PyModule_Create(&moddef); } + ''') + +DRIVER = textwrap.dedent('''\ + import importlib.machinery, importlib.util, sys + loader = importlib.machinery.ExtensionFileLoader("tp_faulty", {so!r}) + spec = importlib.util.spec_from_file_location("tp_faulty", {so!r}, loader=loader) + m = importlib.util.module_from_spec(spec); loader.exec_module(m) + print("LOADED", flush=True) + {call} + print("RETURNED-WITHOUT-REPORT", flush=True) + ''') + + +def python_command(workdir: Path) -> list[str]: + if sys.platform != "darwin": + return [sys.executable] + launcher_c = workdir / "asan_python.c" + launcher_c.write_text("#include \nint main(int argc, char **argv) " + "{ return Py_BytesMain(argc, argv); }\n") + launcher = workdir / "asan_python" + cmd = cc() + SAN_FLAGS + [f"-I{sysconfig.get_paths()['include']}", str(launcher_c), + f"-L{sysconfig.get_config_var('LIBDIR')}", + f"-lpython{sysconfig.get_config_var('LDVERSION')}", "-o", str(launcher)] + subprocess.run(cmd, check=True) + return [str(launcher)] + + +def cmd_selfcheck() -> int: + work = Path(tempfile.mkdtemp(prefix="tp-san-selfcheck-")) + src = work / "tp_faulty.c" + src.write_text(FAULTY_C) + so = work / ("tp_faulty" + sysconfig.get_config_var("EXT_SUFFIX")) + # UBSan's object-size check would stop the planted out-of-bounds read before ASan sees it (seen + # on the first CI run). Disable it for this fault-planting build only, so the ASan case proves + # ASan itself; UBSan is proven by the signed-overflow case. + build = cc() + SAN_FLAGS + ["-fno-sanitize=object-size", "-fPIC", "-shared", + f"-I{sysconfig.get_paths()['include']}", str(src), + "-o", str(so)] + if sys.platform == "darwin": + build += ["-undefined", "dynamic_lookup"] + subprocess.run(build, check=True) + py = python_command(work) + env = dict(os.environ, **run_env()) + if sys.platform == "darwin": + env["PYTHONHOME"] = sys.base_prefix + env.pop("DYLD_INSERT_LIBRARIES", None) + failures = [] + cases = [ + ("clean call is silent", "m.oob_read(0); m.overflow(0)", None, 0), + ("ASan reports heap overflow", "m.oob_read(8)", # first byte past the end: always in the redzone + "AddressSanitizer: heap-buffer-overflow", None), + ("UBSan reports signed overflow", "m.overflow(1)", "runtime error: signed integer overflow", None), + ] + for name, call, needle, want_rc in cases: + run = subprocess.run(py + ["-c", DRIVER.format(so=str(so), call=call)], capture_output=True, + text=True, env=env) + out = run.stdout + run.stderr + if "violates platform policy" in out or "Interceptors are not working" in out: + failures.append(f"{name}: sanitizer runtime could not be loaded:\n{out[-1500:]}") + continue + if needle is None: + ok = run.returncode == 0 and "RETURNED-WITHOUT-REPORT" in out and "Sanitizer" not in out \ + and "runtime error" not in out + else: + ok = needle in out and run.returncode != 0 and "RETURNED-WITHOUT-REPORT" not in out + print(f"{'ok ' if ok else 'FAIL'} {name} (rc={run.returncode})") + if not ok: + failures.append(f"{name}:\n--- head ---\n{out[:2500]}\n--- tail ---\n{out[-800:]}") + if failures: + print("\n".join(failures), file=sys.stderr) + print("SELF-CHECK FAILED: the sanitizer setup does not detect a known fault", file=sys.stderr) + return 1 + print("SELF-CHECK OK: ASan and UBSan both report the planted faults") + return 0 + + +if __name__ == "__main__": + if sys.argv[1:] == ["env"]: + cmd_env() + elif sys.argv[1:] == ["selfcheck"]: + sys.exit(cmd_selfcheck()) + else: + sys.exit(__doc__) diff --git a/python-multiplatform/build.gradle.kts b/python-multiplatform/build.gradle.kts index 3e5235f15..e1f63a250 100644 --- a/python-multiplatform/build.gradle.kts +++ b/python-multiplatform/build.gradle.kts @@ -1,4 +1,16 @@ import com.codingfeline.buildkonfig.compiler.FieldSpec +import java.net.URL +// Imported rather than written fully-qualified at the use site: inside a Gradle build script +// `java` resolves to the JavaPluginExtension, so `java.net.URLClassLoader` fails to compile with +// "Unresolved reference: net". Same for every other `java.*` name used below. +import java.net.URLClassLoader +import java.nio.charset.Charset +import java.nio.file.Path +import java.lang.reflect.InvocationTargetException +import java.io.FileOutputStream +import java.security.MessageDigest +import java.util.Properties +import java.io.File import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType @@ -6,11 +18,17 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink import org.jetbrains.kotlin.konan.target.Family import org.jetbrains.kotlin.konan.target.KonanTarget.* import org.jetbrains.kotlin.konan.target.linker +import python.multiplatform.gradle.CPythonFlavour +import python.multiplatform.gradle.CPythonIncludeDirectories +import python.multiplatform.gradle.CPythonIncludeLayout +import python.multiplatform.gradle.EmbeddedPythonVersion +import python.multiplatform.gradle.GenerateWasmProxyExportsTask plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.library) + id("io.github.thisisthepy.python.multiplatform.bindings") apply false id("com.codingfeline.buildkonfig").version("0.15.2") //id("org.jetbrains.dokka") id("maven-publish") @@ -18,7 +36,12 @@ plugins { } -val pythonVersion = project.rootProject.version.toString() +val configuredPythonVersion = project.findProperty("pythonVersion")?.toString() ?: project.rootProject.version.toString() +val pythonFreeThreaded = project.findProperty("pythonFreeThreaded")?.toString()?.toBoolean() ?: false +val pbsRelease = project.findProperty("pythonBuildStandaloneRelease")?.toString() ?: "20260807" +val pythonAppleSupportBuild = project.findProperty("pythonAppleSupportBuild")?.toString() ?: "b11" + +val pythonVersion = configuredPythonVersion val libraryVersion = "$pythonVersion-alpha01" version = libraryVersion @@ -29,48 +52,953 @@ buildkonfig { defaultConfigs { buildConfigField(FieldSpec.Type.STRING, "pythonVersion", pythonVersion) buildConfigField(FieldSpec.Type.STRING, "libraryVersion", libraryVersion) + // The free-threaded build is not merely a differently-compiled binary: it renames every + // artefact the runtime looks up by name. The shared library is `libpython3.14t.dylib`, not + // `libpython3.14.dylib`, and the stdlib sits in `lib/python3.14t/`. Without this flag + // reaching Kotlin, `manager.loadLibPython` asks for a file that a free-threaded install + // does not contain, and the failure looks like a missing download rather than a flavour + // mismatch. See `Versions.abiFlags`. + buildConfigField(FieldSpec.Type.BOOLEAN, "pythonFreeThreaded", pythonFreeThreaded.toString()) } } -val libVersion = version.toString().split('.').subList(0, 2).joinToString(".") +val libVersion = pythonVersion.split('.').subList(0, 2).joinToString(".") + +/** + * The `ftp/python//` directory holding a release, which drops any pre-release suffix: + * `3.15.0rc1` is published under `3.15.0`. Identical to the version itself for a final release, + * so it is always the right thing to build a python.org URL from. + * + * Declared here, above every consumer, because both the Android and the iOS download tasks need + * it. It used to sit below the Android tasks, which therefore built their URLs from the raw + * version and 404'd on any pre-release -- `ftp/python/3.15.0rc1/` does not exist. That stayed + * invisible because the archives were already sitting in the download directory, so the fetch was + * skipped; adding the `.sigstore` fetch is what surfaced it. + */ +val pythonOrgReleaseDir = pythonVersion.split(".").let { parts -> + val patch = parts.getOrNull(2)?.takeWhile { it.isDigit() } ?: "0" + "${parts[0]}.${parts[1]}.$patch" +} + println("----------------------------------------------------------------------------------------") println(" Build Configuration for Python version $libVersion ") println("----------------------------------------------------------------------------------------") println() -val includePath = "src/nativeInterop/cinterop/include" val licensePath = "src/nativeInterop/cinterop/license" val libPath = "src/nativeInterop/cinterop/lib" val libPathForDesktop = "$libPath/desktop" -val libPathForAndroid = "$libPath/android" -val libPathForIOS = "$libPath/ios/Python.xcframework" + +val downloadDir = layout.buildDirectory.dir("python-standalone").get().asFile + +/** + * Where archives are unpacked, keyed by the CPython version they contain. + * + * Every extraction below is guarded by "is the destination directory empty?", which is what makes + * repeated builds cheap. The guard is only sound if a directory can hold exactly one thing. It + * used to be a flat `extracted/`, so changing `-PpythonVersion` downloaded and + * checksummed the new archive and then *threw it away*: the destination was not empty, so the + * previous version's tree stayed, and the build compiled against it while every log line said + * otherwise. Same for [desktopFlavourSuffix]. Keying the path by what is inside it removes the + * whole class of error -- and switching back to a version already unpacked is still free. + */ +val extractedDir = file("$downloadDir/extracted/$configuredPythonVersion") + +/** + * Suffix that keeps the two desktop flavours apart within [extractedDir]. + * + * The GIL and free-threaded builds of the *same* version are different trees containing + * differently-named libraries (`libpython3.14.dylib` vs `libpython3.14t.dylib`), so they need + * separating for the same reason the version does. + */ +val desktopFlavourSuffix = if (pythonFreeThreaded) "-freethreaded" else "" + +/** + * Fails the build when the extracted CPython's own headers report a version other than + * `pythonVersion` (issue #47). The archive name and the lockfile say what was meant to be + * downloaded; `PY_VERSION` in `patchlevel.h` says what it is. iOS once shipped 3.14.6 (BeeWare + * `3.14-b10`) under `pythonVersion=3.14.7` and nothing noticed. + */ +fun requireHeaderVersion(label: String, extracted: File) { + val headers = extracted.walkTopDown().filter { it.name == "patchlevel.h" }.toList() + if (headers.isEmpty()) throw GradleException("$label: no patchlevel.h under $extracted, cannot verify the CPython version") + val re = Regex("""^\s*#\s*define\s+PY_VERSION\s+"([^"]+)"""", RegexOption.MULTILINE) + for (h in headers) { + val actual = re.find(h.readText())?.groupValues?.get(1) + ?: throw GradleException("$label: no PY_VERSION in $h") + if (actual != configuredPythonVersion) { + throw GradleException("$label: $h says PY_VERSION $actual but pythonVersion=$configuredPythonVersion. " + + "Pin an archive that ships $configuredPythonVersion (see docs/platforms/python-version-acquisition.md).") + } + } +} + +val checksumsFile = rootProject.file("python-checksums.properties") +val pythonArchiveKeys = mutableMapOf() + +fun verifyChecksum(key: String, archive: File) { + if (!checksumsFile.exists()) { + throw GradleException("Checksum lockfile python-checksums.properties not found! Run updatePythonChecksums to generate it.") + } + val props = Properties() + checksumsFile.inputStream().use { props.load(it) } + + val expectedHash = props.getProperty(key) + if (expectedHash == null) { + throw GradleException("Missing checksum for $key in python-checksums.properties. Run updatePythonChecksums task to accept the current downloaded archive as trusted.") + } + + val digest = MessageDigest.getInstance("SHA-256") + archive.inputStream().use { + val buffer = ByteArray(8192) + var bytesRead = it.read(buffer) + while (bytesRead != -1) { + digest.update(buffer, 0, bytesRead) + bytesRead = it.read(buffer) + } + } + val actualHash = digest.digest().joinToString("") { String.format("%02x", it) } + + if (expectedHash != actualHash) { + archive.delete() + throw GradleException( + "Checksum mismatch for $archive (key: $key) in python-checksums.properties.\n" + + "Expected: $expectedHash\n" + + "Actual: $actualHash" + ) + } +} + +// ================================================================================================= +// Sigstore verification of the python.org archives (ROADMAP §12, "Download integrity"). +// +// This is a SECOND gate, not a replacement for `verifyChecksum` above. The two prove different +// things and neither implies the other: +// +// the lockfile "these are the exact bytes this repository reviewed and pinned" +// Sigstore "these are the bytes the CPython release manager actually signed" +// +// The lockfile is also the only one of the two that works offline, and the only one that covers +// every source. So it stays unconditional and Sigstore is layered on top of it. +// +// ### Why this is opt-in +// +// `sigstore-java` drags in grpc-netty-shaded, protobuf, bouncycastle and guava -- tens of +// megabytes that nothing else in this build needs -- and `sigstorePublicDefaults()` fetches a TUF +// trust root over the network on first use, which would turn an offline build from "works" into +// "fails". Declaring the dependency costs nothing because Gradle resolves a configuration lazily; +// it is only fetched if `verifySigstore` is actually true and a download task runs. Enable with: +// +// ./gradlew -PverifyPythonSignatures=true +// +// ### What is covered, and what is not +// +// python.org Android aarch64/x86_64 COVERED -- sibling `.sigstore` bundle +// python.org iOS XCframework (3.15+) COVERED -- same +// astral-sh/python-build-standalone NOT covered. It publishes no sibling signature at all: +// 853 release assets, and the only non-archive among them +// is `SHA256SUMS` (checked directly against the release). +// Its provenance lives in GitHub's attestations API, keyed +// by artifact *digest* rather than filename, and that API +// is rate-limited to 60 requests/hour unauthenticated. That +// is a different mechanism, not this one. The desktop path +// already verifies against the release's own SHA256SUMS in +// addition to the lockfile. +// beeware/Python-Apple-support NOT coverable. The release publishes five tar.gz assets +// and nothing else -- no checksums, no signatures -- and it +// has no attestations either. The SHA-256 pin is the only +// honest instrument available for iOS <= 3.14. +val verifySigstore = project.findProperty("verifyPythonSignatures")?.toString()?.toBoolean() ?: false + +val sigstoreVerifierClasspath: Configuration by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true + isVisible = false +} + +dependencies { + add("sigstoreVerifierClasspath", "dev.sigstore:sigstore-java:2.2.0") +} + +/** The Fulcio certificate identity a python.org bundle must carry. */ +data class SigstoreIdentity(val subjectAlternativeName: String, val issuer: String) + +/** + * Which release manager signed a given CPython version, per https://www.python.org/download/sigstore/ + * + * This is a map rather than a constant on purpose. The signing identity is a property of the + * *release manager*, and it changes between release series: 3.13 was Thomas Wouters signing via + * Google, 3.14 and 3.15 are Hugo van Kemenade signing via GitHub. A hardcoded identity would keep + * verifying happily on the version it was written for and then either fail confusingly or -- far + * worse -- be loosened by the next person to "just make it pass" on an older one. + * + * An unknown series is a hard failure rather than a skip. A verification step that silently + * declines to verify is the thing this whole section exists to avoid. + */ +fun pythonOrgSigstoreIdentity(version: String): SigstoreIdentity { + val series = version.split(".").take(2).joinToString(".") + return when (series) { + "3.14", "3.15" -> SigstoreIdentity("hugo@python.org", "https://github.com/login/oauth") + "3.12", "3.13" -> SigstoreIdentity("thomas@python.org", "https://accounts.google.com") + else -> throw GradleException( + "No Sigstore signing identity is recorded for CPython $series. Add it from " + + "https://www.python.org/download/sigstore/ rather than disabling verification." + ) + } +} + +/** + * Verifies `archive` against its sibling `.sigstore` bundle, pinning the signer identity. + * + * Reflection, and a classloader whose parent is the *platform* loader rather than Gradle's, is + * what keeps this opt-in: nothing here is on the buildscript classpath, so a build that does not + * set `-PverifyPythonSignatures=true` never resolves or downloads any of it. The platform parent + * also isolates sigstore-java's guava/protobuf from the versions Gradle itself runs on. + * + * Pinning the identity is the whole point. `verify()` without certificate matchers proves only + * that *somebody* with a Sigstore certificate signed these bytes, which is a check anyone on the + * internet can pass. + */ +fun verifySigstoreBundle(archive: File, bundleUrl: String?, bundleFile: File, identity: SigstoreIdentity) { + if (!bundleFile.exists()) { + if (bundleUrl == null) { + throw GradleException("Sigstore bundle $bundleFile does not exist and no URL provided") + } + println("Downloading $bundleUrl") + bundleFile.parentFile.mkdirs() + URL(bundleUrl).openStream().use { input -> + FileOutputStream(bundleFile).use { output -> input.copyTo(output) } + } + } + + val loader = URLClassLoader( + sigstoreVerifierClasspath.files.map { it.toURI().toURL() }.toTypedArray(), + ClassLoader.getPlatformClassLoader() + ) + + fun load(name: String): Class<*> = Class.forName(name, true, loader) + + // sigstore-java ships its TUF trust root as a resource and reaches it through Guava's + // `Resources.getResource`, which asks the *thread context* classloader -- not the loader that + // loaded the calling class. Without this swap the verifier builds and then dies with + // "resource dev/sigstore/tuf/sigstore-tuf-root/root.json not found", which reads like a + // packaging bug rather than a classloader one. Observed, not anticipated. + val previousContextLoader = Thread.currentThread().contextClassLoader + Thread.currentThread().contextClassLoader = loader + + try { + val stringMatcher = load("dev.sigstore.strings.StringMatcher") + val matchString = stringMatcher.getMethod("string", String::class.java) + + val certificateMatcher = load("dev.sigstore.VerificationOptions\$CertificateMatcher") + val fulcioBuilder = certificateMatcher.getMethod("fulcio").invoke(null) + fulcioBuilder.javaClass.getMethod("subjectAlternativeName", stringMatcher) + .invoke(fulcioBuilder, matchString.invoke(null, identity.subjectAlternativeName)) + fulcioBuilder.javaClass.getMethod("issuer", stringMatcher) + .invoke(fulcioBuilder, matchString.invoke(null, identity.issuer)) + val matcher = fulcioBuilder.javaClass.getMethod("build").invoke(fulcioBuilder) + + val verificationOptions = load("dev.sigstore.VerificationOptions") + val optionsBuilder = verificationOptions.getMethod("builder").invoke(null) + optionsBuilder.javaClass.getMethod("addCertificateMatchers", certificateMatcher) + .invoke(optionsBuilder, matcher) + val options = optionsBuilder.javaClass.getMethod("build").invoke(optionsBuilder) + + val bundleClass = load("dev.sigstore.bundle.Bundle") + val bundle = bundleClass + .getMethod("from", Path::class.java, Charset::class.java) + .invoke(null, bundleFile.toPath(), Charsets.UTF_8) + + val verifierClass = load("dev.sigstore.KeylessVerifier") + val verifierBuilder = verifierClass.getMethod("builder").invoke(null) + verifierBuilder.javaClass.getMethod("sigstorePublicDefaults").invoke(verifierBuilder) + val verifier = verifierBuilder.javaClass.getMethod("build").invoke(verifierBuilder) + + verifierClass + .getMethod("verify", Path::class.java, bundleClass, verificationOptions) + .invoke(verifier, archive.toPath(), bundle, options) + + println("Sigstore OK: ${archive.name} signed by ${identity.subjectAlternativeName} via ${identity.issuer}") + } catch (e: InvocationTargetException) { + // Unwrap: the reflective frame is noise, the cause is the verification failure. + val cause = e.targetException ?: e + throw GradleException( + "Sigstore verification FAILED for ${archive.name}\n" + + " bundle: ${bundleFile.name}\n" + + " expected: SAN=${identity.subjectAlternativeName} issuer=${identity.issuer}\n" + + " cause: ${cause::class.java.name}: ${cause.message}", + cause + ) + } finally { + Thread.currentThread().contextClassLoader = previousContextLoader + loader.close() + } +} + +/** Verifies a python.org archive if `-PverifyPythonSignatures=true`, otherwise says it skipped. */ +fun maybeVerifySigstore(archive: File, archiveUrl: String) { + if (!verifySigstore) return + verifySigstoreBundle( + archive = archive, + bundleUrl = "$archiveUrl.sigstore", + bundleFile = file("$downloadDir/${archive.name}.sigstore"), + identity = pythonOrgSigstoreIdentity(configuredPythonVersion) + ) +} + +/** Verifies a python-build-standalone archive using GitHub Attestations API if `-PverifyPythonSignatures=true`. */ +fun maybeVerifyPbsSigstore(archive: File, actualHash: String) { + if (!verifySigstore) return + val bundleFile = file("$downloadDir/${archive.name}.sigstore") + if (!bundleFile.exists()) { + val apiUrl = "https://api.github.com/repos/astral-sh/python-build-standalone/attestations/sha256:$actualHash" + println("Downloading attestation from $apiUrl") + val conn = URL(apiUrl).openConnection() as java.net.HttpURLConnection + conn.setRequestProperty("Accept", "application/vnd.github+json") + conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28") + conn.setRequestProperty("User-Agent", "Gradle-Python-Multiplatform") + + if (conn.responseCode != 200) { + throw GradleException("Failed to fetch attestation for ${archive.name} (HTTP ${conn.responseCode}): ${conn.errorStream?.bufferedReader()?.readText()}") + } + + val response = conn.inputStream.bufferedReader().readText() + val parsed = groovy.json.JsonSlurper().parseText(response) as Map<*, *> + val attestations = parsed["attestations"] as List<*> + val bundle = (attestations[0] as Map<*, *>)["bundle"] + val bundleJson = groovy.json.JsonOutput.toJson(bundle) + + bundleFile.parentFile.mkdirs() + bundleFile.writeText(bundleJson) + } + + verifySigstoreBundle( + archive = archive, + bundleUrl = null, + bundleFile = bundleFile, + identity = SigstoreIdentity( + "https://github.com/astral-sh/python-build-standalone/.github/workflows/release.yml@refs/heads/main", + "https://token.actions.githubusercontent.com" + ) + ) +} + +tasks.register("updatePythonChecksums") { + doLast { + println("Updating python-checksums.properties. This accepts currently downloaded archives as trusted.") + val props = Properties() + if (checksumsFile.exists()) { + checksumsFile.inputStream().use { props.load(it) } + } + var updated = false + val digest = MessageDigest.getInstance("SHA-256") + + pythonArchiveKeys.forEach { (key, archive) -> + if (archive.exists()) { + digest.reset() + archive.inputStream().use { + val buffer = ByteArray(8192) + var bytesRead = it.read(buffer) + while (bytesRead != -1) { + digest.update(buffer, 0, bytesRead) + bytesRead = it.read(buffer) + } + } + val hash = digest.digest().joinToString("") { String.format("%02x", it) } + props.setProperty(key, hash) + updated = true + println("Computed $key = $hash") + } else { + println("Skipping $key: archive not downloaded yet at ${archive.name}") + } + } + + if (updated) { + checksumsFile.bufferedWriter().use { writer -> + // These were `"\\n"` -- a literal backslash and an `n`, not a newline. The task + // therefore emitted the entire lockfile as one physical line starting with `#`, + // which `Properties` reads as a single comment: running it once deleted every + // checksum, and the next build failed with "Missing checksum" for all of them. + // The committed file survived only because nobody had run the task since. + writer.write("# Python Multiplatform Checksums\n") + props.stringPropertyNames().sorted().forEach { k -> + writer.write("$k=${props.getProperty(k)}\n") + } + } + } + } +} + +val desktopTargets = mapOf( + "macos-aarch64" to "aarch64-apple-darwin", + "macos-x86_64" to "x86_64-apple-darwin", + "linux-x86_64" to "x86_64-unknown-linux-gnu", + "windows-x86_64" to "x86_64-pc-windows-msvc" +) + +val downloadTasks = desktopTargets.map { (platform, pbsTarget) -> + val flavour = if (pythonFreeThreaded) "freethreaded-install_only_stripped" else "install_only_stripped" + val assetName = "cpython-$configuredPythonVersion+$pbsRelease-$pbsTarget-$flavour.tar.gz" + val url = "https://github.com/astral-sh/python-build-standalone/releases/download/$pbsRelease/$assetName" + val archive = file("$downloadDir/$assetName") + val extractDir = file("$extractedDir/$platform$desktopFlavourSuffix") + val lockKey = "$platform-$configuredPythonVersion-$pbsRelease$desktopFlavourSuffix" + pythonArchiveKeys[lockKey] = archive + + val taskName = "downloadPython_${platform.replace("-", "_")}" + tasks.register(taskName) { + inputs.property("url", url) + outputs.dir(extractDir) + + doLast { + if (!archive.exists()) { + println("Downloading $url") + archive.parentFile.mkdirs() + URL(url).openStream().use { input -> + FileOutputStream(archive).use { output -> + input.copyTo(output) + } + } + } + + val sha256Url = "https://github.com/astral-sh/python-build-standalone/releases/download/$pbsRelease/SHA256SUMS" + val shaFile = file("$downloadDir/SHA256SUMS-$pbsRelease") + if (!shaFile.exists()) { + URL(sha256Url).openStream().use { input -> + FileOutputStream(shaFile).use { output -> + input.copyTo(output) + } + } + } + + val sha256sums = shaFile.readText() + val astralExpectedHash = sha256sums.lines().find { it.endsWith(assetName) }?.substringBefore(" ") + ?: throw GradleException("Checksum for $assetName not found in SHA256SUMS") + + val digest = MessageDigest.getInstance("SHA-256") + archive.inputStream().use { + val buffer = ByteArray(8192) + var bytesRead = it.read(buffer) + while (bytesRead != -1) { + digest.update(buffer, 0, bytesRead) + bytesRead = it.read(buffer) + } + } + val astralActualHash = digest.digest().joinToString("") { String.format("%02x", it) } + + if (astralExpectedHash != astralActualHash) { + archive.delete() + throw GradleException("Checksum mismatch for $assetName. Expected $astralExpectedHash, got $astralActualHash") + } + + verifyChecksum(lockKey, archive) + maybeVerifyPbsSigstore(archive, astralActualHash) + + val isEmpty = extractDir.list()?.isEmpty() ?: true + if (isEmpty) { + println("Extracting $archive to $extractDir") + copy { + from(tarTree(resources.gzip(archive))) + into(extractDir) + } + } + requireHeaderVersion(platform, extractDir) + } + } +} + +val androidTargets = mapOf( + "android-aarch64" to "aarch64", + "android-x86_64" to "x86_64" +) + +val androidDownloadTasks = androidTargets.map { (platform, arch) -> + val url = "https://www.python.org/ftp/python/$pythonOrgReleaseDir/python-$configuredPythonVersion-$arch-linux-android.tar.gz" + val archive = file("$downloadDir/python-$configuredPythonVersion-$arch-linux-android.tar.gz") + val extractDir = file("$extractedDir/$platform") + val lockKey = "$platform-$configuredPythonVersion" + pythonArchiveKeys[lockKey] = archive + + val taskName = "downloadPython_${platform.replace("-", "_")}" + tasks.register(taskName) { + inputs.property("url", url) + outputs.dir(extractDir) + + doLast { + if (!archive.exists()) { + println("Downloading $url") + archive.parentFile.mkdirs() + URL(url).openStream().use { input -> + FileOutputStream(archive).use { output -> + input.copyTo(output) + } + } + } + + // python.org publishes no SHA256SUMS, so the lockfile is what pins these bytes. It also + // publishes a sibling `.sigstore` bundle, which `maybeVerifySigstore` checks + // against the release manager's pinned identity when `-PverifyPythonSignatures=true`. + // (An earlier comment here claimed Sigstore verification was "unreasonable in pure + // Gradle". It is not -- `dev.sigstore:sigstore-java` does it in-process; see the + // section above `updatePythonChecksums` for why it is opt-in rather than always on.) + verifyChecksum(lockKey, archive) + maybeVerifySigstore(archive, url) + + val isEmpty = extractDir.list()?.isEmpty() ?: true + if (isEmpty) { + println("Extracting $archive to $extractDir") + copy { + from(tarTree(resources.gzip(archive))) + into(extractDir) + } + } + requireHeaderVersion(platform, extractDir) + } + } +} + +/** + * Which project publishes the iOS `Python.xcframework` for the configured version. + * + * python.org began publishing an official iOS XCframework with 3.15 (the first entries in + * `ftp/python/3.15.0/` are the 3.15.0b1 betas). BeeWare's Python-Apple-support, which was the + * only source before that, stops at `3.14-b11` (3.14.7) and has no 3.15 tag. The two do not overlap: + * 3.14 and earlier can only come from BeeWare, 3.15 and later only from python.org. So this is a + * hard switch on the version, not a preference. + * + * Swapping is otherwise cheap, because the trees are layout-compatible everywhere this build + * reaches into them -- `Python.xcframework//Python.framework/Headers`, `-F .../`, + * `Python.xcframework/lib/pythonX.Y` and `...//lib-arm64/pythonX.Y` all exist in both. + * The differences are in parts nothing here reads: BeeWare adds a `platform-config/` directory + * (cross-compilation sysconfig data for building wheels) and a `VERSIONS` file, and its headers + * still carry `module.modulemap`, `lock.h`, `monitoring.h` and `typeslots.h` where 3.15 has + * `pyabi.h`, `slots.h` and `slots_generated.h` instead -- a 3.14-vs-3.15 difference, not a + * packaging one. Both ship a GIL-enabled build; neither publishes a free-threaded iOS variant. + */ +val iosFromPythonOrg = pythonVersion.split(".").let { + it[0].toInt() > 3 || (it[0].toInt() == 3 && it[1].toInt() >= 15) +} + +val iosArchiveName = if (iosFromPythonOrg) { + "python-$pythonVersion-iOS-XCframework.tar.gz" +} else { + "Python-$libVersion-iOS-support.$pythonAppleSupportBuild.tar.gz" +} +val iosUrl = if (iosFromPythonOrg) { + "https://www.python.org/ftp/python/$pythonOrgReleaseDir/$iosArchiveName" +} else { + "https://github.com/beeware/Python-Apple-support/releases/download/$libVersion-$pythonAppleSupportBuild/$iosArchiveName" +} +val iosArchive = file("$downloadDir/$iosArchiveName") +val iosExtractDir = file("$extractedDir/ios") +val iosLockKey = if (iosFromPythonOrg) "ios-$pythonVersion-pythonorg" else "ios-$libVersion-$pythonAppleSupportBuild" +pythonArchiveKeys[iosLockKey] = iosArchive + +val downloadPython_ios = tasks.register("downloadPython_ios") { + inputs.property("url", iosUrl) + outputs.dir(iosExtractDir) + + doLast { + if (!iosArchive.exists()) { + println("Downloading $iosUrl") + iosArchive.parentFile.mkdirs() + URL(iosUrl).openStream().use { input -> + FileOutputStream(iosArchive).use { output -> + input.copyTo(output) + } + } + } + + // The two iOS sources are not equally verifiable, and the difference is worth stating. + // + // BeeWare (<= 3.14) publishes five tar.gz assets and nothing else -- no checksums, no + // signatures, no GitHub attestations. The lockfile pin is the only honest instrument + // there, and no amount of build wiring changes that. + // + // python.org (3.15+) publishes a sibling `.sigstore` bundle, which IS verifiable + // here. An earlier comment claimed otherwise; it was wrong. + // + // The lockfile applies to both regardless, because it is the only check that works + // offline and the only one that covers every source. + verifyChecksum(iosLockKey, iosArchive) + if (iosFromPythonOrg) maybeVerifySigstore(iosArchive, iosUrl) + + // The iOS extract dir is keyed by pythonVersion, not by archive, so bumping the pinned + // archive (b10 -> b11) would otherwise keep the old tree: "not empty, skip". A stamp of the + // archive's lock key + digest tells a stale tree from a current one (issue #47). + val iosStamp = file("$iosExtractDir/.archive-stamp") + val iosWanted = "$iosLockKey ${iosArchive.name}" + if (iosExtractDir.exists() && (!iosStamp.exists() || iosStamp.readText().trim() != iosWanted)) { + println("Discarding stale iOS extraction in $iosExtractDir") + iosExtractDir.deleteRecursively() + } + val isEmpty = iosExtractDir.list()?.isEmpty() ?: true + if (isEmpty) { + println("Extracting $iosArchive to $iosExtractDir") + copy { + from(tarTree(resources.gzip(iosArchive))) + into(iosExtractDir) + } + iosStamp.writeText(iosWanted + "\n") + } + requireHeaderVersion("ios", iosExtractDir) + } +} + +val downloadAllPythonBuilds by tasks.registering { + dependsOn(downloadTasks) + dependsOn(androidDownloadTasks) + dependsOn(downloadPython_ios) +} + +/** + * Public build output (issue #46): the include directory of the CPython this build acquires, per + * target and flavour, each carrying its extraction task. Consumers (pypackpack's compile slot, + * TypedPython's C build) read it with + * `project(":python-multiplatform").extensions.getByType().includeDir("macos-aarch64")` + * after `evaluationDependsOn(":python-multiplatform")`. Only the desktop flavour selected with + * `-PpythonFreeThreaded` has a task; Android and iOS are always GIL. See + * `docs/platforms/python-version-acquisition.md` §7. + */ +extensions.add( + "cpythonIncludeDirectories", + CPythonIncludeDirectories( + layout.projectDirectory.dir(extractedDir.absolutePath), + configuredPythonVersion, + ) { target, flavour -> + val desktop = target in CPythonIncludeLayout.desktopTargets + val extractedFlavour = if (desktop && pythonFreeThreaded) CPythonFlavour.FREE_THREADED else CPythonFlavour.GIL + if (flavour == extractedFlavour) tasks.named(CPythonIncludeLayout.downloadTaskName(target)) else null + }, +) + +/** + * Public build output (issue #61): the CPython version this build embeds, as the `pythonMultiplatform` + * extension, as Gradle attributes on the published variants, and as a classpath resource + * (`META-INF/python-multiplatform/python.properties`) in the jar and the AAR. See + * `docs/platforms/python-version-acquisition.md` "Published version". + */ +val embeddedPythonVersion = EmbeddedPythonVersion(configuredPythonVersion, pythonFreeThreaded) +embeddedPythonVersion.register(project) + +val generatePythonProperties by tasks.registering { + val outputDir = layout.buildDirectory.dir("generated/python-properties") + val content = embeddedPythonVersion.renderProperties() + inputs.property("content", content) + outputs.dir(outputDir) + doLast { + val file = outputDir.get().asFile.resolve(EmbeddedPythonVersion.RESOURCE_PATH) + file.parentFile.mkdirs() + file.writeText(content) + } +} val androidBuildDir = "$projectDir/build/android" +// ================================================================================================= +// GraalVM reachability metadata for the desktop (Panama/FFM) surface. +// +// This file used to be a checked-in static resource. Under `native-image`'s closed world every +// `FunctionDescriptor` that `Linker.downcallHandle`/`Linker.upcallStub` will ever see has to be +// declared up front; anything missing does not fail the build, it fails at *runtime* with +// `MissingForeignRegistrationError`. A checked-in list therefore rots silently: adding one +// `find("PyX", Integer.TYPE, P, P, P)` line to `bindings.kt` with a shape nothing else uses +// produces a binary that builds clean and dies on first call. It had already rotted: the +// checked-in list carried one upcall descriptor while `Panama` builds three, so `ProxyTypeFactory` +// would have died in any image that reached it. See ROADMAP §7. +// +// So the shapes are derived from the sources that define them: +// +// * `bindings.kt` -- one `find(symbol, returnType, params...)` per CPython entry +// point; `Panama.findSymbol` turns each into a descriptor. +// * `ShapeDowncalls.desktop.kt` -- the 14-entry shared shape vocabulary, declared as +// `Panama.unboundDowncallHandle(intArgs, floatArgs, kind)`. +// * `Panama.kt` -- `findSym("malloc"/"free", ...)` plus the upcall stub +// builders, whose descriptors are spelled out in +// `// @UpcallShape(...)` markers next to each builder. +// +// Every one of those is a *source-of-truth* read, and each parse is bounded by a count assertion +// (see `check(...)` calls below): a declaration form the parser does not recognise fails the +// build rather than silently dropping a descriptor. `ReachabilityMetadataTest` then re-checks the +// generated file against the `MethodHandle.type()`s the code actually produces at runtime, which +// is a verification path that shares no code with this parser. +// ================================================================================================= + +abstract class GenerateReachabilityMetadata : DefaultTask() { + + @get:InputFile + abstract val bindingsSource: RegularFileProperty + + @get:InputFile + abstract val shapeVocabularySource: RegularFileProperty + + @get:InputFile + abstract val panamaSource: RegularFileProperty + + /** Hand-authored entries (resources, reflection) that no source file can imply. */ + @get:InputFile + abstract val baseMetadata: RegularFileProperty + + @get:Input + abstract val relativePath: Property + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + /** A descriptor: FFM carrier names, e.g. `long (long, int)`. */ + private data class Descriptor(val returnType: String, val parameterTypes: List) + + @TaskAction + fun generate() { + val downcalls = LinkedHashSet() + downcalls += parseBindings(bindingsSource.get().asFile.readText()) + downcalls += parseShapeVocabulary(shapeVocabularySource.get().asFile.readText()) + + val panamaText = panamaSource.get().asFile.readText() + downcalls += parsePanamaDowncalls(panamaText) + val upcalls = parseUpcallShapes(panamaText) + + val json = buildString { + append("{\n") + append(" \"foreign\": {\n") + append(" \"downcalls\": [\n") + append(renderDescriptors(downcalls.sortedWith(descriptorOrder))) + append(" ],\n") + append(" \"upcalls\": [\n") + append(renderDescriptors(upcalls.sortedWith(descriptorOrder))) + append(" ]\n") + append(" }") + for ((key, value) in readBaseEntries()) { + append(",\n \"").append(key).append("\": ") + append(indentContinuation(groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(value)))) + } + append("\n}\n") + } + + val target = outputDirectory.get().asFile.resolve(relativePath.get()) + target.parentFile.mkdirs() + target.writeText(json) + logger.lifecycle( + "Reachability metadata: ${downcalls.size} downcall + ${upcalls.size} upcall descriptors -> $target" + ) + } + + private val descriptorOrder = + compareBy({ it.parameterTypes.size }, { it.returnType }, { it.parameterTypes.joinToString(",") }) + + private fun renderDescriptors(items: List): String = items.joinToString(",\n", postfix = "\n") { + val params = it.parameterTypes.joinToString(", ") { p -> "\"$p\"" } + " { \"returnType\": \"${it.returnType}\", \"parameterTypes\": [$params] }" + } + + /** JsonOutput indents by 4; the rest of this document uses 2, nested one level under the root. */ + private fun indentContinuation(pretty: String): String = pretty.lines() + .joinToString("\n") { line -> + if (line.isEmpty()) line + else " ".repeat(2 + line.takeWhile { it == ' ' }.length / 2) + line.trimStart() + } + .trimStart() + + @Suppress("UNCHECKED_CAST") + private fun readBaseEntries(): Map { + val parsed = groovy.json.JsonSlurper().parse(baseMetadata.get().asFile) as Map + // `_`-prefixed keys are documentation for the humans editing the base file; native-image + // validates the document against a schema, so they must not reach the generated output. + return parsed.filterKeys { !it.startsWith("_") } + } + + // ---- bindings.kt ----------------------------------------------------------------------- + + /** + * `P` is `Panama.POINTER_TYPE` (`Long.TYPE`), and `LongLong`/`Double` are the `java.lang` + * aliases `bindings.kt` imports. A token outside this table is a new carrier type whose FFM + * layout this task cannot guess -- failing is the point. + */ + private val javaTypeTokens = mapOf( + "P" to "long", + "Void.TYPE" to "void", + "Integer.TYPE" to "int", + "LongLong.TYPE" to "long", + "Long.TYPE" to "long", + "Double.TYPE" to "double", + "Float.TYPE" to "float", + "Short.TYPE" to "short", + "Byte.TYPE" to "byte", + "Character.TYPE" to "char", + "Boolean.TYPE" to "boolean", + "POINTER_TYPE" to "long", + ) + + private fun carrier(token: String, where: String): String = javaTypeTokens[token] + ?: throw GradleException( + "Unknown carrier type `$token` in $where. Add it to GenerateReachabilityMetadata's " + + "javaTypeTokens table (with the FFM layout name native-image expects) before using it." + ) + + private fun parseBindings(text: String): Set { + val declarations = Regex("""val\s+\w+Handle\s*:\s*MethodHandle""").findAll(text).count() + val callSites = Regex("""=\s*find\(""").findAll(text).count() + val matches = Regex("""=\s*find\("(\w+)"((?:\s*,\s*[\w.]+)*)\s*\)""").findAll(text).toList() + + check(declarations == callSites) { + "bindings.kt declares $declarations MethodHandle fields but has $callSites `= find(` " + + "assignments; every declared handle must be initialised by a `find(...)` call for " + + "its descriptor to be derivable." + } + check(matches.size == callSites) { + "bindings.kt has $callSites `= find(` assignments but only ${matches.size} of them " + + "match the expected `find(\"symbol\", ReturnType, ParamType...)` form. The " + + "unmatched ones would be missing from the reachability metadata." + } + + return matches.mapTo(LinkedHashSet()) { match -> + val symbol = match.groupValues[1] + val tokens = match.groupValues[2].split(',').map { it.trim() }.filter { it.isNotEmpty() } + val types = tokens.map { carrier(it, "bindings.kt find(\"$symbol\", ...)") } + Descriptor(types.first(), types.drop(1)) + } + } + + // ---- ShapeDowncalls.desktop.kt --------------------------------------------------------- + + private fun parseShapeVocabulary(text: String): Set { + val occurrences = Regex("""Panama\.unboundDowncallHandle\(""").findAll(text).count() + val matches = Regex( + """Panama\.unboundDowncallHandle\(\s*(\d+)\s*,\s*(\d+)\s*,\s*ReturnKind\.(\w+)\s*\)""" + ).findAll(text).toList() + + check(matches.size == occurrences) { + "ShapeDowncalls.desktop.kt has $occurrences `Panama.unboundDowncallHandle(` calls but " + + "only ${matches.size} match the literal `(intArgs, floatArgs, ReturnKind.X)` form." + } + + return matches.mapTo(LinkedHashSet()) { match -> + val intArgs = match.groupValues[1].toInt() + val floatArgs = match.groupValues[2].toInt() + val returnType = when (val kind = match.groupValues[3]) { + "VOID" -> "void" + "LONG" -> "long" + "DOUBLE" -> "double" + else -> throw GradleException("Unknown ReturnKind.$kind in ShapeDowncalls.desktop.kt") + } + // `buildShape` lays out intArgs JAVA_LONGs followed by floatArgs JAVA_DOUBLEs. The + // leading target-address argument is not part of the descriptor: it is bound by + // `filterArguments`, so it exists on the MethodHandle but not on the FunctionDescriptor. + Descriptor(returnType, List(intArgs) { "long" } + List(floatArgs) { "double" }) + } + } + + // ---- Panama.kt ------------------------------------------------------------------------- + + private fun parsePanamaDowncalls(text: String): Set = + Regex("""findSym\("(\w+)"\s*,\s*([\w.]+)\s*,\s*arrayOf\(([^)]*)\)\s*\)""") + .findAll(text) + .mapTo(LinkedHashSet()) { match -> + val symbol = match.groupValues[1] + val returnType = carrier(match.groupValues[2], "Panama.kt findSym(\"$symbol\", ...)") + val params = match.groupValues[3].split(',').map { it.trim() }.filter { it.isNotEmpty() } + .map { carrier(it, "Panama.kt findSym(\"$symbol\", ...)") } + Descriptor(returnType, params) + } + + /** + * Upcall descriptors are assembled reflectively inside the stub builders, so there is no + * literal to read. Each builder therefore carries a `// @UpcallShape(...)` marker, and a + * builder without one fails the build -- that is what stops a fourth stub shape from being + * added without reaching the metadata. + */ + private fun parseUpcallShapes(text: String): Set { + val lines = text.lines() + val markerRegex = Regex( + """//\s*@UpcallShape\(\s*returnType\s*=\s*"(\w+)"\s*,\s*parameterTypes\s*=\s*\[([^\]]*)]\s*\)""" + ) + val builderRegex = Regex("""val\s+buildUpcallStub\w*\s*:""") + + val result = LinkedHashSet() + lines.forEachIndexed { index, line -> + if (!builderRegex.containsMatchIn(line)) return@forEachIndexed + val marker = (maxOf(0, index - 4) until index) + .reversed() + .firstNotNullOfOrNull { markerRegex.find(lines[it]) } + ?: throw GradleException( + "Panama.kt:${index + 1} declares an upcall stub builder with no " + + "`// @UpcallShape(returnType = \"...\", parameterTypes = [...])` marker " + + "above it. Without the marker its FunctionDescriptor cannot reach the " + + "reachability metadata, and native-image would fail at runtime with " + + "MissingForeignRegistrationError." + ) + val params = marker.groupValues[2].split(',') + .map { it.trim().trim('"') }.filter { it.isNotEmpty() } + result += Descriptor(marker.groupValues[1], params) + } + check(result.isNotEmpty()) { "Panama.kt declares no upcall stub builders -- parser is out of date." } + return result + } +} + +val nativeImageMetadataPath = + "META-INF/native-image/org.thisisthepy/python-multiplatform/reachability-metadata.json" + +val generateDesktopReachabilityMetadata by tasks.registering(GenerateReachabilityMetadata::class) { + group = "native" + description = "Derives the GraalVM foreign (FFM) reachability metadata from the desktop FFI sources" + bindingsSource.set(layout.projectDirectory.file("src/desktopMain/kotlin/python/native/ffi/bindings.kt")) + shapeVocabularySource.set( + layout.projectDirectory.file("src/desktopMain/kotlin/python/native/ffi/ShapeDowncalls.desktop.kt") + ) + panamaSource.set(layout.projectDirectory.file("src/desktopMain/kotlin/python/native/ffi/Panama.kt")) + baseMetadata.set(layout.projectDirectory.file("native-image/reachability-metadata.base.json")) + relativePath.set(nativeImageMetadataPath) + outputDirectory.set(layout.buildDirectory.dir("generated/native-image-metadata/desktop")) +} +val generateWasmProxyExports = tasks.register("generateWasmProxyExports") { + packageName.set("python.multiplatform.generated.wasm") + outputDirectory.set(layout.buildDirectory.dir("generated/wasmProxyExports/test")) +} + kotlin { - /** Uncomment this block to enable WebAssembly support (currently not supported by Python Multiplatform) - @OptIn(ExperimentalWasmDsl::class) + // ROADMAP §10. The target is a leaf directly under `commonMain` -- deliberately not under an + // intermediate source set, because `EmbedAPI.kt`'s `expect inline fun`s crash the compiler when + // combined with an intermediate `expect`/`actual` (see docs/design/architecture.md). + // + // `nodejs()` carries the suite: the tests have to drive a real CPython Emscripten build, and + // Node can load `python.wasm` off the filesystem with no webpack step to fight. + // + // `browser()` carries a *different* claim, and it is there because three of the four defects + // ROADMAP §10 records were invisible to Node -- `cpython.mjs` failing to resolve in a webpack + // context, `node:fs`/NODEFS having no browser equivalent, and the generated entry module's + // shape. Each was found by hand-driving a served bundle, which is evidence that expires. The + // browser test task is a small, deliberately chosen subset (see `wasmJsBrowserTest` below); the + // point is not to run the suite twice but to make the browser route fail a build when it breaks. + // + // What makes this target able to reach CPython at all is that Kotlin 2.4.20-Beta2 *imports* its + // linear memory (`intrinsics.memory`) instead of defining one. Emscripten's memory is handed in + // there, so a `PyObject*` is an address Kotlin can dereference directly. See docs/platforms/wasm-design.md + // and src/wasmJsMain/README.md. + @OptIn(org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl::class) wasmJs { - moduleName = "sample" + nodejs() browser { - val rootDirPath = project.rootDir.path - val projectDirPath = project.projectDir.path - commonWebpackConfig { - outputFileName = "demo.js" - devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { - static = (static ?: mutableListOf()).apply { - // Serve sources to debug inside browser - add(rootDirPath) - add(projectDirPath) - } + testTask { + useKarma { + useChromeHeadless() } } } - binaries.executable() } - */ androidTarget { + // ROADMAP §15d: without this line the Android target gets no Maven publication at all -- + // Kotlin Multiplatform only creates one when a variant is named here -- so an Android app + // consumer could not resolve this library by coordinates no matter where it was published. + // Every other target had a `publishPublicationToMavenLocal`; `androidTarget` had + // none, and `./gradlew tasks --all` was the only place that showed it. + // + // Release only. A debug variant would double the artefact for a build type nobody consumes + // by coordinates, and the AAR's payload (see `copyAndroidPythonAssets` below) is the + // expensive part, not the classes. + publishLibraryVariants("release") + @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { jvmTarget.set(JvmTarget.JVM_17) @@ -80,37 +1008,70 @@ kotlin { val copyAndroidPythonBinaries by tasks.creating(Copy::class) { dependsOn( tasks.named("linkAndroidNativeArm64"), - tasks.named("linkAndroidNativeX64") + tasks.named("linkAndroidNativeX64"), + downloadAllPythonBuilds ) into("$androidBuildDir/jniLibs/") - abiList.forEach { - from("$libPathForAndroid/$it") { - include("libpython*.*.so") + abiList.forEach { abi -> + val arch = if (abi == "arm64-v8a") "aarch64" else "x86_64" + from("$extractedDir/android-$arch/prefix/lib") { + include("libpython*.so") include("lib*_python.so") - into(it) + into(abi) } } } + // Everything staged here lands in the consumer's APK, so what it excludes is a + // packaging decision, not a build-tree tidy-up. Measured on the release AAR before any + // exclusion (40.1 MB on disk, 137.9 MB of entries): + // + // assets//lib/**/test 7.11 MB compressed 32.30 MB raw x2 ABIs + // jni/ 5.31 / 5.09 MB 15.0 / 15.3 MB + // assets//lib/** (rest) 3.16 MB 11.91 MB x2 + // assets//lib/**/lib-dynload 2.46 / 2.37 MB 7.6 / 7.3 MB + // assets//include 0.45 MB 1.91 MB x2 + // classes/manifest/etc 0.49 MB 0.52 MB + // + // The library itself is 0.49 MB of that. Two of the entries above are payload nothing + // on a device ever reads, and both are dropped here: + // + // - `include/python$libVersion` is CPython's C headers. cinterop reads them from the + // extraction tree (`targetIncludePath`), never from assets, and no Kotlin source in + // `androidMain`, `artMain`, `androidInstrumentedTest` or `sample` opens an asset + // under `include/`. They were being shipped to every device for nothing. + // - `test` is CPython's own regression suite -- 32 MB raw per ABI, 38% of the AAR -- + // and nothing in this repository imports it. + // + // What is deliberately *not* excluded, so the reasoning survives: `lib-dynload` is the + // compiled extension modules and is the only genuinely per-ABI part of the tree (the + // pure-Python half is byte-identical between arm64-v8a and x86_64 apart from five + // sysconfig files, verified with `diff -rq`), and `idlelib`/`ensurepip`/`tkinter` are + // dead weight for most embedders but are ordinary stdlib that a consumer may import. + // "CPython's own test suite" and "C headers" are provable; "nobody wants tkinter" is + // not. val copyAndroidPythonAssets by tasks.creating(Copy::class) { + dependsOn(downloadAllPythonBuilds) into("$androidBuildDir/assets/") - abiList.forEach { - from(includePath) { - into("$it/include/python$libVersion") // include - } - } - abiList.forEach { - from("$libPathForAndroid/$it/python$libVersion") { - exclude("config-$libVersion-aarch64-linux-android/") - exclude("config-$libVersion-x86_64-linux-android/") - into("$it/lib/python$libVersion") // python stdlib + // A `Copy` never removes what it stopped copying, and this directory is an AGP + // asset source root -- so a tree staged by an older revision of this task keeps + // shipping in the AAR and the APK forever. That is exactly how `include/` and + // `test/` would have survived the exclusions above on any machine that had built + // once before them. + doFirst { delete("$androidBuildDir/assets/") } + abiList.forEach { abi -> + val arch = if (abi == "arm64-v8a") "aarch64" else "x86_64" + from("$extractedDir/android-$arch/prefix/lib/python$libVersion") { + exclude("config-$libVersion-$arch-linux-android/") + exclude("test/") + into("$abi/lib/python$libVersion") } } } - tasks.whenTaskAdded { - if (name.startsWith("merge") && name.endsWith("JniLibFolders")) { + tasks.configureEach { + if (name.startsWith("merge") && (name.endsWith("JniLibFolders") || name.endsWith("NativeLibs"))) { dependsOn(copyAndroidPythonBinaries) } - if (name.startsWith("package") && name.endsWith("Assets")) { + if (name != "copyAndroidPythonAssets" && name.endsWith("Assets")) { dependsOn(copyAndroidPythonAssets) } } @@ -127,20 +1088,93 @@ kotlin { tasks.withType { duplicatesStrategy = DuplicatesStrategy.WARN } + val hostPlatform = when { + System.getProperty("os.name").contains("Mac") -> + if (System.getProperty("os.arch") == "aarch64") "macos-aarch64" else "macos-x86_64" + System.getProperty("os.name").contains("Windows") -> "windows-x86_64" + else -> "linux-x86_64" + } tasks.withType { duplicatesStrategy = DuplicatesStrategy.WARN + inputs.property("hostPlatform", hostPlatform) + // The metadata carries no `${hostPlatform}` placeholder today: the CPython library is + // no longer registered as an image resource (see native-image/reachability-metadata. + // base.json for why, and manager.kt for the filesystem sidecar that replaced it). The + // substitution is kept because any platform-scoped `resources` glob added back needs + // it -- a native image bundles one platform, and the jar carries all four. + filesMatching("**/reachability-metadata.json") { + filter { line -> + line.replace("\${hostPlatform}", hostPlatform) + } + } } + // `tasks` here is the *project's* container, not the desktop target's, so this block sees + // every `Jar` in the module -- `allMetadataJar` included. That is fine for the licence and + // was not fine for the CPython libraries, hence the name guard below. + // + // `allMetadataJar` is the artifact published at the root coordinate + // `io.github.thisisthepy:python-multiplatform`, which is what every Kotlin Multiplatform + // consumer resolves in order to compile against `commonMain`. Measured on the mavenLocal + // publication while enabling the Android one (§15d): **87.4 MB, of which 83.0 MB was four + // host platforms' libpython** (`lib/linux-x86_64` alone 64.7 MB compressed, 240 MB raw) + // against 0.3 MB of actual metadata. Nothing reads `lib/` out of a metadata jar -- + // `manager.loadLibPython` reads it off the desktop *runtime* classpath, which is + // `desktopJar` and stays unchanged. + // + // The sources jars never picked the libraries up in the first place (`desktopSourcesJar` + // and `androidReleaseSourcesJar` both measure 212-218 KB with no `lib/` entry), so this + // narrowing changes exactly one artifact. tasks.withType { duplicatesStrategy = DuplicatesStrategy.WARN from(licensePath) { into("META-INF/LICENSE") } - from(libPathForDesktop) { - // TODO: Let the script include the only files needed for the platform - include("windows-*/*") - include("linux-*/*") - include("macos-*/*") - into("lib") + } + // Issue #74. python-build-standalone ships `libpython3.14.so -> libpython3.14.so.1.0` as a + // symlink. Gradle's `tarTree` extracts a symlink as an empty regular file, so the jar used + // to carry a 0-byte `libpython3.14.so` (the name the loader asks for) beside the real + // 252 MB `libpython3.14.so.1.0` that nothing asked for. Staging here: + // 1. drops every zero-length library (a symlink stub is never a library), + // 2. renames `libX.so.1.0` to `libX.so`, so the jar carries the real file under the name + // `System.mapLibraryName` produces, + // Debug info is not stripped here (issue #86): the archives acquired above are python-build- + // standalone's `install_only_stripped` flavour, so the linux library already has none, and + // a host `strip` (absent or ELF-blind on macOS) would only be a second, unreliable mechanism. + val stageDesktopLibraries = tasks.register("stageDesktopLibraries") { + dependsOn(downloadAllPythonBuilds) + val stageDir = layout.buildDirectory.dir("desktop-libs") + into(stageDir) + from("$extractedDir") { + include("macos-*$desktopFlavourSuffix/python/lib/libpython*.dylib") + include("linux-*$desktopFlavourSuffix/python/lib/libpython*.so*") + include("windows-*$desktopFlavourSuffix/python/python*.dll") + include("windows-*$desktopFlavourSuffix/python/vcruntime*.dll") + // `macos-*` matches `macos-aarch64-freethreaded` too. + if (!pythonFreeThreaded) exclude("*-freethreaded/**") + eachFile { + if (file.length() == 0L) { exclude(); return@eachFile } + val platform = relativePath.segments[0].removeSuffix(desktopFlavourSuffix) + val filename = name.replace(Regex("""(\.so)(\.\d+)+$"""), "$1") + path = "lib/$platform/$filename" + } + includeEmptyDirs = false + } + } + tasks.withType().matching { it.name == "desktopJar" }.configureEach { + if (configuredPythonVersion == "3.13.0" && !pythonFreeThreaded) { + from(libPathForDesktop) { + include("windows-*/*") + include("linux-*/*") + include("macos-*/*") + into("lib") + } + } else { + // Issue #74: the libraries are packed from the staged tree, not straight from the + // extraction, so that symlinks are resolved and the linux library is stripped. + dependsOn(stageDesktopLibraries) + from(stageDesktopLibraries.map { it.destinationDir }) { + include("lib/**") + } } } } @@ -161,31 +1195,50 @@ kotlin { IOS_SIMULATOR_ARM64 -> "ios-arm64_x86_64-simulator" else -> throw RuntimeException("Unsupported ABI: $konanTarget") } - val targetLibPath = when(konanTarget.family) { - Family.ANDROID -> libPathForAndroid - Family.IOS -> libPathForIOS + val targetExtractDir = when(konanTarget.family) { + Family.ANDROID -> "$extractedDir/android-${if (targetABI == "arm64-v8a") "aarch64" else "x86_64"}/prefix" + Family.IOS -> "$extractedDir/ios/Python.xcframework" + else -> throw RuntimeException("Unsupported target family: ${konanTarget.family}") + } + val targetIncludePath = when(konanTarget.family) { + Family.ANDROID -> "$targetExtractDir/include/python$libVersion" + Family.IOS -> "$targetExtractDir/$targetABI/Python.framework/Headers" else -> throw RuntimeException("Unsupported target family: ${konanTarget.family}") } compilations.getByName("main").cinterops.create("python") { - headers("$includePath/Python.h") + headers("$targetIncludePath/Python.h") packageName("python.native.ffi.bindings") - includeDirs(includePath) + includeDirs(targetIncludePath) if (konanTarget.family == Family.IOS) { - compilerOpts("-framework", "Python", "-F$projectDir/$targetLibPath/$targetABI", "-fno-common", "-fvisibility=hidden") + compilerOpts("-framework", "Python", "-F$targetExtractDir/$targetABI", "-fno-common", "-fvisibility=hidden") + } + } + + if (konanTarget.family == Family.ANDROID) { + compilations.getByName("main").cinterops.create("jni_onload") { + defFile("src/artMain/cinterop/jni_onload.def") } } binaries { if (konanTarget.family == Family.ANDROID) { sharedLib("multiplatform_python$libVersion") { - linkerOpts.addAll(listOf("-L$projectDir/$targetLibPath/$targetABI/", "-lpython$libVersion")) + linkerOpts.addAll(listOf("-L$targetExtractDir/lib/", "-lpython$libVersion", "-u", "JNI_OnLoad")) linkTaskProvider.configure { val type = if (buildType == NativeBuildType.DEBUG) "debug" else "release" - copy { - from(outputFile) - into(file("$androidBuildDir/$type/jniLibs/$targetABI/")) + val dest = file("$androidBuildDir/$type/jniLibs/$targetABI/") + // doLast, not the configure block itself: a bare copy {} here runs at + // CONFIGURATION time, so it stages whatever the previous build left in + // outputFile. That is why a changed .def or source produced an APK with + // a stale library and an UnsatisfiedLinkError that looked like a code + // bug -- the fix had to be built twice for the second run to pick it up. + doLast { + copy { + from(outputFile) + into(dest) + } } } afterEvaluate { @@ -193,14 +1246,30 @@ kotlin { preBuild.dependsOn(linkTaskProvider) } } + getTest(NativeBuildType.DEBUG).linkerOpts.addAll(listOf( + "-L$targetExtractDir/lib/", + "-lpython$libVersion", + "-Wl,--allow-shlib-undefined", + "-u", "JNI_OnLoad" + )) } else if (konanTarget.family == Family.IOS) { framework { baseName = "PythonMultiplatform" linkerOpts.addAll(listOf( - "-framework", "Python", "-F$projectDir/$targetLibPath/$targetABI", "-Objc" + "-framework", "Python", "-F$targetExtractDir/$targetABI", "-Objc" )) } + + // The test executable is a separate binary from the framework above, + // so it needs its own linker flags to resolve the embedded CPython symbols. + // Python.framework has an @rpath-relative install name and is not copied + // next to the test binary, so an explicit -rpath is required for dyld to + // find it when the test runs on the simulator. + getTest(NativeBuildType.DEBUG).linkerOpts.addAll(listOf( + "-framework", "Python", "-F$targetExtractDir/$targetABI", + "-rpath", "$targetExtractDir/$targetABI" + )) } } } @@ -209,14 +1278,57 @@ kotlin { sourceSets { val jvmMain by creating val commonMain by getting + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + val jvmTest by creating + jvmTest.dependsOn(commonTest) val desktopMain by getting { resources.srcDirs("src/desktopMain/resources") + resources.srcDir(generatePythonProperties) + // `reachability-metadata.json` is generated, not checked in -- see + // GenerateReachabilityMetadata above. Wiring it as a resource srcDir (through the + // task's own output provider, so the dependency is inferred) is what puts it on the + // native-image classpath at META-INF/native-image/... + resources.srcDir(generateDesktopReachabilityMetadata.flatMap { it.outputDirectory }) } + val desktopTest by getting + desktopTest.dependsOn(jvmTest) val androidMain by getting jvmMain.dependsOn(commonMain) desktopMain.dependsOn(jvmMain) androidMain.dependsOn(jvmMain) + // `commonTest` holds the entire object-model suite, and it used to run only on desktop and + // the iOS simulator -- yet Android is the platform where that model was most recently + // found broken. It runs here as an *instrumented* test: `androidUnitTest` executes on a + // host JVM that cannot load the arm64/x86_64 `.so`, so the interpreter is unreachable + // there. `jvmTest` (not `commonTest` directly) keeps the test hierarchy the mirror of the + // main one -- desktopTest -> jvmTest -> commonTest, androidInstrumentedTest -> jvmTest. + val androidInstrumentedTest by getting { + dependencies { + implementation(libs.androidx.test.junit) + implementation("androidx.test:runner:1.6.2") + // On the JVM, `kotlin.test.Test` and the `assert*` functions are `expect` + // declarations; kotlin-test-junit supplies the actuals, mapping `kotlin.test.Test` + // onto `org.junit.Test` by typealias. Without it commonTest does not compile here, + // and -- because AndroidJUnitRunner discovers tests by scanning the dex for + // `org.junit.Test` -- it is also what makes the runner see these classes at all. + implementation(libs.kotlin.test) + implementation(libs.kotlin.test.junit) + } + } + // KGP warns here ("Source Set groups can't depend on 'jvmTest' together as they belong to + // different Kotlin Source Set Trees") because androidInstrumentedTest lives in the + // `instrumentedTest` tree while desktopTest lives in `test`. There is no way to share + // commonTest with an instrumented compilation without crossing that line, and the warning + // is exactly that: both compilations build the shared sources independently, which is what + // we want. It is why `forceGC()` needs an `actual` in androidInstrumentedTest as well as + // in androidUnitTest -- they are two separate compilations of the same target. + androidInstrumentedTest.dependsOn(jvmTest) + val nativeMain by creating nativeMain.dependsOn(commonMain) try { // when not on macOS @@ -232,6 +1344,16 @@ kotlin { iosMain.dependsOn(nativeMain) } + // wasmJs sits directly under commonMain, as a sibling of jvmMain and nativeMain. It shares + // nothing with either: there is no JNI, no Panama and no cinterop here, only `@WasmImport` + // against CPython's own wasm exports. + val wasmJsMain by getting + wasmJsMain.dependsOn(commonMain) + val wasmJsTest by getting { + kotlin.srcDir(generateWasmProxyExports.map { it.outputDirectory }) + } + wasmJsTest.dependsOn(commonTest) + val artMain by creating { kotlin.srcDir("src/artMain/kotlin") } @@ -240,14 +1362,1325 @@ kotlin { artMain.dependsOn(nativeMain) androidNativeX64Main.dependsOn(artMain) androidNativeArm64Main.dependsOn(artMain) + + // `src/nativeTest` existed as a directory long before this line, and nothing pointed at + // it -- so its `GCLeakTest.native.kt` was dead source, and the same file had been copied + // byte-for-byte into all five target test source sets to compensate. This is the mirror of + // the main hierarchy (nativeMain -> iosMain/artMain), which is what a test for anything in + // `nativeMain` needs: a test placed in one target's source set is compiled for that target + // only, and androidNative is exactly the target that goes unbuilt when that happens. + val nativeTest by creating + nativeTest.dependsOn(commonTest) + listOf( + "iosX64Test", "iosArm64Test", "iosSimulatorArm64Test", + "androidNativeX64Test", "androidNativeArm64Test", + ).forEach { getByName(it).dependsOn(nativeTest) } + + // ...and `artTest` completes the mirror for the other half: things true of androidNative + // but not of iOS. Without it such a test has nowhere to live except duplicated into both + // androidNative target source sets, which is the arrangement the comment above describes + // as the problem. + val artTest by creating { + kotlin.srcDir("src/artTest/kotlin") + } + artTest.dependsOn(nativeTest) + listOf("androidNativeX64Test", "androidNativeArm64Test") + .forEach { getByName(it).dependsOn(artTest) } + } +} + +/** + * 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`. + * + * The simulator distribution archive does contain that stdlib, so unpack it into the build + * directory and hand its location to the test binary. + */ +val extractIosSimulatorStdlib by tasks.registering(Copy::class) { + dependsOn(downloadAllPythonBuilds) + from("$extractedDir/ios/Python.xcframework/lib/python$libVersion") { + into("lib/python$libVersion") + } + from("$extractedDir/ios/Python.xcframework/ios-arm64_x86_64-simulator/lib-arm64/python$libVersion") { + into("lib/python$libVersion") + } + into(layout.buildDirectory.dir("python-stdlib/ios-simulator")) + includeEmptyDirs = false +} + +tasks.withType().configureEach { + dependsOn(extractIosSimulatorStdlib) + val pythonHome = layout.buildDirectory.dir("python-stdlib/ios-simulator").get().asFile.absolutePath + // simctl only forwards variables into the spawned process when they carry this prefix. + environment("SIMCTL_CHILD_PYTHONHOME", pythonHome) + environment("PYTHONHOME", pythonHome) +} + +// ================================================================================================= +// The standard library inside an iOS *app* -- SPEC L-10, issue #59. +// +// `extractIosSimulatorStdlib` above serves the simulator *test* binary, which `simctl` points at a +// workspace path through SIMCTL_CHILD_PYTHONHOME. An installed app has no such variable, and a path on +// this workspace's external volume parks the sandboxed app at 0% CPU (iosMain/README.md). So an app +// carries its own prefix inside its bundle, staged here per slice and copied in by the Xcode Run +// Script phase `tools/xcode/install-python.sh`; `IosPythonHome` (iosMain) finds it at run time. +// The selection rules are `IosPythonHomeLayout` in the Gradle plugin, where they are unit-tested. +// ================================================================================================= + +val iosPythonHomeRoot = layout.buildDirectory.dir("python-ios-home") + +val stageIosPythonHomeSlices = python.multiplatform.gradle.IosPythonHomeLayout.slices.map { slice -> + tasks.register(slice.stageTaskName, python.multiplatform.gradle.StageIosPythonHomeTask::class.java) { + group = "python" + description = "Stages CPython's standard library for an iOS app built for ${slice.name} (SPEC L-10)" + dependsOn(downloadPython_ios) + xcframework.set(file("$extractedDir/ios/Python.xcframework")) + sliceName.set(slice.name) + pythonVersion.set(configuredPythonVersion) + destinationDir.set(iosPythonHomeRoot.map { it.dir(slice.name) }) } } +/** Every slice; the Xcode phase uses [stageIosPythonHomeForXcode], which stages only the one it builds. */ +val stageIosPythonHome by tasks.registering { + group = "python" + description = "Stages CPython's standard library for every iOS app slice (SPEC L-10)" + dependsOn(stageIosPythonHomeSlices) +} + +/** + * The Xcode phase's entry point. Picks the slice from `EFFECTIVE_PLATFORM_NAME` and `ARCHS` -- Xcode + * exports both to a Run Script phase, and `./gradlew` passes the client's environment to the build -- + * stages it, and prints two lines a `-q` run leaves alone on stdout: + * + * PYTHON_HOME_DIR=/python-ios-home//home + * PYTHON_DYLIB_INFO_TEMPLATE=/python-ios-home//dylib-Info-template.plist + * + * The same contract shape as toolchain's `stagePythonBundleIosForXcode` (`PYTHON_PAYLOAD_DIR=`). + * Run outside Xcode (no `EFFECTIVE_PLATFORM_NAME`), or for a universal `ARCHS`, it fails while the + * task graph is built, with the reason, rather than staging a guessed slice. + */ +val stageIosPythonHomeForXcode by tasks.registering { + group = "python" + description = "Stages the iOS app prefix for the slice Xcode is building and prints its path (SPEC L-10)" + val platformName = providers.environmentVariable("EFFECTIVE_PLATFORM_NAME") + val archs = providers.environmentVariable("ARCHS") + val selected = providers.provider { + python.multiplatform.gradle.IosPythonHomeLayout.sliceFor(platformName.orNull, archs.orNull) + } + // Only the slice being built: staging all three costs three lib-dynload trees per build. + dependsOn(selected.map { tasks.named(it.stageTaskName) }) + val root = iosPythonHomeRoot.map { it.asFile.absolutePath } + doLast { + val slice = selected.get().name + println("PYTHON_HOME_DIR=${root.get()}/$slice/${python.multiplatform.gradle.StageIosPythonHomeTask.PREFIX_DIRECTORY}") + println("PYTHON_DYLIB_INFO_TEMPLATE=${root.get()}/$slice/${python.multiplatform.gradle.StageIosPythonHomeTask.TEMPLATE_NAME}") + } +} + +// ================================================================================================= +// androidNative -- giving the target a test *run*, not just a test *link*. +// +// Every other target in this build has a task that executes its tests. androidNative had +// `androidNativeArm64TestBinaries` and nothing that ran the binary it produced, because KGP +// registers an execution task only where it knows how to reach a host: `KotlinNativeTest` for the +// build machine, `KotlinNativeSimulatorTest` for simctl. An Android device is neither, so the +// whole of `commonTest` compiled for this target on every build and had never once been executed. +// +// That gap is visible in docs/design/upcall.md: the five-platform upcall table has an empty +// androidNative row, and `537c1a0b` says it was left empty rather than estimated. It is also the +// exact situation ROADMAP §11b was in for Android/ART, where attaching the suite to a target that +// had only ever compiled it surfaced two real defects in the first twelve tests. +// +// What the run needs, and why each piece is here: +// +// 1. **The binary itself.** `test.kexe` is a normal ELF executable; `adb push` + `chmod 755` + +// exec from `/data/local/tmp` is enough. No APK, no instrumentation, no JVM. +// 2. **libpython.** The test binary is linked `-lpython3.14` (see `getTest(DEBUG).linkerOpts` +// above), so `libpython3.14.so` and the extension modules' shared objects have to sit +// somewhere the dynamic loader looks -- hence `LD_LIBRARY_PATH`. +// 3. **A standard library.** `Py_Initialize()` does not fail without one, it *aborts the +// process* ("Failed to import encodings module"), which would be reported as a run that +// produced no tests. Same reason `extractIosSimulatorStdlib` exists above and the same reason +// `PythonInstrumentationRunner` unpacks assets on Android/ART; here the prefix is pushed to +// the device and `PYTHONHOME` points at it. +// 4. **Results in the same shape as every other target.** The Kotlin/Native runner's TeamCity +// logger is the only machine-readable output it has, so its service messages are parsed back +// into JUnit XML under `build/test-results/androidNativeTest/`. That is the directory +// AGENTS.md says to count, and counting it is only trustworthy if a *crashed* run is +// distinguishable from a clean one -- so a `testStarted` with no `testFinished` is written +// out as a failure naming the exit code, rather than silently dropped. A native suite that +// dies takes the rest of the run with it, and the difference between "212 passed" and "212 +// passed then the process died" is the whole point of running this at all. +// ================================================================================================= + +/** Where the payload is staged on the device. One directory per ABI; ABIs never share a run. */ +fun androidNativeTestDeviceDir(abi: String) = "/data/local/tmp/pmp-nativetest-$abi" + +/** Runs a command, returning its exit code and combined output. */ +fun runCommand(command: List): Pair { + val process = ProcessBuilder(command).redirectErrorStream(true).start() + process.outputStream.close() + val text = process.inputStream.bufferedReader().readText() + return process.waitFor() to text +} + +/** + * Undoes TeamCity's escaping: `|n` `|r` `|'` `|[` `|]` `||` and the `|0xNNNN` form. + * Anything else after `|` stands for itself. + */ +fun teamCityUnescape(value: String): String { + val out = StringBuilder(value.length) + var i = 0 + while (i < value.length) { + val c = value[i] + if (c != '|' || i == value.length - 1) { + out.append(c); i++; continue + } + when (val e = value[i + 1]) { + 'n' -> { out.append('\n'); i += 2 } + 'r' -> { out.append('\r'); i += 2 } + // The three Unicode line separators the Kotlin/Native logger escapes by name. + 'x' -> { out.append('\u0085'); i += 2 } + 'l' -> { out.append('\u2028'); i += 2 } + 'p' -> { out.append('\u2029'); i += 2 } + '0' -> { + // |0xNNNN -- a single UTF-16 unit written as hex. + if (i + 6 <= value.length && value[i + 2] == 'x') { + out.append(value.substring(i + 3, minOf(i + 7, value.length)).toInt(16).toChar()); i += 7 + } else { out.append(e); i += 2 } + } + else -> { out.append(e); i += 2 } + } + } + return out.toString() +} + +/** + * Splits a service message body into its `key='value'` attributes. + * + * Values are scanned character by character rather than matched with a regex: `'` is a legal + * character inside a value (escaped as `|'`), and a value ending in `||` puts a literal `|` + * immediately before the closing quote, so "closing quote is the first `'` not preceded by `|`" + * is wrong on exactly the messages that carry an assertion message. + */ +fun parseServiceMessageAttributes(body: String): Map { + val attributes = LinkedHashMap() + var i = 0 + while (i < body.length) { + while (i < body.length && body[i] != '=') { + i++ + } + if (i >= body.length) break + val keyEnd = i + var keyStart = keyEnd + while (keyStart > 0 && !body[keyStart - 1].isWhitespace()) keyStart-- + i++ // '=' + if (i >= body.length || body[i] != '\'') continue + i++ // opening quote + val raw = StringBuilder() + while (i < body.length && body[i] != '\'') { + if (body[i] == '|' && i + 1 < body.length) { + raw.append(body[i]).append(body[i + 1]); i += 2 + } else { + raw.append(body[i]); i++ + } + } + i++ // closing quote + attributes[body.substring(keyStart, keyEnd)] = teamCityUnescape(raw.toString()) + } + return attributes +} + +/** XML text/attribute escaping. `<`, `&` and `"` are the only ones that can appear here. */ +fun xmlEscape(value: String): String = value + .replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'") + // The runner prints raw bytes from Python; a stray control character makes the XML unparseable. + .map { if (it.code < 0x20 && it != '\n' && it != '\r' && it != '\t') ' ' else it } + .joinToString("") + +/** + * Turns one run's TeamCity output into JUnit XML files under [resultsDir], and returns + * `Triple(total, failed, ignored)`. + * + * [exitCode] matters: a native test binary that aborts leaves a `testStarted` with no + * `testFinished`, and that has to become a *failure* rather than a missing row -- see the header + * comment. + */ +fun writeNativeTestResults( + output: String, + resultsDir: File, + exitCode: Int, + label: String, +): Triple { + class Case(val suite: String, val name: String) { + var durationMs: Long = 0 + var failureMessage: String? = null + var failureDetails: String? = null + var ignored = false + var finished = false + val output = StringBuilder() + } + + val suites = LinkedHashMap>() + var currentSuite: String? = null + var currentCase: Case? = null + + for (line in output.lineSequence()) { + val trimmed = line.trim() + if (trimmed.startsWith("##teamcity[") && trimmed.endsWith("]")) { + val body = trimmed.removePrefix("##teamcity[").removeSuffix("]") + val kind = body.substringBefore(' ') + val attributes = parseServiceMessageAttributes(body) + val name = attributes["name"] ?: "" + when (kind) { + "testSuiteStarted" -> { + currentSuite = name + suites.getOrPut(name) { mutableListOf() } + } + "testSuiteFinished" -> currentSuite = null + "testStarted" -> currentCase = Case(currentSuite ?: "unknown", name) + .also { suites.getOrPut(it.suite) { mutableListOf() }.add(it) } + "testFailed" -> currentCase?.apply { + failureMessage = attributes["message"] ?: "failed" + failureDetails = attributes["details"] ?: "" + } + "testIgnored" -> { + val case = currentCase ?: Case(currentSuite ?: "unknown", name) + .also { suites.getOrPut(it.suite) { mutableListOf() }.add(it) } + case.ignored = true + case.finished = true + currentCase = null + } + "testFinished" -> currentCase?.apply { + durationMs = attributes["duration"]?.toLongOrNull() ?: 0 + finished = true + }.also { currentCase = null } + } + } else { + // Not a service message: the test's own stdout. `UpcallBoundaryCostTest` reports + // through `println`, so this is not decoration -- it is the measurement. + // Anything printed outside a test is kept only in `run-output.txt`; attaching it to a + // testcase it did not come from would misattribute it. + currentCase?.output?.append(line)?.append('\n') + } + } + + // A case still open at the end of the stream is a case the process died inside of. + for (case in suites.values.flatten()) { + if (!case.finished && case.failureMessage == null) { + case.failureMessage = + "the test process exited (code $exitCode) during this test and never reported a result" + case.failureDetails = buildString { + appendLine("androidNative test binary died mid-suite on $label.") + appendLine("Everything after this test in the run order was never executed.") + appendLine() + appendLine("--- tail of the run's output ---") + append(output.takeLast(4000)) + } + } + } + + resultsDir.deleteRecursively() + resultsDir.mkdirs() + + var total = 0 + var failed = 0 + var ignored = 0 + for ((suite, cases) in suites) { + if (cases.isEmpty()) continue + val suiteFailures = cases.count { it.failureMessage != null } + val suiteIgnored = cases.count { it.ignored } + total += cases.size + failed += suiteFailures + ignored += suiteIgnored + val xml = buildString { + appendLine("""""") + append("") + for (case in cases) { + append(" ") + continue + } + appendLine(">") + if (case.ignored) appendLine(" ") + if (failure != null) { + appendLine(" ") + appendLine(xmlEscape(case.failureDetails ?: "")) + appendLine(" ") + } + if (case.output.isNotEmpty()) { + appendLine(" ${xmlEscape(case.output.toString())}") + } + appendLine(" ") + } + appendLine("") + } + File(resultsDir, "TEST-$suite.xml").writeText(xml) + } + return Triple(total, failed, ignored) +} + +listOf("Arm64" to "arm64-v8a", "X64" to "x86_64").forEach { (targetSuffix, abi) -> + val arch = if (abi == "arm64-v8a") "aarch64" else "x86_64" + val stagingDir = layout.buildDirectory.dir("androidNativeTest/$abi") + + // The interpreter half of the payload: the shared objects the test binary is linked against, + // and the standard library `Py_Initialize()` refuses to start without. `config-*` is excluded + // for the same reason `copyAndroidPythonAssets` excludes it -- it is build machinery for + // compiling extensions, not runtime. + val stagePayload = tasks.register("stageAndroidNative${targetSuffix}TestPayload") { + dependsOn(downloadAllPythonBuilds) + from("$extractedDir/android-$arch/prefix/lib") { + include("*.so") + include("*.so.*") + into("lib") + } + from("$extractedDir/android-$arch/prefix/lib/python$libVersion") { + exclude("config-$libVersion-*/") + into("lib/python$libVersion") + } + into(stagingDir) + includeEmptyDirs = false + } + + tasks.register("androidNative${targetSuffix}Test") { + group = "verification" + description = "Runs the androidNative $abi test binary on a connected device or emulator." + dependsOn("linkDebugTestAndroidNative$targetSuffix", stagePayload) + // The device is not an input Gradle can fingerprint, and a green run says nothing about + // the next one on a different device. + outputs.upToDateWhen { false } + + val testBinary = (kotlin.targets.getByName("androidNative$targetSuffix") + as org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget) + .binaries.getTest(NativeBuildType.DEBUG).outputFile + val resultsRoot = layout.buildDirectory.dir("test-results/androidNative${targetSuffix}Test") + val staged = stagingDir + val requestedSerial = (project.findProperty("androidNativeTestSerial")?.toString() + ?: System.getenv("ANDROID_SERIAL")) + val testFilter = project.findProperty("androidNativeTestFilter")?.toString() + val sdkDir = android.sdkDirectory + + doLast { + val adb = File(sdkDir, "platform-tools/adb") + if (!adb.isFile) { + throw GradleException( + "adb not found at $adb. Set ANDROID_HOME (or sdk.dir in local.properties) to an " + + "SDK that has platform-tools installed." + ) + } + + // ---- pick the device(s) ---------------------------------------------------------- + val (listCode, listOutput) = runCommand(listOf(adb.absolutePath, "devices")) + if (listCode != 0) throw GradleException("`adb devices` failed:\n$listOutput") + val online = listOutput.lineSequence() + .drop(1) + .mapNotNull { line -> + val parts = line.trim().split(Regex("\\s+")) + if (parts.size >= 2 && parts[1] == "device") parts[0] else null + } + .toList() + if (online.isEmpty()) { + throw GradleException( + "No device or emulator is connected. `androidNative${targetSuffix}Test` runs the " + + "test binary on a device -- there is no host to fall back to for this target." + ) + } + + // An x86_64 emulator cannot run the arm64 binary and vice versa, and the failure if it + // is tried is a bare "not executable" from the shell, so the ABI is checked up front. + val candidates = (if (requestedSerial != null) listOf(requestedSerial) else online).filter { serial -> + val (_, abiList) = runCommand( + listOf(adb.absolutePath, "-s", serial, "shell", "getprop", "ro.product.cpu.abilist") + ) + val supported = abiList.trim().split(",").map { it.trim() } + val matches = abi in supported + if (!matches) { + logger.lifecycle("androidNative$targetSuffix: skipping $serial (supports ${abiList.trim()}, needs $abi)") + } + matches + } + if (candidates.isEmpty()) { + throw GradleException( + "No connected device supports $abi (connected: ${online.joinToString()}). " + + "Use -PandroidNativeTestSerial= to name one explicitly." + ) + } + + val binary = testBinary + if (!binary.isFile) throw GradleException("Test binary not found at $binary") + + // AGENTS.md: results are counted out of this directory, and a crashed run that leaves + // the previous run's XML behind gets counted as the previous run. + resultsRoot.get().asFile.deleteRecursively() + + var totalFailures = 0 + val summaries = mutableListOf() + for (serial in candidates) { + val remote = androidNativeTestDeviceDir(abi) + + // Read from outside the process what the process is supposed to discover about + // itself. `AndroidNativePlatform` used to be three hardcoded placeholders, and a + // hardcoded value is only distinguishable from a real read by comparing against a + // source the binary does not control -- these three properties are that source, and + // they differ per device, so the same binary is held to a different expectation on + // each emulator it is pushed to. See `AndroidNativeDeviceIdentityTest`. + fun deviceProperty(name: String): String { + val (code, value) = runCommand( + listOf(adb.absolutePath, "-s", serial, "shell", "getprop", name) + ) + val trimmed = value.trim() + if (code != 0 || trimmed.isEmpty()) { + throw GradleException( + "`adb -s $serial shell getprop $name` returned nothing (exit $code). " + + "The androidNative test binary is checked against this value, so " + + "the run cannot proceed without it." + ) + } + return trimmed + } + val deviceSdk = deviceProperty("ro.build.version.sdk") + val deviceRelease = deviceProperty("ro.build.version.release") + val deviceAbi = deviceProperty("ro.product.cpu.abi") + val label = "$serial (API $deviceSdk, $abi)" + + logger.lifecycle("androidNative$targetSuffix: staging to $label") + runCommand(listOf(adb.absolutePath, "-s", serial, "shell", "mkdir", "-p", remote)) + // `--sync` is what makes this usable in a loop: the 60 MB standard library is + // pushed once and then compared rather than re-sent (2506 files skipped in <0.1s). + for (arguments in listOf( + listOf("push", "--sync", File(staged.get().asFile, "lib").absolutePath, "$remote/"), + listOf("push", "--sync", binary.absolutePath, "$remote/test.kexe"), + )) { + val (code, text) = runCommand(listOf(adb.absolutePath, "-s", serial) + arguments) + if (code != 0) throw GradleException("adb ${arguments.first()} to $label failed:\n$text") + } + runCommand(listOf(adb.absolutePath, "-s", serial, "shell", "chmod", "755", "$remote/test.kexe")) + + val filterArgument = testFilter?.let { " --ktest_gradle_filter='$it'" } ?: "" + val command = "cd $remote && LD_LIBRARY_PATH=$remote/lib PYTHONHOME=$remote " + + "PMP_DEVICE_API_LEVEL='$deviceSdk' PMP_DEVICE_RELEASE='$deviceRelease' " + + "PMP_DEVICE_ABI='$deviceAbi' " + + "./test.kexe --ktest_logger=TEAMCITY$filterArgument" + logger.lifecycle("androidNative$targetSuffix: running on $label") + val (exitCode, runOutput) = runCommand(listOf(adb.absolutePath, "-s", serial, "shell", command)) + + // One subdirectory per device when there is more than one, so two emulators do not + // overwrite each other's XML and the run cannot be miscounted as a single pass. + val resultsDir = if (candidates.size > 1) File(resultsRoot.get().asFile, serial) + else resultsRoot.get().asFile + val (total, failed, ignored) = writeNativeTestResults(runOutput, resultsDir, exitCode, label) + if (total == 0) { + throw GradleException( + "The androidNative $abi test binary reported no tests on $label (exit $exitCode). " + + "That is what a process that aborts before the first test looks like:\n" + + runOutput.takeLast(4000) + ) + } + totalFailures += failed + summaries += "$label: $total tests, $failed failed, $ignored ignored (exit $exitCode)" + File(resultsDir, "run-output.txt").writeText(runOutput) + } + + summaries.forEach { logger.lifecycle("androidNative$targetSuffix: $it") } + if (totalFailures > 0) { + throw GradleException( + "androidNative $abi tests failed: $totalFailures failure(s). " + + "See ${resultsRoot.get().asFile}" + ) + } + } + } +} + +// ================================================================================================= +// ROADMAP §10 -- staging CPython next to the wasmJs test bundle. +// +// Three things have to be true before a `wasmJs` test can reach the interpreter, and none of them +// is something the Kotlin/Wasm toolchain does on its own: +// +// 1. `python.mjs`/`python.wasm` must sit next to the compiled Kotlin, because `cpython.mjs` +// (src/wasmJsMain/resources) imports the glue by relative specifier. +// 2. `cpython.mjs` needs the *real* build directory, not the staging copy: Emscripten derives +// `sys.prefix` from `thisProgram` and mounts the host filesystem through NODEFS, so the stdlib +// is found at its original path. That path is handed over in a generated `cpython-config.mjs` +// rather than an environment variable, because `KotlinJsTest` does not forward one. +// 3. The generated import object must hand Kotlin's `intrinsics.memory` import Emscripten's +// memory instead of the placeholder the compiler emits. +// +// (3) is the whole integration, and it is one expression. Kotlin 2.4.20-Beta2 *imports* its linear +// memory declared `min=0, max=none`; a wasm memory import accepts any memory whose limits sit +// inside its own, so an unbounded import accepts Emscripten's bounded one. Emscripten is built +// exactly as it would be alone -- no `-sIMPORTED_MEMORY`, no binary patching. Kotlin adapts. +// Ordering comes free from ES modules: the import-object module imports `cpython.mjs`, which has a +// top-level `await`, so Emscripten is fully instantiated before the import object is built. +// +// The CPython build itself is produced by `tools/wasm/build-cpython.sh` (also reachable as the +// `buildWasmPython` task below) -- CPython 3.14.2 matched to `pyemscripten_2026_0` (PEP 783), +// relinked with `wasmExports,wasmMemory` added to `-sEXPORTED_RUNTIME_METHODS`. Without those two the +// 8287 wasm exports are present in the binary but unreachable from JS, so there is nothing to hand +// `@WasmImport`; neither appears in PEP 783's ABI-sensitive list. Unlike every other platform's +// CPython this is a build, not a download: no distributor ships a python.wasm with those exports. +// +// The script stages `python.wasm`, `python.mjs` and the stdlib zip into `/.caches/wasm-runtime` +// -- the same three files an unpacked `python-multiplatform-wasm-runtime` zip holds -- and that is +// the default here. It used to be an absolute path outside the repository; when that directory was +// moved, every wasm task on every checkout silently skipped. +// ================================================================================================= + +val wasmPythonDir: String = (project.findProperty("wasmPythonDir")?.toString() + ?: System.getenv("PMP_PYTHON_DIR") + ?: rootProject.layout.projectDirectory.dir(".caches/wasm-runtime").asFile.absolutePath) + +/** + * Builds the CPython Emscripten runtime into `.caches/` and stages it at the default + * [wasmPythonDir]. Long (a full CPython cross build) and needs network the first time (emsdk 5.0.3 + * and the CPython 3.14.2 checkout), so nothing depends on it: the wasm tasks keep their + * skip-with-a-message contract when it has not been run. + */ +val buildWasmPython by tasks.registering(Exec::class) { + group = "python" + description = "Builds CPython 3.14.2 for wasm32-emscripten (pyemscripten_2026_0) into .caches/" + val script = rootProject.layout.projectDirectory.file("tools/wasm/build-cpython.sh").asFile + commandLine("bash", script.absolutePath, "all") +} + +/** + * Where `tools/wasm/build-cpython.sh wheels` puts the pinned compiled wheels the wasm suite loads + * (`wasmJsTest/.../WasmCompiledWheelTest`). Not checked in: they are downloaded by URL and accepted + * only against the sha256 the script pins. + */ +val wasmWheelsDir: String = (project.findProperty("wasmWheelsDir")?.toString() + ?: System.getenv("PMP_WASM_WHEELS_DIR") + ?: rootProject.layout.projectDirectory.dir(".caches/wasm-wheels").asFile.absolutePath) + +/** Downloads the pinned wheels into [wasmWheelsDir]. Network, a few MB; nothing depends on it. */ +val fetchWasmWheels by tasks.registering(Exec::class) { + group = "python" + description = "Downloads the pinned pyemscripten_2026_0 wheels the wasm suite loads into .caches/" + val script = rootProject.layout.projectDirectory.file("tools/wasm/build-cpython.sh").asFile + environment("WASM_WHEELS_DIR", wasmWheelsDir) + commandLine("bash", script.absolutePath, "wheels") +} + +/** + * The test-only tail of the test bundle's `cpython-config.mjs`: where the wheels were extracted, and + * whether their absence is a failure. Appended here, never by [cpythonConfigModule], because a + * consumer's bundle has no business with either. All three are always written -- the test reads a + * missing knob as "the build did not stage anything", which is a failure rather than a skip. + */ +fun testWheelConfig(sitePackages: File?, wheelsDir: File, required: Boolean): String = + "\n// Test-only, appended by the wasmJs KotlinJsTest doFirst. See WasmCompiledWheelTest.\n" + + "export const PMP_TEST_SITE_PACKAGES = ${groovy.json.JsonOutput.toJson(sitePackages?.absolutePath)};\n" + + "export const PMP_TEST_WHEELS_DIR = ${groovy.json.JsonOutput.toJson(wheelsDir.absolutePath)};\n" + + "export const PMP_TEST_WHEELS_REQUIRED = $required;\n" + +/** + * The generated `cpython-config.mjs` that `cpython.mjs` imports. + * + * Both constants are generated unconditionally: a named export that is missing fails the *whole* ES + * import, not the branch that reads it, so the module's import list has to be the same in both + * bundles. + * + * @param stdlibZipUrl where the standard library zip sits relative to the importing module -- a URL + * a browser `fetch`es and a path Node reads with `fs`. `null` only when no zip was staged at all, + * which leaves the interpreter dependent on a CPython source checkout being present; see + * `cpython.mjs`'s `nodeSettings`. + */ +fun cpythonConfigModule(pythonDir: File, stdlibZipUrl: String?): String = + "// Generated by python-multiplatform/build.gradle.kts. Do not edit.\n" + + "//\n" + + "// PYTHON_DIR is a *Node* fact: NODEFS mounts the real filesystem and the interpreter chdirs\n" + + "// here. It is not where the standard library comes from -- that is STDLIB_ZIP_URL on both\n" + + "// hosts -- so it may be an unpacked runtime artefact rather than a CPython build directory.\n" + + "export const PYTHON_DIR = ${groovy.json.JsonOutput.toJson(pythonDir.absolutePath)};\n" + + "\n" + + "// STDLIB_ZIP_URL is read by both hosts: neither MEMFS nor a browser has a stdlib until this\n" + + "// zip is installed as `/lib/python3.zip` during preRun.\n" + + "export const STDLIB_ZIP_URL = ${groovy.json.JsonOutput.toJson(stdlibZipUrl)};\n" + +// ================================================================================================= +// ROADMAP §10 -- why a *missing* interpreter can be made a build failure on demand. +// +// Every wasm task below skips when there is no CPython Emscripten build at `wasmPythonDir`, so that +// a checkout without one still builds every other target. That contract is right for a laptop and +// wrong for CI: `.github/workflows/wasm.yml` asked a runner for `wasmJsNodeTest`, the runner had no +// such build, the task skipped, and the job reported **green having run zero tests**. A green tick +// that measures nothing is worse than a red one, because nobody goes looking. +// +// `-PrequireWasmRuntime=true` turns every one of those skips into a failure that names the path it looked +// for. It is opt-in rather than the default precisely because the default has to keep a checkout +// without an Emscripten build working. +// ================================================================================================= + +val requireWasmRuntime: Boolean = + project.findProperty("requireWasmRuntime")?.toString()?.toBoolean() ?: false + +/** + * `true` when [marker] is present; otherwise skips, or fails under `-PrequireWasmRuntime=true`. + * + * The decision is made inside `onlyIf` on purpose -- that is the same predicate that produces the + * skip, so there is no second place for the two answers to drift apart. + * + * The message is *logged* before it is thrown, and that is not belt-and-braces. Gradle wraps an + * exception raised from an `onlyIf` predicate and reports only `Could not evaluate spec for 'Task + * satisfies onlyIf spec'` -- checked, not assumed: the first version of this printed exactly that + * and nothing else, which would have replaced a green tick that says nothing with a red one that + * says nothing. + */ +fun Task.wasmRuntimePresent(marker: File): Boolean { + if (marker.isFile) return true + val remedy = "Unpack the published runtime " + + "(io.github.thisisthepy:python-multiplatform-wasm-runtime:$libraryVersion) and point " + + "-PwasmPythonDir / PMP_PYTHON_DIR at it, or build one into the default location with " + + "./gradlew :python-multiplatform:buildWasmPython (tools/wasm/build-cpython.sh)." + val what = "$name: no CPython Emscripten build at ${marker.parentFile} " + + "(looked for ${marker.name})." + if (requireWasmRuntime) { + val message = "$what -PrequireWasmRuntime was passed, so this is a failure rather than a " + + "skip: the caller asked to run the wasm suite, and skipping would report success " + + "having run nothing. $remedy" + logger.error("FAILING $message") + throw GradleException(message) + } + logger.lifecycle("SKIPPING $what $remedy Pass -PrequireWasmRuntime=true to make this a failure.") + return false +} + +// ================================================================================================= +// ROADMAP §10 -- the same staging, for a *consumer's* browser bundle. +// +// The `KotlinJsTest` block further down stages CPython next to this module's own test bundle. That +// covers `wasmJsNodeTest` and nothing else, and the gap is not the sample's: any project that +// depends on this library and builds a `wasmJs` browser bundle fails webpack outright with +// +// Module not found: Error: Can't resolve './cpython.mjs' in +// '/build/wasm/packages//kotlin' +// +// because `bindings.kt`'s `@WasmImport(MODULE, ...)` makes the *generated import object* carry +// `import * as ... from './cpython.mjs'`, and nothing puts that file into a consumer's webpack +// context. Verified rather than assumed: this library's wasmJs klib +// (`build/classes/kotlin/wasmJs/main/default/resources`) carries no `cpython.mjs`, so there is no +// artefact a consumer could unpack it from either. It is a library defect on the measure that +// matters -- an external consumer meets it identically, and cannot fix it without knowing three +// internals (the glue file, the `intrinsics.memory` placeholder, and the `@WasmExport` handoff). +// +// This task produces the directory a consumer adds to its own `wasmJsMain` resources. Everything in +// it is either this library's glue or the interpreter it was built against: +// +// cpython.mjs this module's resource, verbatim +// cpython-config.mjs generated; in a browser bundle only STDLIB_ZIP_URL is read +// python.mjs Emscripten's glue, loaded by cpython.mjs through a webpackIgnore import +// python.wasm the interpreter +// python3..zip the standard library, fetched into MEMFS during preRun +// +// It is *not* wired into the plugin (`python-multiplatform-gradle-plugin`), which is where a +// consumer outside this repository would have to receive it. That needs the five files above to be +// a published artifact first, and the wasm CPython build is not published anywhere yet -- it is a +// local directory named by `-PwasmPythonDir`. Recorded in ROADMAP §10. +// ================================================================================================= + +val stageWasmBrowserRuntime by tasks.registering { + group = "python" + description = "Assembles CPython's Emscripten build plus this library's glue for a consumer's " + + "wasmJs browser bundle" + + val pythonDir = file(wasmPythonDir) + val glue = layout.projectDirectory.file("src/wasmJsMain/resources/cpython.mjs").asFile + val outputDir = layout.buildDirectory.dir("wasm-browser-runtime") + + // Declared so that a changed glue file or a rebuilt interpreter re-stages, and an unchanged one + // does not re-copy 13 MB on every build. + inputs.file(glue) + inputs.property("wasmPythonDir", wasmPythonDir) + inputs.files( + providers.provider { + if (pythonDir.resolve("python.wasm").isFile) files( + pythonDir.resolve("python.mjs"), + pythonDir.resolve("python.wasm"), + ) else files() + } + ) + outputs.dir(outputDir) + + // Same skip-with-a-message contract the wasm test tasks have: a checkout without an Emscripten + // CPython build must still be able to build every other target. + onlyIf { wasmRuntimePresent(pythonDir.resolve("python.wasm")) } + + doLast { + val dir = outputDir.get().asFile + dir.mkdirs() + + // The stdlib zip is named after the version the interpreter reports, and the build directory + // ships exactly one. Located rather than derived from a version string: the two would be a + // second place to keep in step, and `cpython.mjs` already reads the version out of the + // running interpreter to decide where in MEMFS it goes. + val stdlibZip = pythonDir.listFiles() + ?.firstOrNull { it.name.matches(Regex("""python\d+\.\d+\.zip""")) } + ?: throw GradleException( + "no python..zip in $pythonDir. A browser has no NODEFS, so the " + + "standard library has to travel as the zip CPython's own web example fetches; " + + "the Emscripten build produces it next to python.wasm." + ) + + copy { + from(pythonDir) { include("python.mjs", "python.wasm", stdlibZip.name) } + from(glue) + into(dir) + } + dir.resolve("cpython-config.mjs").writeText( + cpythonConfigModule(pythonDir, stdlibZipUrl = "./${stdlibZip.name}") + ) + logger.lifecycle("Staged CPython's wasm runtime for a browser bundle into $dir") + } +} + +// ------------------------------------------------------------------------------------------------- +// ROADMAP §10 -- the other half of "the wiring is not in python-multiplatform-gradle-plugin". The +// plugin cannot hand a consumer a directory that only exists on this machine; it can only resolve a +// Maven coordinate. So `stageWasmBrowserRuntime`'s output is zipped and published under its own +// artifact ID, at this library's own version, so `implementation("io.github.thisisthepy: +// python-multiplatform:$version")` and the runtime it needs stay paired by construction -- there is +// no second version string for a consumer to get out of step. +// +// This does not need `-PwasmPythonDir` to be published anywhere itself: the zip is built from +// whatever this machine already staged, exactly the same bytes `wasmJsNodeTest` already runs +// against. Publishing is `./gradlew :python-multiplatform:publishWasmRuntimePublicationToMavenLocal` +// (or the aggregate `publishToMavenLocal`), same as every other target this module publishes. +// ------------------------------------------------------------------------------------------------- + +val wasmBrowserRuntimeZip by tasks.registering(Zip::class) { + group = "python" + description = "Zips stageWasmBrowserRuntime's output for the wasmRuntime Maven publication" + dependsOn(stageWasmBrowserRuntime) + from(layout.buildDirectory.dir("wasm-browser-runtime")) + archiveBaseName.set("python-multiplatform-wasm-runtime") + destinationDirectory.set(layout.buildDirectory.dir("wasm-runtime-artifact")) + + // Same skip as the task it zips: a checkout without a local Emscripten CPython build has + // nothing to zip, and must still be able to run every other publishing task. + onlyIf { wasmRuntimePresent(file(wasmPythonDir).resolve("python.wasm")) } +} + +/** + * Rewrites a consumer's generated Kotlin/Wasm output so that it can reach the interpreter. + * + * 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 + * 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. + * + * @param dir the compile-sync output webpack reads, `build/wasm/packages//kotlin`. + * @param modulePrefix the generated module basename, which is `-`. + */ +fun patchKotlinWasmOutputForCPython(dir: File, modulePrefix: String, logger: org.gradle.api.logging.Logger) { + val importObject = dir.listFiles()?.firstOrNull { it.name.endsWith(".import-object.mjs") } + ?: throw GradleException("No *.import-object.mjs in $dir -- the Kotlin/Wasm output layout changed.") + val text = importObject.readText() + + val ns = Regex("""import \* as (\w+) from ['"]\./cpython\.mjs['"];""").find(text)?.groupValues?.get(1) + ?: throw GradleException( + "${importObject.name} does not import ./cpython.mjs. That import is emitted because " + + "bindings.kt declares @WasmImport against it; if it is gone, the binding module changed." + ) + val placeholder = Regex("""memory:\s*new WebAssembly\.Memory\(\{[^}]*}\)""") + if (!placeholder.containsMatchIn(text)) { + if (!text.contains("memory: $ns.wasmMemory")) { + throw GradleException( + "${importObject.name} has no `intrinsics.memory` placeholder to replace. Kotlin used " + + "to emit `new WebAssembly.Memory({ initial: 0 })` there; if that changed, " + + "docs/platforms/wasm-design.md's integration step needs revisiting." + ) + } + } else { + importObject.writeText(placeholder.replace(text, "memory: $ns.wasmMemory")) + logger.lifecycle("Pointed ${importObject.name}'s intrinsics.memory at Emscripten's wasmMemory") + } + + val entry = dir.listFiles() + ?.firstOrNull { + it.name.endsWith(".mjs") && !it.name.contains("import-object") && + !it.name.contains("js-builtins") && it.name.startsWith(modulePrefix) + } + ?: throw GradleException("No Kotlin/Wasm entry module in $dir -- the output layout changed.") + val entryText = entry.readText() + val handoff = "pmpSetKotlinExports" + if (!entryText.contains(handoff)) { + if (!entryText.contains("const exports = wasmInstance.exports")) { + throw GradleException( + "${entry.name} has no `const exports = wasmInstance.exports` to hand to cpython.mjs. " + + "Upcalls need the Kotlin instance's raw exports; if the generated entry module " + + "changed shape, ROADMAP §10's upcall wiring needs revisiting rather than deleting." + ) + } + entry.writeText(handedOffEntryModule(entryText, handoff)) + logger.lifecycle("Handed ${entry.name}'s wasm exports to cpython.mjs for upcall registration") + } +} + +/** + * 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 + * 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` + * is still null". + * + * Observed in a browser rather than reasoned about: the sample printed sections 1-4 correctly and + * then `could not register wasm export 'pmp_invoke'`, with the handoff sitting two lines below the + * call that needed it. + * + * The `import` goes at the top rather than next to the call because ES imports are hoisted and + * evaluated before the module body either way; putting it first is what makes that obvious. + */ +fun handedOffEntryModule(entryText: String, handoff: String): String { + val header = "// Added by python-multiplatform's build: upcall registration needs the raw wasm\n" + + "// exports, and this is the only scope that has them. See ROADMAP §7/§10.\n" + + "import { $handoff } from './cpython.mjs';\n\n" + val startCall = "exports._start();" + val body = if (entryText.contains(startCall)) { + // Before `_start()`, not after: `_start()` is Kotlin `main()`. + entryText.replace(startCall, "$handoff(exports);\n\n$startCall") + } else { + entryText.trimEnd() + "\n\n$handoff(exports);\n" + } + val patched = header + body + + // The postcondition, and it is here because **no test can stand in for it**. `wasmJsBrowserTest` + // runs the library's own test bundle, whose entry module has no `_start()` at all -- checked, + // not assumed: reverting this function to the plain append produced a byte-identical file and + // all ten browser tests stayed green. The bug is only expressible in an *executable* bundle, so + // the only thing that can catch it is a check on the text at the moment it is written. + if (patched.contains(startCall) && patched.indexOf("$handoff(exports)") > patched.indexOf(startCall)) { + throw GradleException( + "the upcall handoff was placed after `$startCall` in the generated entry module. " + + "`_start()` is Kotlin `main()`, so every upcall the application makes would run with " + + "`kotlinExports` still null and `pmpRegisterUpcall` would return -1. See ROADMAP §10." + ) + } + return patched +} + +// ------------------------------------------------------------------------------------------------- +// ROADMAP §10 -- the LinkError guard. +// +// Wasm import types are checked *exactly*, and they are checked at instantiation rather than at the +// call. So one wrong parameter type in `wasmJsMain/.../bindings.kt` does not fail to compile and +// does not fail one test: it raises a `LinkError` that takes out the whole module, and the run +// reports zero tests with a message naming neither the function nor the type. +// +// The declarations were originally read off `python.wasm`'s own type section by hand, precisely +// because they cannot be transliterated from `EmbedAPI.kt` -- `Py_ssize_t` is `Long` there and `i32` +// here, on 14 functions. A hand-read fact rots the way a checked-in metadata file does (ROADMAP §7), +// so this reads it again from the artefact on every run and fails with the offending function named. +// +// Same shape as `generateDesktopReachabilityMetadata`: derive from the thing that decides, never +// from a copy of the answer. +// ------------------------------------------------------------------------------------------------- + +/** + * Reads `python.wasm`'s type, import, function and export sections and returns + * `export name -> (parameter valtypes, result valtypes)` for every exported function. + * + * Only the four sections that matter are decoded; the rest are skipped by their declared size. + */ +fun readWasmExportedFunctionTypes(file: File): Map, List>> { + val bytes = file.readBytes() + var p = 0 + + fun u8(): Int = bytes[p++].toInt() and 0xFF + fun leb(): Int { + var result = 0 + var shift = 0 + while (true) { + val b = u8() + result = result or ((b and 0x7F) shl shift) + if (b and 0x80 == 0) return result + shift += 7 + } + } + fun name(): String { + val n = leb() + val s = String(bytes, p, n, Charsets.UTF_8) + p += n + return s + } + fun valtype(): String = when (val v = u8()) { + 0x7F -> "i32"; 0x7E -> "i64"; 0x7D -> "f32"; 0x7C -> "f64"; 0x7B -> "v128" + 0x70 -> "funcref"; 0x6F -> "externref" + else -> "valtype:0x%02x".format(v) + } + + require( + bytes.size > 8 && bytes[0] == 0x00.toByte() && bytes[1] == 0x61.toByte() && + bytes[2] == 0x73.toByte() && bytes[3] == 0x6D.toByte() + ) { "$file is not a wasm module" } + p = 8 + + val types = mutableListOf, List>>() + val funcTypeIndex = mutableListOf() // funcidx space: imported functions occupy the low end + val exports = mutableListOf>() + + while (p < bytes.size) { + val id = u8() + val size = leb() + val end = p + size + when (id) { + 1 -> repeat(leb()) { + val form = u8() + check(form == 0x60) { + ("$file: type section entry 0x%02x is not a plain functype. The GC rec-group " + + "encodings are not decoded here; if CPython's toolchain started emitting " + + "them this parser needs extending rather than deleting.").format(form) + } + val params = List(leb()) { valtype() } + val results = List(leb()) { valtype() } + types += params to results + } + 2 -> repeat(leb()) { + name(); name() + when (u8()) { + 0x00 -> funcTypeIndex += leb() + 0x01 -> { valtype(); val f = u8(); leb(); if (f == 1) leb() } + 0x02 -> { val f = u8(); leb(); if (f == 1) leb() } + 0x03 -> { valtype(); u8() } + 0x04 -> { u8(); leb() } + else -> error("$file: unknown import descriptor") + } + } + 3 -> repeat(leb()) { funcTypeIndex += leb() } + 7 -> repeat(leb()) { + val n = name() + val kind = u8() + val idx = leb() + if (kind == 0x00) exports += n to idx + } + } + p = end + } + + return exports.mapNotNull { (n, idx) -> + funcTypeIndex.getOrNull(idx)?.let { t -> types.getOrNull(t)?.let { n to it } } + }.toMap() +} + +/** + * The `@WasmImport` declarations of `bindings.kt`, keyed by the imported C name. + * + * The return type is optional in the pattern because Kotlin lets a `Unit`-returning declaration + * omit it, and `free`, `PyObject_GC_UnTrack` and `pmpCallFree` all do. Requiring it made this + * function skip them silently -- three declarations checked by nothing, in a check whose entire + * purpose is to notice a wrong type. The caller asserts that every `external fun` in the file was + * matched, so a future shape change fails loudly instead of shrinking the covered set. + */ +fun readWasmImportDeclarations(file: File): Map, String>> { + val decl = Regex( + """@WasmImport\(MODULE,\s*"([^"]+)"\)\s*\r?\n\s*external\s+fun\s+(\w+)\s*\(([^)]*)\)(\s*:\s*\w+)?""" + ) + return decl.findAll(file.readText()).associate { m -> + val (cName, ktName, params, ret) = m.destructured + val paramTypes = params.split(',').map { it.trim() }.filter { it.isNotEmpty() } + .map { it.substringAfter(':').trim() } + cName to Triple(ktName, paramTypes, ret.substringAfter(':').trim().ifEmpty { "Unit" }) + } +} + +/** Kotlin type -> wasm valtypes, over the primitive subset a `@WasmImport` may use. */ +fun kotlinTypeToValtypes(type: String): List? = when (type) { + "Int" -> listOf("i32") + "Long" -> listOf("i64") + "Float" -> listOf("f32") + "Double" -> listOf("f64") + "Unit" -> emptyList() + else -> null +} + +val verifyWasmAbiSignatures by tasks.registering { + group = "verification" + description = "Checks wasmJs @WasmImport declarations against python.wasm's own type section " + + "-- a mismatch is a LinkError at instantiation, not a compile error." + + val bindingsFile = layout.projectDirectory + .file("src/wasmJsMain/kotlin/python/native/ffi/bindings.kt").asFile + val glueFile = layout.projectDirectory.file("src/wasmJsMain/resources/cpython.mjs").asFile + val wasmFile = file(wasmPythonDir).resolve("python.wasm") + + inputs.file(bindingsFile) + inputs.file(glueFile) + outputs.upToDateWhen { false } + + onlyIf { wasmRuntimePresent(wasmFile) } + + doLast { + val actualTypes = readWasmExportedFunctionTypes(wasmFile) + val declared = readWasmImportDeclarations(bindingsFile) + val glueText = glueFile.readText() + val reExported = Regex("""export const \w+ = bind\("([^"]+)"\)""") + .findAll(glueText).map { it.groupValues[1] }.toSet() + // The handful of imports that are JavaScript rather than CPython -- upcall registration and + // the two `call_indirect` stand-ins. They have no entry in python.wasm's type section, so + // all that can be checked is that the glue really defines them. + val glueFunctions = Regex("""export function (\w+)\s*\(""") + .findAll(glueText).map { it.groupValues[1] }.toSet() + + // Without this a change to the declaration shape would make the whole check pass on a + // subset, which is the failure mode ROADMAP §2 records for `AssembledApiTest`: a green + // acceptance test measuring its own scope. It has already happened here once -- requiring + // an explicit return type quietly excluded the three `Unit`-returning declarations. + val externalFunCount = Regex("""^\s*external\s+fun\s""", RegexOption.MULTILINE) + .findAll(bindingsFile.readText()).count() + check(declared.size == externalFunCount) { + "${bindingsFile.name} has $externalFunCount `external fun` declarations but only " + + "${declared.size} were parsed. The unparsed ones are checked by nothing." + } + check(declared.size > 250) { + "only ${declared.size} @WasmImport declarations in ${bindingsFile.name} -- that is far " + + "below the surface this target is supposed to bind" + } + check(actualTypes.size > 5000) { + "only ${actualTypes.size} exported functions decoded out of ${wasmFile.name} -- " + + "the parser is not reading the module it thinks it is" + } + + val problems = mutableListOf() + var glueChecked = 0 + for ((cName, d) in declared.entries.sortedBy { it.key }) { + val (ktName, paramTypes, retType) = d + if (cName in glueFunctions) { + glueChecked++ + continue + } + if (cName !in reExported) { + problems += "$cName: declared @WasmImport, but cpython.mjs neither re-exports it " + + "from python.wasm nor defines it as glue, so the binding resolves to a thrower" + } + val real = actualTypes[cName] + if (real == null) { + problems += "$cName: not an exported function of ${wasmFile.name}" + continue + } + val wantParams = paramTypes.flatMap { + kotlinTypeToValtypes(it) ?: listOf("?").also { _ -> + problems += "$ktName: parameter type '$it' is not a wasm primitive" + } + } + val wantResults = kotlinTypeToValtypes(retType) ?: listOf("?").also { + problems += "$ktName: return type '$retType' is not a wasm primitive" + } + if (wantParams != real.first || wantResults != real.second) { + problems += "$cName: bindings.kt declares (${wantParams.joinToString()}) -> " + + "(${wantResults.joinToString()}), python.wasm has " + + "(${real.first.joinToString()}) -> (${real.second.joinToString()})" + } + } + + if (problems.isNotEmpty()) { + throw GradleException( + "wasmJs ABI mismatch -- ${problems.size} problem(s). Each is a LinkError at " + + "instantiation, which kills the whole module rather than one call:\n " + + problems.joinToString("\n ") + ) + } + logger.lifecycle( + "verifyWasmAbiSignatures: ${declared.size - glueChecked} @WasmImport declarations " + + "agree with ${wasmFile.name}'s type section, plus $glueChecked glue functions " + + "defined in ${glueFile.name}" + ) + } +} + +tasks.withType().configureEach { + if (!name.startsWith("wasmJs")) return@configureEach + dependsOn(verifyWasmAbiSignatures) + + // ROADMAP §10 -- lifetimes. `GCLeakTest` and `WasmFinalizationTest` need to *ask* for a + // collection, and on this target the collector is the host engine's. `--expose-gc` is the only + // way to reach it; there is no library-callable equivalent, which is why `forceGC()` degrades to + // a no-op when it is absent rather than pretending. Node accepts it on the command line only -- + // `NODE_OPTIONS` rejects V8 flags -- so it goes here. + nodeJsArgs.add("--expose-gc") + + val pythonDir = file(wasmPythonDir) + // The npm project the Kotlin/Wasm node runner actually executes out of, which is under the + // ROOT build directory rather than this module's. + val stagingDir = rootProject.layout.buildDirectory + .dir("wasm/packages/${rootProject.name}-${project.name}-test/kotlin") + + onlyIf { wasmRuntimePresent(pythonDir.resolve("python.mjs")) } + + // Fetching the wheels changes what WasmCompiledWheelTest can prove, so it re-runs the suite. + inputs.files(fileTree(wasmWheelsDir) { include("*.whl") }) + .withPropertyName("wasmWheels").optional() + + doFirst { + val dir = stagingDir.get().asFile + // The stdlib zip is what *both* runners get their standard library from -- see + // `cpython.mjs`'s `installStdlibZip`. Node used to reach it through NODEFS instead, which + // silently made every wasm test depend on a CPython **source checkout** sitting next to the + // build directory; staging it here is what lets `wasmPythonDir` be an unpacked + // `python-multiplatform-wasm-runtime` artefact, which is the only form CI can obtain. + val stdlibZip = pythonDir.listFiles()?.firstOrNull { it.name.matches(Regex("""python\d+\.\d+\.zip""")) } + copy { + from(pythonDir) { include("python.mjs", "python.wasm") } + if (stdlibZip != null) from(stdlibZip) + into(dir) + } + dir.resolve("cpython-config.mjs").writeText( + cpythonConfigModule(pythonDir, stdlibZipUrl = stdlibZip?.let { "./${it.name}" }) + ) + + // The compiled wheels WasmCompiledWheelTest imports. Extracted rather than put on + // `sys.path` as zips: the extension is an Emscripten side module that `dlopen` reads off + // the filesystem, which zipimport cannot serve. Under Node the interpreter reaches this + // host path through the NODEFS mounts in `cpython.mjs`. + val wheels = file(wasmWheelsDir).listFiles { f -> f.name.endsWith(".whl") }?.sortedBy { it.name }.orEmpty() + val sitePackages = dir.resolve("wasm-site-packages") + delete(sitePackages) + if (wheels.isNotEmpty()) { + copy { + wheels.forEach { from(zipTree(it)) } + into(sitePackages) + } + logger.lifecycle("Extracted ${wheels.size} wheel(s) from $wasmWheelsDir for WasmCompiledWheelTest") + } else { + logger.lifecycle( + "No wheels in $wasmWheelsDir -- WasmCompiledWheelTest will " + + (if (requireWasmRuntime) "FAIL (-PrequireWasmRuntime)" else "skip") + + ". Fetch them with tools/wasm/build-cpython.sh wheels (or :python-multiplatform:fetchWasmWheels)." + ) + } + dir.resolve("cpython-config.mjs").appendText( + testWheelConfig(sitePackages.takeIf { wheels.isNotEmpty() }, file(wasmWheelsDir), requireWasmRuntime) + ) + + // The same two substitutions a consumer's browser bundle needs, and the reason they are a + // shared function: see `patchKotlinWasmOutputForCPython`. + patchKotlinWasmOutputForCPython(dir, modulePrefix = rootProject.name, logger = logger) + } +} + +// ------------------------------------------------------------------------------------------------- +// ROADMAP §10 -- `wasmJsBrowserTest`, and why it runs ten tests rather than 344. +// +// Three of the four defects §10 records were invisible to Node by construction: `cpython.mjs` +// failing to resolve in a webpack context, `node:fs`/NODEFS having no browser equivalent, and the +// generated entry module's shape. All three were established by hand-driving a served distribution +// in Chromium, which is evidence with no expiry date attached -- nothing failed if they came back. +// +// So this task exists to make the browser route fail a build, not to run the suite twice. The +// filter admits `python.multiplatform.browser.*` (WasmBrowserRuntimeTest, which asserts exactly the +// things a browser decides) and `WasmSelectorsImportTest` (the JSPI intrinsic deletion, whose whole +// subject is a *host* decision, and whose regression mode is `abort()` rather than a red test). +// Everything else already runs under Node against the same Kotlin and the same interpreter. +// +// **The browser is a machine fact, so a missing one skips rather than fails**, exactly as a missing +// CPython Emscripten build already does above. `resolveChromiumFamilyBrowser` below makes that one +// decision and hands the answer to karma-chrome-launcher as `CHROME_BIN`. +// +// What the *bundle* needs -- the glue beside it and the stdlib zip where a document-relative fetch +// will look -- is in `karma.config.d/cpython.js`, because both are facts about how karma serves +// files rather than about Gradle. +// ------------------------------------------------------------------------------------------------- + +// The browser package is compiled into the one test bundle *both* runners load, so Node would run it +// too. `WasmBrowserRuntimeTest.theHostIsABrowserAndNotNode` exists precisely to make that loud, and +// it did -- without this line the Node run reports 350 tests, 2 failed. Excluding is the right +// direction: the alternative, teaching those tests to skip under Node, is how a browser-only +// assertion quietly stops being one. +tasks.named("wasmJsNodeTest") { + filter.excludeTestsMatching("python.multiplatform.browser.*") +} + +tasks.named("wasmJsBrowserTest") { + filter.isFailOnNoMatchingTests = true + filter.includeTestsMatching("python.multiplatform.browser.*") + filter.includeTestsMatching("python.multiplatform.ffi.WasmSelectorsImportTest.*") + + // Resolved once, here, rather than probed again inside `karma.config.d/cpython.js`: the launcher + // reads `CHROME_BIN` out of its own process environment, so handing it over is both the skip + // decision and the configuration, from one list. + val chromeBinary = resolveChromiumFamilyBrowser() + if (chromeBinary != null) environment("CHROME_BIN", chromeBinary) + onlyIf { + // The browser is the second thing this task can be missing, and it skips for exactly the + // same reason the interpreter does -- so `-PrequireWasmRuntime=true` has to cover it too, or CI + // would report green having run zero browser tests by the *other* route. + if (chromeBinary == null && requireWasmRuntime) { + throw GradleException( + "$name: no Chromium-family browser found, and -PrequireWasmRuntime was passed. " + + "Set CHROME_BIN to one (karma's ChromeHeadless launcher reads it), or install " + + "Chrome/Chromium." + ) + } + if (chromeBinary == null) { + logger.lifecycle( + "SKIPPING $name -- no Chromium-family browser found. Set CHROME_BIN to one " + + "(karma's ChromeHeadless launcher reads it), or install Chrome/Chromium." + ) + } else { + logger.lifecycle("$name drives $chromeBinary") + } + chromeBinary != null + } +} + +/** + * The browser karma will launch, or `null`. + * + * `CHROME_BIN` first, because that is the knob karma-chrome-launcher itself reads and a caller who + * sets it means it. The probe list after it is ordered by how close each browser is to the thing + * being tested rather than by preference: all of them are Chromium, and what this suite needs from + * one is JSPI (Chromium 137+) and Wasm GC. Whale is in the list because it is what this machine + * has -- it reports `HeadlessChrome/150` -- and leaving it out would have meant the browser tests + * could not run at all here. + * + * Deliberately not Firefox: `WasmBrowserRuntimeTest` asserts that `WebAssembly.promising` was + * deleted, and on an engine that never had it that assertion would pass while proving nothing. + */ +fun resolveChromiumFamilyBrowser(): String? { + System.getenv("CHROME_BIN")?.takeIf { File(it).canExecute() }?.let { return it } + val candidates = listOf( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "${System.getProperty("user.home")}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Applications/Whale.app/Contents/MacOS/Whale", + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ) + return candidates.firstOrNull { File(it).canExecute() } +} + android { namespace = "python.multiplatform" compileSdk = libs.versions.android.compileSdk.get().toInt() sourceSets["main"].assets.srcDirs("src/androidMain/assets", "$androidBuildDir/assets") + // KMP androidMain resources do not reach the AAR's classes.jar; AGP's main resources do. + sourceSets["main"].resources.srcDir(generatePythonProperties) + // AGP's legacy source-set API resolves that provider to files and drops the task dependency + // (issue #72: `processReleaseJavaRes` read the output with no declared dependency), so it is + // declared on every AGP java-resources task explicitly. + tasks.matching { Regex("""process\w*JavaRes""").matches(it.name) } + .configureEach { dependsOn(generatePythonProperties) } + sourceSets["androidTest"].assets.srcDirs("$androidBuildDir/assets") sourceSets["debug"].jniLibs.srcDirs("src/androidMain/jniLibs", "$androidBuildDir/jniLibs", "$androidBuildDir/debug/jniLibs") sourceSets["release"].jniLibs.srcDirs("src/androidMain/jniLibs", @@ -258,6 +2691,16 @@ android { ndk { abiFilters.addAll(listOf("arm64-v8a", "x86_64")) } + // Instrumented tests are the only way to exercise the Android JNI path at all: the JVM + // unit-test JVM cannot load the arm64 .so, and every other verification target only + // compiles rather than runs. + // + // The runner is a subclass rather than the stock one because `commonTest` knows nothing + // about Android: its fixture just calls `Python3.initialize()`, which aborts the *process* + // ("Failed to import encodings module") unless the stdlib has been unpacked out of the + // APK assets and PYTHONHOME points at it first. The subclass does that once, before any + // test class is loaded. + testInstrumentationRunner = "python.multiplatform.PythonInstrumentationRunner" } packaging { resources { @@ -276,6 +2719,19 @@ android { } publishing { + publications { + // ROADMAP §10 -- a separate artifact ID, not a classifier on the wasmJs publication: a + // plugin resolving it needs a Maven coordinate it can name without first knowing what + // classifiers the Kotlin Multiplatform plugin happens to attach to a KMP publication this + // version, and a consumer who never builds a browser bundle should not need to resolve it + // as a side effect of resolving the library at all. + create("wasmRuntime") { + groupId = "io.github.thisisthepy" + artifactId = "python-multiplatform-wasm-runtime" + version = libraryVersion + artifact(wasmBrowserRuntimeZip) + } + } publications.withType().configureEach { //artifact(javadocJar) groupId = groupId @@ -319,6 +2775,43 @@ publishing { fun downloadPythonBuilds() { - // TODO: Automatically download stand-alone Python builds - //val downloadDir = "$projectDir/build/python/standalone/$pythonVersion" + // Moved to task `downloadAllPythonBuilds` +} + +tasks.withType().configureEach { + dependsOn(downloadAllPythonBuilds) +} + +val copyDesktopPythonBinariesForTests by tasks.registering(Copy::class) { + // Same resolved/stripped tree the desktop jar packs (issue #74). + val stage = tasks.named("stageDesktopLibraries") + dependsOn(stage) + from(stage.map { it.destinationDir }) + into(layout.buildDirectory.dir("desktop-test-binaries")) +} + +tasks.named("desktopTest") { + dependsOn(copyDesktopPythonBinariesForTests) + // DesktopJarLibrariesTest opens the produced jar. + dependsOn("desktopJar") + systemProperty("pm.desktopJar", tasks.named("desktopJar").get().archiveFile.get().asFile.absolutePath) + classpath += files(layout.buildDirectory.dir("desktop-test-binaries")) + + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + } + ) + jvmArgs("--enable-preview", "-Djava.library.path=.") + + val platform = if (System.getProperty("os.name").contains("Mac")) { + if (System.getProperty("os.arch") == "aarch64") "macos-aarch64" else "macos-x86_64" + } else if (System.getProperty("os.name").contains("Windows")) { + "windows-x86_64" + } else { + "linux-x86_64" + } + + val pythonHome = File(extractedDir, "$platform$desktopFlavourSuffix/python").absolutePath + environment("PYTHONHOME", pythonHome) } diff --git a/python-multiplatform/karma.config.d/cpython.js b/python-multiplatform/karma.config.d/cpython.js new file mode 100644 index 000000000..fe678d2dd --- /dev/null +++ b/python-multiplatform/karma.config.d/cpython.js @@ -0,0 +1,70 @@ +// Appended verbatim into the generated karma.conf.js (KotlinKarma reads every *.js in this +// directory). ROADMAP §10 -- what a browser needs that Node does not. +// +// `wasmJsNodeTest` reaches CPython off the filesystem. karma serves over HTTP, and the two runtime +// facts that follow from that are the whole of this file: +// +// 1. `cpython.mjs` loads Emscripten's glue with `import(/* webpackIgnore: true */ './python.mjs')`. +// webpackIgnore means the *browser* resolves that specifier -- relative to the bundle, which +// karma-webpack writes to a temp directory whose name changes every run. So the files cannot be +// served from a fixed URL; they have to be copied next to the bundle. `webpackCopy` is exactly +// that hook (see kotlin-web-helpers/dist/karma-webpack-output.js), and it is why this is a +// karma config rather than a Gradle copy step. `python.mjs` then finds `python.wasm` beside +// itself the same way. +// +// 2. `browserSettings()` fetches the standard library as `STDLIB_ZIP_URL`, which is +// `./python3.zip` -- a *document*-relative URL, because in a real distribution the zip +// sits next to index.html. karma's document is `/context.html` at the server root, so the +// proxy below is what makes karma's page look like that distribution. Getting this wrong is a +// 404 during `preRun`, i.e. a failure inside `addRunDependency` rather than a test failure. +// +// Everything is located relative to `config.basePath`, so no path from the build script is +// duplicated here. The staging directory is the npm project's `kotlin/`, which +// `python-multiplatform/build.gradle.kts` fills in the `KotlinJsTest` `doFirst`. + +const path = require('path'); +const fs = require('fs'); + +const staged = path.resolve(config.basePath, 'kotlin'); + +// Located rather than named: the zip carries the interpreter's version, and a literal here would be +// a second place to keep in step with the CPython build (`cpython.mjs` already reads the version out +// of the running interpreter to decide where in MEMFS the zip goes). +const stdlibZip = fs.existsSync(staged) + ? fs.readdirSync(staged).find((name) => /^python\d+\.\d+\.zip$/.test(name)) + : undefined; + +const runtimeFiles = ['python.mjs', 'python.wasm', stdlibZip] + .filter(Boolean) + .map((name) => path.join(staged, name)) + .filter(fs.existsSync); + +if (runtimeFiles.length === 0) { + // Not an error: the Gradle task skips when there is no Emscripten CPython build at all, and a + // karma run without one would fail on the import of './python.mjs' with no explanation. + console.warn( + '[cpython] no staged CPython runtime in ' + staged + '. wasmJsBrowserTest will not be able ' + + 'to load the interpreter; see stageWasmBrowserRuntime / -PwasmPythonDir.' + ); +} + +config.webpackCopy = (config.webpackCopy || []).concat(runtimeFiles); + +if (stdlibZip) { + // karma serves anything in `files` under `/absolute`; the proxy puts it where a + // document-relative fetch from `/context.html` will look for it. + const zipPath = path.join(staged, stdlibZip); + config.files.push({ pattern: zipPath, included: false, served: true, watched: false }); + config.proxies['/' + stdlibZip] = '/absolute' + zipPath; +} + +// karma's default is 10 s of silence before it gives up, and bring-up here is `python.wasm` (9.6 MB) +// plus the stdlib zip (3.7 MB) over HTTP, compiled, and then `Py_Initialize` -- none of which +// reports anything, because the first test does not run until all of it is done. +// +// 60 s rather than something larger, and the number is a measurement: a whole green run of this task +// is 4.5 s wall on a warm build -- karma start, browser launch, HTTP, CPython bring-up and ten tests, +// all of it. This budget is what a *failing* run costs before karma gives up, so an over-generous one +// is paid on every red build. Measured: the `node:fs` break below took 2m07s at 120 s. +config.browserNoActivityTimeout = 60000; +config.captureTimeout = 60000; diff --git a/python-multiplatform/native-image/reachability-metadata.base.json b/python-multiplatform/native-image/reachability-metadata.base.json new file mode 100644 index 000000000..bc068874b --- /dev/null +++ b/python-multiplatform/native-image/reachability-metadata.base.json @@ -0,0 +1,30 @@ +{ + "_comment": [ + "Hand-authored half of the GraalVM reachability metadata shipped at", + "META-INF/native-image/org.thisisthepy/python-multiplatform/reachability-metadata.json.", + "The `foreign` section is NOT here: it is derived from the desktop FFI sources by the", + "`generateDesktopReachabilityMetadata` task (see python-multiplatform/build.gradle.kts) and", + "merged in front of these keys. Only entries that cannot be derived from code belong here.", + "Keys starting with `_` are stripped before the file is written, since native-image validates", + "the document against a schema." + ], + "_comment_resources": [ + "There is deliberately no `resources` entry for `lib//libpython*`. A registered", + "resource is not read from a jar at runtime by a native image -- it is baked into the image", + "heap as a byte[], which cost 19.4 MB of a 35.4 MB binary so that manager.kt could write it", + "straight back out to a temporary file at startup. CPython has to exist on disk anyway,", + "because PYTHONHOME must point at a prefix with a matching stdlib for Py_Initialize to get", + "past `encodings`, so manager.kt loads the library from that same prefix when the classpath", + "copy is absent. Re-adding a glob here would put the 19.4 MB back.", + "Consumers with no PYTHONHOME on disk can set PYTHON_MULTIPLATFORM_LIBPYTHON instead.", + "If a platform-scoped glob is ever added back, write it as `lib/${hostPlatform}/**`: the jar", + "carries all four platforms and an image needs exactly one. The desktop ProcessResources", + "filter substitutes that placeholder." + ], + "reflection": [ + { + "type": "python.native.ffi.UpcallTarget", + "allPublicMethods": true + } + ] +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/PythonInstrumentationRunner.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/PythonInstrumentationRunner.kt new file mode 100644 index 000000000..45d65ab22 --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/PythonInstrumentationRunner.kt @@ -0,0 +1,42 @@ +package python.multiplatform + +import androidx.test.runner.AndroidJUnitRunner +import python.native.ffi.PythonOnDevice + +/** + * Brings up CPython before any test class is loaded. + * + * `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 + * 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. + * + * [PythonOnDevice.ensureInitialised] is idempotent, so the instrumented tests that call it + * themselves are unaffected. + */ +class PythonInstrumentationRunner : AndroidJUnitRunner() { + + /** + * `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 — + * goes through `withGIL`. + * + * What still matters about the thread is only that the staging and the first C API call + * happen off the main thread, before any test class is loaded. + */ + override fun onStart() { + PythonOnDevice.ensureInitialised() + super.onStart() + } +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/assembled/AssembledApiTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/assembled/AssembledApiTest.kt new file mode 100644 index 000000000..468f3cf8b --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/assembled/AssembledApiTest.kt @@ -0,0 +1,120 @@ +package python.multiplatform.assembled + +import python.multiplatform.Versions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import python.multiplatform.ffi.Python3 +import python.native.ffi.PythonOnDevice + +/** + * The assembled layer on Android: `Python3` and the object model, as an application would use + * them. + * + * ### 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 + * 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 + * `String` straight across JNI and a Kotlin/Native `String` is not a `jstring`. + * + * Fourteen green instrumented tests said nothing about that, which is the point: a test that + * does not walk the path callers walk proves only that some other path works. + * + * ### Why it is disabled + * + * 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. + * + * Enable this file when ROADMAP §2 lands (the remaining ~340 functions migrated to the + * primitive-only JNI surface). It is the check for that work: if these pass, the object model + * is usable on Android; until they do, it is not. + */ +@RunWith(AndroidJUnit4::class) +class AssembledApiTest { + + @Test + fun interpreterReportsItsVersion() { + PythonOnDevice.ensureInitialised() + // Against the *configured* version rather than a literal -- same reason as the four + // assertions ROADMAP §9 already converted: this checks the interpreter loaded is the one + // the build configured, instead of pinning a release line that needs hand-editing. + val expected = Versions.currentVersion.compactVersionString + val version = Python3.version + assertTrue("expected $expected.x, got $version", version.startsWith(expected)) + } + + @Test + fun execRunsAStatement() { + PythonOnDevice.ensureInitialised() + Python3.exec("_assembled_probe = 1 + 1") + } + + @Test + fun importReturnsAUsableModule() { + PythonOnDevice.ensureInitialised() + val sys = Python3.import("sys") + try { + val version = sys.getAttr("version") + try { + val expected = Versions.currentVersion.compactVersionString + assertTrue("sys.version was $version", version.toString().startsWith(expected)) + } finally { + version.close() + } + } finally { + sys.close() + } + } + + @Test + fun attributeAccessRoundTrips() { + PythonOnDevice.ensureInitialised() + val sys = Python3.import("sys") + try { + val path = sys.getAttr("path") + try { + assertTrue("sys.path should be non-empty", path.isTruthy) + } finally { + path.close() + } + } finally { + sys.close() + } + } + + @Test + fun callingABuiltinReturnsAValue() { + PythonOnDevice.ensureInitialised() + val builtins = Python3.import("builtins") + try { + val lenFn = builtins.getAttr("len") + val strType = builtins.getAttr("str") + try { + val s = strType() + try { + val n = lenFn(s) + try { + assertEquals("0", n.toString()) + } finally { + n.close() + } + } finally { + s.close() + } + } finally { + strType.close() + lenFn.close() + } + } finally { + builtins.close() + } + } +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/env/PythonBootstrapTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/env/PythonBootstrapTest.kt new file mode 100644 index 000000000..2a5ed93d8 --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/env/PythonBootstrapTest.kt @@ -0,0 +1,310 @@ +package python.multiplatform.env + +import android.content.res.AssetManager +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import python.multiplatform.Versions +import python.multiplatform.ffi.Python3 +import java.io.File +import java.io.FileNotFoundException +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The contract [PythonBootstrap] has to hold up, written before it existed. + * + * Three copies of this bootstrap were hand-written before the helper: `sample`'s `MainActivity`, + * the external consumer app used to verify §15d, and `PythonOnDevice` in this very source set. + * They disagreed on the one thing that matters -- how to tell "already unpacked" from "half + * unpacked" -- so the tests below fix that decision rather than the copying, which all three got + * right. + * + * Everything here stages into a scratch prefix under `cacheDir`, never `filesDir`: the live + * interpreter this instrumentation process is already running reads its stdlib out of `filesDir`, + * and a test that re-unpacked over it would be pulling the floor out from under the other 340-odd + * tests in the same process. + */ +class PythonBootstrapTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val scratches = mutableListOf() + + private fun scratch(name: String): File = + File(context.cacheDir, "pmp-bootstrap-test/$name").also { + it.deleteRecursively() + it.mkdirs() + scratches += it + } + + @AfterTest + fun cleanUp() { + // ~26 MB a piece. Leaving them behind fills the emulator's data partition across runs. + scratches.forEach { it.deleteRecursively() } + } + + /** + * The reason the helper exists at all: what it stages has to be exactly what + * [PythonHomeCheck] -- which `Python3.initialize` now runs unconditionally -- accepts. Two + * components deriving the same layout from the same [Versions] is not proof they agree; this + * runs one against the other. + */ + @Test + fun stagedPrefixIsOneThatPythonHomeCheckAccepts() { + val prefix = scratch("accepted") + val staging = PythonBootstrap.stageStdlib(context, prefix) + + assertTrue(staging.unpacked, "a freshly emptied prefix should have been unpacked into") + assertEquals(prefix, staging.prefix) + assertNull( + PythonHomeCheck.diagnose(prefix.absolutePath), + "PythonHomeCheck rejected the prefix PythonBootstrap just staged -- the two disagree " + + "about the layout, which is exactly the failure the helper is meant to remove", + ) + assertTrue( + File(staging.stdlibDir, "encodings/__init__.py").isFile, + "no encodings package under ${staging.stdlibDir} -- Py_Initialize aborts on this", + ) + assertTrue(staging.fileCount > 500, "only ${staging.fileCount} files staged") + } + + /** + * The second call must not re-unpack. This is the whole point of the stamp: 26 MB of asset + * decompression on every cold start is not something a host app should pay for silently. + * + * The assertion is on the reported flag, not on the timing -- an emulator's disk is not a + * stable enough clock to fail a build on. The timings are logged for the record. + */ + @Test + fun secondStagingIsSkippedRatherThanRepeated() { + val prefix = scratch("idempotent") + + val first = PythonBootstrap.stageStdlib(context, prefix) + assertTrue(first.unpacked, "first call did not unpack") + + val second = PythonBootstrap.stageStdlib(context, prefix) + assertFalse(second.unpacked, "second call unpacked again despite a matching stamp") + assertEquals(0, second.fileCount, "a skipped staging should report no files written") + assertEquals(first.stdlibDir, second.stdlibDir) + + Log.i( + TAG, + "PYBOOTSTRAP_COST api=${android.os.Build.VERSION.SDK_INT} " + + "first=${first.elapsedMillis}ms files=${first.fileCount} bytes=${first.bytes} " + + "subsequent=${second.elapsedMillis}ms", + ) + assertTrue( + second.elapsedMillis <= first.elapsedMillis, + "the skipped path (${second.elapsedMillis}ms) cost more than the unpack " + + "(${first.elapsedMillis}ms)", + ) + } + + /** + * A tree with no stamp is a tree that was never finished. + * + * All three hand-written copies decided "already unpacked" by probing one entry -- + * `target.list()` being non-empty, or `encodings/` being a directory. A process killed partway + * through the copy leaves both of those true and the tree incomplete, and the next start-up + * accepts it. The stamp is written last, so it cannot be true of a partial tree. + */ + @Test + fun aTreeWithoutAStampIsUnpackedAgain() { + val prefix = scratch("unfinished") + val staged = PythonBootstrap.stageStdlib(context, prefix) + assertTrue(staged.unpacked) + + // What a copy interrupted after `encodings/` but before the end looks like to the old + // checks: present, non-empty, no stamp. + assertTrue(PythonBootstrap.stampFile(prefix).delete(), "no stamp was written to delete") + assertTrue(File(staged.stdlibDir, "encodings").isDirectory) + + val redone = PythonBootstrap.stageStdlib(context, prefix) + assertTrue(redone.unpacked, "an unstamped tree was accepted as complete") + } + + /** + * A stamp from a different build must not be honoured. The realistic trigger is not a Python + * version bump -- that changes the directory name too and would be caught anyway -- but an APK + * upgrade that restages the same Python version's assets over an app whose `filesDir` survived + * the install. + */ + @Test + fun aStampFromAnotherBuildIsNotHonoured() { + val prefix = scratch("stale") + assertTrue(PythonBootstrap.stageStdlib(context, prefix).unpacked) + + PythonBootstrap.stampFile(prefix).writeText("python-multiplatform 0.0.0 someotherabi 1 1") + + assertTrue( + PythonBootstrap.stageStdlib(context, prefix).unpacked, + "a stamp naming a different build was accepted", + ) + } + + /** + * [PythonBootstrap] classifies an asset entry as a directory by *failing* to open it, which + * saves one `AssetManager.list()` per file across ~1700 files. That is an undocumented + * behaviour of `AssetManager.open()`, and AssetManager was reimplemented wholesale in API 28 -- + * so this asserts the two classifications agree on whatever API level is running, rather than + * trusting that they do. + */ + @Test + fun openBasedDirectoryDetectionAgreesWithListBased() { + val assets = context.assets + val root = "${PythonBootstrap.assetAbiDirectory(assets)}/${PythonBootstrap.stdlibRelativePath()}" + + val byList = sortedSetOf() + val byOpen = sortedSetOf() + collect(assets, root, byList, byOpen) + + assertTrue(byList.isNotEmpty(), "no assets found under $root") + assertEquals( + byList, byOpen, + "open()-based and list()-based file classification disagree on API " + + "${android.os.Build.VERSION.SDK_INT}", + ) + } + + private fun collect( + assets: AssetManager, + path: String, + byList: MutableSet, + byOpen: MutableSet, + ) { + val entries = assets.list(path) ?: return + for (entry in entries) { + val child = "$path/$entry" + if (assets.list(child).isNullOrEmpty()) byList += child + + val opened = try { + assets.open(child).close(); true + } catch (_: FileNotFoundException) { + false + } + if (opened) byOpen += child else collect(assets, child, byList, byOpen) + } + } + + /** + * [PythonBootstrap] departs from the copy loop all three hand-written versions used in two + * ways -- it classifies directories by a failed `open()` instead of a `list()`, and it reuses + * one 64 KB buffer instead of letting `copyTo` allocate an 8 KB one per file. Both are claims + * about cost, so they are measured here rather than asserted in a comment. + * + * Alternating order, two samples each, because whichever runs first pays for a cold page + * cache. Nothing is asserted about the *margin* -- an emulator's disk is the host's SSD behind + * qemu and is not a stable enough clock for that. What is asserted is that the two produce the + * same tree, which is the part that would be a bug. + */ + @Test + fun optimisedCopyIsMeasuredAgainstTheHandWrittenOne() { + val assets = context.assets + val from = "${PythonBootstrap.assetAbiDirectory(assets)}/${PythonBootstrap.stdlibRelativePath()}" + + val naive = LongArray(2) + val helper = LongArray(2) + var naiveFiles = 0 + var helperFiles = 0 + + for (round in 0..1) { + val n = scratch("naive$round") + naive[round] = measure { naiveFiles = copyTheOldWay(assets, from, File(n, "out")) } + + val h = scratch("helper$round") + helper[round] = measure { helperFiles = PythonBootstrap.stageStdlib(context, h).fileCount } + } + + Log.i( + TAG, + "PYBOOTSTRAP_COPY api=${android.os.Build.VERSION.SDK_INT} files=$helperFiles " + + "handwritten=${naive.joinToString("/")}ms helper=${helper.joinToString("/")}ms", + ) + assertEquals(naiveFiles, helperFiles, "the two copy loops wrote different file counts") + } + + private inline fun measure(block: () -> Unit): Long { + val started = System.nanoTime() + block() + return (System.nanoTime() - started) / 1_000_000 + } + + /** What `sample`, the consumer app and `PythonOnDevice` each wrote, verbatim in shape. */ + private fun copyTheOldWay(assets: AssetManager, from: String, to: File): Int { + var files = 0 + to.mkdirs() + val entries = assets.list(from) ?: return 0 + for (entry in entries) { + val src = "$from/$entry" + val dst = File(to, entry) + if (!assets.list(src).isNullOrEmpty()) { + files += copyTheOldWay(assets, src, dst) + continue + } + assets.open(src).use { input -> + dst.outputStream().use { output -> input.copyTo(output) } + } + files++ + } + return files + } + + /** + * The end-to-end shape a host app writes: one call, and CPython is up. + * + * `PYTHONHOME` is asserted through `System.getenv` rather than `Os.getenv` on purpose. Those + * are the same thing on Android -- libcore's single-argument `System.getenv` delegates to the + * live native environment, unlike the JVM's cached no-arg map -- and it is the one + * [PythonHomeCheck] reads, so this is the assertion that the value the bootstrap sets is the + * value the check will see. + */ + @Test + fun initializeBringsTheInterpreterUpAndAgreesWithTheCheckOnPythonHome() { + PythonBootstrap.initialize(context, silent = true) + + assertTrue(Python3.isInitialized, "PythonBootstrap.initialize left Python down") + assertEquals( + context.filesDir.absolutePath, System.getenv("PYTHONHOME"), + "PYTHONHOME is not the prefix PythonBootstrap staged into", + ) + assertNull(PythonHomeCheck.diagnose(System.getenv("PYTHONHOME")!!)) + + val globals = Python3.import("__main__").dict + assertEquals("6", Python3.eval("sum([1, 2, 3])", 258, globals, globals).toString()) + } + + /** + * `tempfile.mkstemp()` is not optional plumbing -- it is what the standard library itself + * reaches for whenever embedded Python code needs a scratch file. `tempfile.gettempdir()` + * (via `_candidate_tempdir_list()`) tries `TMPDIR`/`TEMP`/`TMP` from `os.environ` first, then + * the hardcoded `/tmp`, `/var/tmp`, `/usr/tmp`, then `os.getcwd()` as a last resort. None of + * the hardcoded paths exist inside an Android app's sandbox, and the instrumentation + * process's cwd is `/`, not app-private storage -- so with nothing set, every candidate is + * refused and `tempfile.mkstemp()` raises `FileNotFoundError: [Errno 2] No usable temporary + * directory found in ['/tmp', '/var/tmp', '/usr/tmp', '/']`. + */ + @Test + fun initializeLeavesTempfileUsable() { + PythonBootstrap.initialize(context, silent = true) + + Python3.exec( + "import tempfile as _pmp_bootstrap_tempfile, os as _pmp_bootstrap_os\n" + + "_pmp_bootstrap_fd, __pmp_bootstrap_tmp_path__ = _pmp_bootstrap_tempfile.mkstemp()\n" + + "_pmp_bootstrap_os.close(_pmp_bootstrap_fd)\n" + ) + val globals = Python3.import("__main__").dict + val path = Python3.eval("__pmp_bootstrap_tmp_path__", 258, globals, globals).toString() + try { + assertTrue(File(path).isFile, "tempfile.mkstemp() reported '$path' but no such file exists") + } finally { + Python3.exec("import os; os.remove(__pmp_bootstrap_tmp_path__)") + } + } + + private companion object { + const val TAG = "PythonBootstrapTest" + } +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/env/PythonPayloadStagingTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/env/PythonPayloadStagingTest.kt new file mode 100644 index 000000000..c062d50fc --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/env/PythonPayloadStagingTest.kt @@ -0,0 +1,94 @@ +package python.multiplatform.env + +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What can be checked about the Android payload path **on this APK**, and an explicit record of + * what cannot. + * + * `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, + * 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. + * + * So what is pinned here is the half that *is* reachable: the no-payload case (which must be + * silence, not failure, because it is the normal state of an app with no Python package), and the + * registry that carries a staged directory from `PythonBootstrap` to `Python3.initialize`. The copy + * loop itself is `copyAssetTree`, shared byte-for-byte with `stageStdlib`, which + * `PythonBootstrapTest` exercises on 804 files on every API level this suite runs on. + */ +class PythonPayloadStagingTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val scratches = mutableListOf() + + private fun scratch(name: String): File = + File(context.cacheDir, "pmp-payload-test/$name").also { + it.deleteRecursively() + it.mkdirs() + scratches += it + } + + @AfterTest + fun cleanUp() { + scratches.forEach { it.deleteRecursively() } + } + + @Test + fun anApkWithNoPayloadStagesNothingAndDoesNotFail() { + assertTrue( + context.assets.list(PythonPayload.PAYLOAD_ROOT).isNullOrEmpty(), + "this library's own APK is not a consumer and should carry no assets/python -- if it " + + "does, this test is measuring the wrong thing", + ) + + val prefix = scratch("absent") + assertNull( + PythonBootstrap.stagePayload(context, prefix), + "no assets/python is the normal state of an app with no Python package; it must be " + + "silence rather than an exception at start-up", + ) + assertFalse( + PythonBootstrap.payloadStampFile(prefix).exists(), + "nothing was staged, so nothing may claim it was", + ) + assertFalse(File(prefix, PythonPayload.PAYLOAD_ROOT).exists()) + } + + @Test + fun aRegisteredRootIsWhatDiscoveryReturns() { + val staged = scratch("registered") + AndroidPayloadRoots.register(staged) + + assertContains( + discoverStagedPayloadRoots(), + staged.absolutePath, + "the registry is the whole of Android's discovery: reading an APK's assets needs a " + + "Context and Python3.initialize has none, so PythonBootstrap stages and registers " + + "while initialize installs", + ) + } + + @Test + fun registrationIsIdempotent() { + val staged = scratch("twice") + AndroidPayloadRoots.register(staged) + AndroidPayloadRoots.register(staged) + + assertEquals( + 1, + discoverStagedPayloadRoots().count { it == staged.absolutePath }, + "an activity recreated, or a host app that initialises defensively, calls this twice", + ) + } +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ffi/upcall/ProxyBootstrap.android.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ffi/upcall/ProxyBootstrap.android.kt new file mode 100644 index 000000000..4ef6fa3ca --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ffi/upcall/ProxyBootstrap.android.kt @@ -0,0 +1,14 @@ +package python.multiplatform.ffi.upcall + +/** + * `androidMain`'s `UpcallEntry.publish` installs both names, as `PyMethodDef`s whose `ml_meth` is a + * C shim in `artMain/cinterop/jni_onload.def` that calls back into `UpcallCallbacks`. + * + * Flipped from `false` only after the tests below were watched to pass on `pmp_api26` and + * `pmp_api36`; the constant is the claim, not the cause. Getting there took two C changes and not + * the one that was recorded here -- see [publishesProxyEntryPoints]'s ART row. + */ +actual val publishesProxyEntryPoints: Boolean = true + +/** §13.4's emulator run drives the `asyncio` fast path on `pmp_api26` and `pmp_api36` alike. */ +actual val proxyBootstrapSupportsAsyncio: Boolean = true diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ref/AndroidCleanerPathTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ref/AndroidCleanerPathTest.kt new file mode 100644 index 000000000..d8bfecf84 --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ref/AndroidCleanerPathTest.kt @@ -0,0 +1,112 @@ +package python.multiplatform.ref + +import android.os.Build +import python.native.ffi.toNativePointer +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AndroidCleanerPathTest { + + @Test + fun testPathSelectionMatchesApiLevel() { + val initialOutstanding = PhantomCleanerRegistry.outstanding + val dummyPointer = 0x12345678L.toNativePointer()!! + val cleaner = registerCleaner(dummyPointer) { } + + val isApi33OrAbove = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + val cleanerClass = cleaner.javaClass + assertEquals("python.multiplatform.ref.AndroidCleaner", cleanerClass.name) + + var cleanableValue: Any? = null + var cleanableReflectError: Throwable? = null + try { + val cleanableField = cleanerClass.getDeclaredField("cleanable").apply { isAccessible = true } + cleanableValue = cleanableField.get(cleaner) + } catch (t: Throwable) { + cleanableReflectError = t + } + + val fallbackField = cleanerClass.getDeclaredField("fallback").apply { isAccessible = true } + val fallbackValue = fallbackField.get(cleaner) + + if (isApi33OrAbove) { + assertNull(cleanableReflectError, "API 33+ must successfully reflect on cleanable field") + assertNotNull(cleanableValue, "API 33+ must populate java.lang.ref.Cleaner.Cleanable") + assertNull(fallbackValue, "API 33+ must NOT populate PhantomCleanerRegistry.Cleanable") + assertEquals( + initialOutstanding, + PhantomCleanerRegistry.outstanding, + "API 33+ must NOT register entries in PhantomCleanerRegistry" + ) + } else { + // On API < 33, java.lang.ref.Cleaner is missing from Android SDK/runtime. + // Reflecting on field type java.lang.ref.Cleaner$Cleanable throws NoClassDefFoundError or cleanableValue is null. + if (cleanableReflectError != null) { + assertTrue( + cleanableReflectError is NoClassDefFoundError || cleanableReflectError is ClassNotFoundException, + "API < 33 reflection error must be NoClassDefFoundError/ClassNotFoundException due to missing Cleaner class: $cleanableReflectError" + ) + } else { + assertNull(cleanableValue, "API < 33 must NOT populate java.lang.ref.Cleaner.Cleanable") + } + assertNotNull(fallbackValue, "API < 33 must populate PhantomCleanerRegistry.Cleanable") + assertEquals( + initialOutstanding + 1, + PhantomCleanerRegistry.outstanding, + "API < 33 must register entries in PhantomCleanerRegistry" + ) + } + + cleaner.close() + } + + @Test + fun testDrainMechanismReclaimsObjectOnGC() { + val isApi33OrAbove = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + val actionExecuted = AtomicBoolean(false) + val initialDrained = PhantomCleanerRegistry.drained + + val weakCleaner = allocateCleanerAndDrop(actionExecuted) + + var attempts = 0 + while (!actionExecuted.get() && attempts < 20) { + forceGC() + attempts++ + } + + assertTrue( + actionExecuted.get(), + "Cleaner action must execute upon GC (weakCleaner collected: ${weakCleaner.get() == null}, attempts: $attempts)" + ) + + if (isApi33OrAbove) { + assertEquals( + initialDrained, + PhantomCleanerRegistry.drained, + "API 33+ uses java.lang.ref.Cleaner, so PhantomCleanerRegistry.drained must NOT increment" + ) + } else { + assertTrue( + PhantomCleanerRegistry.draining, + "API < 33 must have active PhantomCleanerRegistry drain thread running" + ) + assertTrue( + PhantomCleanerRegistry.drained > initialDrained, + "API < 33 must increment PhantomCleanerRegistry.drained when garbage collected" + ) + } + } + + private fun allocateCleanerAndDrop(actionExecuted: AtomicBoolean): java.lang.ref.WeakReference { + val dummyPointer = 0x9999L.toNativePointer()!! + val cleaner = registerCleaner(dummyPointer) { + actionExecuted.set(true) + } + return java.lang.ref.WeakReference(cleaner) + } +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ref/CycleCollectionTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ref/CycleCollectionTest.kt new file mode 100644 index 000000000..02b17a19c --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ref/CycleCollectionTest.kt @@ -0,0 +1,710 @@ +package python.multiplatform.ref + +import python.multiplatform.ffi.ProxyTypeFactory +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.withGIL +import python.multiplatform.reflection.ClassLookup +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.ReflectedClass +import python.native.ffi.PythonOnDevice +import python.native.ffi.bindings +import python.native.ffi.toNativePointer +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A Kotlin object that holds a Python object. Both the wrapper (for ownership) and the raw + * pointer (for `tp_traverse`, which has to hand CPython the bare pointer) are kept. + */ +class AndroidCycleNode { + var ref: PyObject? = null + var rawPtr: Long = 0L +} + +/** + * Same shape as [AndroidCycleNode], but deliberately never registered with [ClassLookup]. The + * negative control for [CycleCollectionTest.testCycleCollectionByGC]. + */ +class UnregisteredAndroidCycleNode { + var ref: PyObject? = null + var rawPtr: Long = 0L +} + +/** + * Which thread the last `tp_traverse` upcall arrived on. + * + * Test-owned, and that is the point: the lambda [CycleCollectionTest.setUp] registers with + * [ClassLookup] runs *inside* `ProxyCallbacks.traverse`, which itself runs on whichever thread C + * called the slot from. Recording the thread there therefore needs no hook in production code. + * + * Without it, "the handle was released after a `threading.Thread` ran `gc.collect()`" would only + * show that the release happened, not that it happened on a thread ART had never seen -- which is + * the whole claim `pmp_attach`'s `AttachCurrentThreadAsDaemon` branch exists to support. + */ +object TraverseThreadProbe { + @Volatile + var lastThreadId: Long = -1L + + @Volatile + var lastThreadName: String? = null + + fun record() { + val t = Thread.currentThread() + lastThreadId = t.id + lastThreadName = t.name + } + + fun reset() { + lastThreadId = -1L + lastThreadName = null + } +} + +/** + * Android's counterpart of `desktopTest`'s and `iosSimulatorArm64Test`'s `CycleCollectionTest`: + * a Kotlin object holds a Python object, the Python object holds that Kotlin object through its + * handle, and the loop is breakable only by CPython's cycle collector reaching across the + * boundary through the proxy type's `tp_traverse`/`tp_clear`. + * + * Android reaches those slots differently from either of the other two. There is no Panama and + * no `staticCFunction` here, so the slots are C functions in `artMain/cinterop/jni_onload.def` + * that call back into `python.multiplatform.ffi.ProxyCallbacks` over JNI. What this test + * establishes, beyond the collection itself, is that a JNI upcall issued from inside a running + * CPython collection works at all. + * + * Instrumented, not a unit test: `androidUnitTest` runs on a host JVM that cannot load the + * arm64/x86_64 `.so`, so the interpreter -- and with it `JNI_OnLoad`, and with it the + * registration of `proxyCreateType` -- is unreachable there. + */ +class CycleCollectionTest { + + @BeforeTest + fun setUp() { + PythonOnDevice.ensureInitialised() + + // Registered unconditionally, and deliberately not behind an "is the interpreter already + // up?" check. Nesting registration inside such a check makes it run only when this test + // is the one that starts the interpreter -- true in isolation, false in a full suite run, + // and on Android the runner has already initialised it before any test class loads, so + // it would be false always. Without the registration ClassLookup.find returns null, + // tp_traverse reports nothing, and the cycle is invisible to CPython's collector. The + // failure then looks exactly like "the object was never GC-tracked", which is a + // different bug. Both desktop and iOS hit this. + ClassLookup.register( + ReflectedClass( + name = AndroidCycleNode::class.qualifiedName!!, + memberNames = emptyList(), + traverse = { obj, visit -> + TraverseThreadProbe.record() + val node = obj as AndroidCycleNode + if (node.rawPtr != 0L) visit(node.rawPtr) + }, + ) + ) + } + + @AfterTest + fun tearDown() { + HandleTable.releaseAll() + } + + /** + * Drops the last reference to a proxy *now*, instead of leaving it to ART's collector. + * + * Every test here that closes a cycle ends with the proxy held by an [AndroidCycleNode] and by + * nothing else. Once [tearDown] drops the handle table's root, that node is JVM garbage, and + * the `Py_DecRef` its [PyObject] owes is scheduled by ART's collector and run on a cleaner -- + * `java.lang.ref.Cleaner`'s thread on API 33+, the `PyAutoCloseable-Cleaner` daemon draining a + * `ReferenceQueue` below it (see `PyAutoCloseable.android.kt`). Either way that release goes + * through `withGIL`, so it can only land once the cleaner thread is given the GIL. + * + * That is a live grenade for any later test that reads a reference count across a window in + * which it releases the GIL, and [testDeallocOnAThreadCPythonCreated] is exactly such a test. + * Observed on this platform once, as `expected:<5> but was:<2>` -- three references off the + * proxy type between its two readings, and three is exactly how many tests here leave a proxy + * behind ([testCycleCollectionByGC], [testHandleSurvivesWhenTraverseReportsNothing] and + * [testCycleCollectedOnAThreadCPythonCreated]). + * + * The failure itself is rare -- it needs an ART collection to fire *inside* + * [testDeallocOnAThreadCPythonCreated]'s `withGIL`, after its first reading, so that the + * cleaner is still blocked when the worker thread hands the GIL over. Twenty full-suite runs + * (ten per emulator, API 26 and API 36) produced none. The debt behind it is not rare at all + * and is what this fixes: with these calls removed, [settleJvmFinalisation] reports 3 owed on + * both API levels, every run. + * + * Calling this is not cleanup for tidiness. It is what makes the deallocation happen at a point + * the test controls, under the GIL it is already holding. Ported from `nativeTest`'s copy, + * which got it thirteen hours earlier (`643eaed9`) while this file was left behind. + */ + private fun disposeProxy(node: AndroidCycleNode) { + node.ref?.close() + node.ref = null + node.rawPtr = 0L + } + + /** + * Blocks until an ART collection stops changing the reference count at [typeAddr]. + * + * The backstop for [disposeProxy]: it makes this test's opening reading independent of whatever + * any earlier test happened to leave owing, rather than merely correct while they all remember + * to tidy up. + * + * Two platform details, both already paid for elsewhere in this source set: + * + * - **`Runtime.getRuntime().gc()`, not `System.gc()`.** On Android `System.gc()` only records + * a request and defers it, so a loop of bare `System.gc()` calls runs no collection at all. + * `runFinalization()` follows it so the reference queues are drained. `forceGC()` in + * `GCLeakTest.androidInstrumented.kt` carries the measurement behind that claim. + * - **Not called while this thread holds the GIL.** The cleaner is blocked in + * `PyGILState_Ensure`; settling means letting it through, which cannot happen while this + * thread is inside `withGIL`. (`kotlin.native.runtime.GC.collect()` deadlocks outright in + * that position on Kotlin/Native; on ART it would merely never settle, which is worse to + * diagnose.) + * + * [bindings.obRefCnt] is a plain 8-byte load, so reading it without the GIL is the same racy + * poll the `nativeTest` copy does with a `LongVar` load -- which is all this needs, since it is + * watching for the value to stop moving rather than trusting any single reading. + * + * @return how far the count moved while settling -- 0 once every test disposes of its own + * proxy, and the number of proxies that were still owed if one stops doing so. + */ + private fun settleJvmFinalisation(typeAddr: Long): Long { + if (typeAddr == 0L) return 0L + val started = bindings.obRefCnt(typeAddr) + var last = started + var quiet = 0 + var turns = 0 + while (quiet < 4 && turns < 60) { + Runtime.getRuntime().gc() + System.runFinalization() + Thread.sleep(30) + val now = bindings.obRefCnt(typeAddr) + if (now == last) quiet++ else { quiet = 0; last = now } + turns++ + } + return started - last + } + + @Test + fun testCycleCollectionByGC() { + withGIL { + val proxyTypeAddr = ProxyTypeFactory.createProxyType() + assertTrue( + proxyTypeAddr != 0L, + "Proxy type creation failed -- PyType_FromSpec returned NULL, or proxyCreateType " + + "was never registered by JNI_OnLoad", + ) + + // 1. An instance of the proxy type. PyType_FromSpec gave it Py_TPFLAGS_HAVE_GC, so + // object.__new__ allocates it through the GC and tracks it. + val pyObjPtr = bindings.PyObject_CallObjectN(proxyTypeAddr, 0L) + assertTrue(pyObjPtr != 0L, "Failed to instantiate proxy type") + + // 2. A Kotlin object, rooted in the handle table -- a root CPython cannot see, and + // therefore the half of the cycle only tp_clear can drop. + val node = AndroidCycleNode() + val handle = HandleTable.register(node).raw + + // 3. Store the handle in the proxy's relative type data (negative basicsize, reached + // through PyObject_GetTypeData on the C side). + ProxyTypeFactory.setHandle(pyObjPtr, handle) + assertEquals( + handle, ProxyTypeFactory.handleOf(pyObjPtr), + "the handle did not survive the round trip through relative type data", + ) + + // 4. Close the loop: the Kotlin object now holds the Python object that holds it. + node.ref = PyObject(assertNotNull(pyObjPtr.toNativePointer()), borrowed = false) + node.rawPtr = pyObjPtr + + assertNotNull(HandleTable.resolveRaw(handle), "handle did not resolve before collection") + + // 5. Collect. + // + // The return value matters. PyRun_SimpleString fails immediately if the error + // indicator is already set, so an exception left behind by an earlier test would make + // gc.collect() silently not run -- which looks exactly like "the object was not + // tracked", since tp_traverse only runs during a collection. + val gcRc = PythonOnDevice.withUtf8("import gc; gc.collect()") { + bindings.PyRun_SimpleStringN(it) + } + assertEquals( + 0, gcRc, + "gc.collect() did not run -- the error indicator was already set on entry " + + "(errorIndicatorSet=${bindings.PyErr_Occurred() != 0L})", + ) + + // 6. tp_clear ran if and only if the collector saw the cycle, and tp_clear is the + // only thing that releases this handle. + assertNull( + HandleTable.resolveRaw(handle), + "the cycle was not collected: the handle is still live after gc.collect()", + ) + + // tp_clear broke the loop but did not free the proxy -- `node` still holds the last + // reference. See disposeProxy for why that reference is given back here rather than + // left to ART's collector. + disposeProxy(node) + } + } + + /** + * The negative control for [testCycleCollectionByGC]. + * + * Built identically, except the Kotlin class is not in [ClassLookup], so `tp_traverse` + * reports nothing. The collector then sees a proxy whose one reference is external, decides + * it is reachable, and never calls `tp_clear` -- so the handle stays live. + * + * Without this, [testCycleCollectionByGC] could pass for the wrong reason: `resolveRaw` + * returning null proves the handle was released, not that *the collector* released it. + */ + @Test + fun testHandleSurvivesWhenTraverseReportsNothing() { + withGIL { + val proxyTypeAddr = ProxyTypeFactory.createProxyType() + assertTrue(proxyTypeAddr != 0L, "Proxy type creation failed") + + val pyObjPtr = bindings.PyObject_CallObjectN(proxyTypeAddr, 0L) + assertTrue(pyObjPtr != 0L, "Failed to instantiate proxy type") + + val node = UnregisteredAndroidCycleNode() + assertNull( + ClassLookup.find(UnregisteredAndroidCycleNode::class.qualifiedName!!), + "this control only means anything while the class stays unregistered", + ) + val handle = HandleTable.register(node).raw + + ProxyTypeFactory.setHandle(pyObjPtr, handle) + + node.ref = PyObject(assertNotNull(pyObjPtr.toNativePointer()), borrowed = false) + node.rawPtr = pyObjPtr + + val gcRc = PythonOnDevice.withUtf8("import gc; gc.collect()") { + bindings.PyRun_SimpleStringN(it) + } + assertEquals(0, gcRc, "gc.collect() did not run") + + assertNotNull( + HandleTable.resolveRaw(handle), + "the handle was released even though tp_traverse reported no reference -- " + + "something other than the cycle collector is clearing handles", + ) + // Keeps `node` (and with it the Python object) alive across the collection, so the + // assertion above is about the collector's decision and not about ART's GC. + assertEquals(pyObjPtr, node.rawPtr) + + // The proxy survived on purpose, so this test is the one that owes its release. Not + // [disposeProxy], which takes an AndroidCycleNode -- this node deliberately is not one. + node.ref?.close() + node.ref = null + } + } + + /** + * The JNI upcall is the part of this platform's design that has no counterpart on desktop or + * Kotlin/Native, so it is worth pinning separately from the collection itself. + * + * [ProxyCallbacks.traverse] is what `tp_traverse` calls; calling it directly proves the + * Kotlin side reports the right pointers, independently of whether the collector ever gets + * there. A failure here and a pass in [testCycleCollectionByGC] would be contradictory; a + * pass here and a failure there localises the problem to the C side or to GC tracking. + */ + @Test + fun traverseReportsTheHeldPointerForARegisteredClass() { + val node = AndroidCycleNode() + node.rawPtr = 0x1234_5678L + val handle = HandleTable.register(node).raw + + assertTrue( + python.multiplatform.ffi.ProxyCallbacks.traverse(handle).contentEquals(longArrayOf(0x1234_5678L)), + "ProxyCallbacks.traverse did not report the pointer the registered traverse visits", + ) + + val unregistered = UnregisteredAndroidCycleNode() + val unregisteredHandle = HandleTable.register(unregistered).raw + assertEquals( + 0, + python.multiplatform.ffi.ProxyCallbacks.traverse(unregisteredHandle).size, + "an unregistered class must report nothing", + ) + + assertEquals( + 0, python.multiplatform.ffi.ProxyCallbacks.traverse(0L).size, + "the null handle must report nothing", + ) + } + + /** + * The leak `tp_clear` cannot reach: a proxy that dies **without** being in a cycle. + * + * Ported from `desktopTest`, and it is the case that matters most in practice. `tp_clear` only + * runs when the cyclic collector decides to break a loop, which is the exceptional path. The + * ordinary one is a refcount reaching zero, and that goes straight to `tp_dealloc` -- so with + * no `tp_dealloc` slot the [HandleTable] entry stays rooted for the life of the interpreter, + * holding its Kotlin object with it. Almost every proxy dies this way. + * + * The [AndroidCycleNode]s here deliberately reference nothing: `rawPtr` stays 0 and `ref` + * stays null, so there is no cycle for the collector to find and `gc.collect()` is never + * called. The only thing that can release these handles is `tp_dealloc`. + * + * ### The type refcount assertions + * + * `tp_dealloc` on a heap type has a second obligation beyond freeing the object: since 3.8 + * every instance holds a strong reference to its type, and the default `subtype_dealloc` + * releases it. `pmp_proxy_dealloc` in `jni_onload.def` hand-writes that (`PyObject_Type` for + * the temporary, then two `Py_DecRef`s), so it can leak one type reference per instance by + * forgetting, or free the type out from under the process by doing it twice. Both are checked + * by reading `ob_refcnt` through [bindings.obRefCnt] -- the first field of `PyObject`, fixed + * there by the stable ABI. `sys.getrefcount` cannot answer this: it counts its own argument's + * temporary reference. + * + * The "rises while alive" assertion is not decoration: it is what proves the probe reads a + * real refcount, so the "returns afterwards" assertion cannot pass vacuously on an address + * that happens to hold a stable number. + */ + @Test + fun testHandleReleasedWhenProxyDiesWithoutCycle() { + withGIL { + val proxyTypeAddr = ProxyTypeFactory.createProxyType() + assertTrue(proxyTypeAddr != 0L, "Proxy type creation failed") + + val liveBefore = HandleTable.liveCount + val typeRefBefore = bindings.obRefCnt(proxyTypeAddr) + + val rounds = 100 + val proxies = LongArray(rounds) + val handles = LongArray(rounds) + + repeat(rounds) { i -> + val pyObjPtr = bindings.PyObject_CallObjectN(proxyTypeAddr, 0L) + assertTrue(pyObjPtr != 0L, "Failed to instantiate proxy type at round $i") + proxies[i] = pyObjPtr + + // rawPtr stays 0 and ref stays null: nothing to make a cycle out of. + val handle = HandleTable.register(AndroidCycleNode()).raw + handles[i] = handle + ProxyTypeFactory.setHandle(pyObjPtr, handle) + } + + assertEquals( + liveBefore + rounds, HandleTable.liveCount, + "setup failed: $rounds handles should be rooted while the proxies are alive", + ) + val typeRefAlive = bindings.obRefCnt(proxyTypeAddr) + assertEquals( + typeRefBefore + rounds, typeRefAlive, + "each live instance of a heap type holds one reference to that type, so the count " + + "should have risen by exactly $rounds (before: $typeRefBefore, alive: " + + "$typeRefAlive). If this fails the ob_refcnt probe is not reading a refcount " + + "and the balance assertion after it would be meaningless", + ) + + // Drop the only reference to each proxy. This takes the refcount to zero, which is + // tp_dealloc's path and *not* tp_clear's -- the cyclic collector never runs here. + repeat(rounds) { i -> bindings.Py_DecRefN(proxies[i]) } + + val stillRooted = handles.count { HandleTable.resolveRaw(it) != null } + assertEquals( + 0, stillRooted, + "$stillRooted of $rounds handles are still rooted after their proxies were " + + "deallocated. A proxy that dies without a cycle never runs tp_clear, so " + + "without a tp_dealloc slot its HandleTable entry -- and the Kotlin object it " + + "holds -- leaks for the life of the interpreter", + ) + assertEquals( + liveBefore, HandleTable.liveCount, + "the table should be back to its starting size once every proxy is gone", + ) + assertEquals( + typeRefBefore, bindings.obRefCnt(proxyTypeAddr), + "tp_dealloc must release the instance's reference to its heap type exactly once: " + + "a count above $typeRefBefore means it was never released and the type leaks " + + "per instance, below means it was released twice and the type will be freed " + + "while still in use", + ) + } + } + + /** + * `tp_dealloc` reached from a thread CPython created, which ART has never seen. + * + * Every other test here runs the slots on the instrumentation thread, so `pmp_attach`'s + * `GetEnv` succeeds and the `AttachCurrentThreadAsDaemon` branch beside it is never taken. + * `ProxyTypeFactory`'s own documentation records that gap. A `threading.Thread` is a bare + * pthread, so handing the proxy's last reference to one and letting that thread drop it runs + * `pmp_proxy_dealloc` -> `pmp_release_handle` -> `pmp_attach` on a thread with no JNIEnv. + * + * Two things can go wrong and this test tells them apart from a pass: + * - the attach fails, `pmp_release_handle` returns early, and the handle stays rooted; + * - the attach succeeds but the detach is missed, in which case ART aborts the process with + * "Native thread exiting without having called DetachCurrentThread" when the worker exits + * -- which shows up as the whole instrumentation run dying, not as this assertion failing. + * + * The proxy is parked in `__main__` so that Python owns the only reference and Kotlin keeps + * none. Rebinding the name on the worker takes the refcount to zero there. + */ + @Test + fun testDeallocOnAThreadCPythonCreated() { + // Sampled below: the proxy type's reference count, before and after a window in which this + // thread *gives up the GIL* -- `_t.start()` and `_t.join()` both release it, which is the + // whole point of the test. That makes this the one test here whose measurement a pending + // JVM finalisation can walk into: the cleaner thread wants the GIL, this is the only place + // it is offered, and a leftover proxy deallocated there takes one reference off the type + // between the two readings. The failure reads as "tp_dealloc did not balance the + // instance's reference to its heap type" while tp_dealloc has balanced it exactly. + // + // So nothing may still be owed when the first reading is taken. Called outside withGIL -- + // see settleJvmFinalisation for why it cannot settle anything from inside one. + // + // The assertion on the result is the guard on disposeProxy: measured at 3 on API 26 and on + // API 36 with the disposals removed, and 3 is precisely how many tests above leave a proxy + // behind. Unlike the failure it prevents, that reading is deterministic -- which is the + // point of having it, since the failure is not. createProxyType() is idempotent and takes + // the GIL itself, so calling it here costs nothing and holds nothing. + val stillOwed = settleJvmFinalisation(ProxyTypeFactory.createProxyType()) + assertEquals( + 0L, stillOwed, + "$stillOwed proxy deallocation(s) were still owed to ART's collector when this test " + + "began, which means some test above stopped releasing its own proxy. Left alone " + + "they land during the GIL-release window below and this test fails claiming " + + "tp_dealloc did not balance the heap type -- see disposeProxy", + ) + + withGIL { + val proxyTypeAddr = ProxyTypeFactory.createProxyType() + assertTrue(proxyTypeAddr != 0L, "Proxy type creation failed") + + val liveBefore = HandleTable.liveCount + val typeRefBefore = bindings.obRefCnt(proxyTypeAddr) + + val pyObjPtr = bindings.PyObject_CallObjectN(proxyTypeAddr, 0L) + assertTrue(pyObjPtr != 0L, "Failed to instantiate proxy type") + + val handle = HandleTable.register(AndroidCycleNode()).raw + ProxyTypeFactory.setHandle(pyObjPtr, handle) + + // Hand the reference to __main__, then drop ours: Python now owns the only one. + val main = PythonOnDevice.withUtf8("__main__") { bindings.PyImport_ImportModuleN(it) } + assertTrue(main != 0L, "could not import __main__") + val globals = PythonOnDevice.withUtf8("__dict__") { + bindings.PyObject_GetAttrStringN(main, it) + } + assertTrue(globals != 0L, "could not read __main__.__dict__") + val setRc = PythonOnDevice.withUtf8("_pmp_thread_proxy") { + bindings.PyDict_SetItemStringN(globals, it, pyObjPtr) + } + assertEquals(0, setRc, "could not park the proxy in __main__") + bindings.Py_DecRefN(pyObjPtr) + bindings.Py_DecRefN(globals) + bindings.Py_DecRefN(main) + + assertNotNull(HandleTable.resolveRaw(handle), "handle did not resolve before the drop") + + // The gc.collect() in the worker is not what drops the proxy -- __main__ still holds + // it at that point, so it is reachable. It is there to make the worker thread + // *identify itself*: collecting calls tp_traverse on every tracked object in the + // generation, this proxy included, and the traverse lambda records its thread. Without + // it the release below would be observable but the thread it happened on would not. + // + // t.join() releases the GIL, so the worker really does run both statements. + TraverseThreadProbe.reset() + val here = Thread.currentThread().id + val script = """ + import gc, threading + def _pmp_drop(): + global _pmp_thread_proxy + gc.collect() + _pmp_thread_proxy = None + _t = threading.Thread(target=_pmp_drop) + _t.start() + _t.join() + """.trimIndent() + val rc = PythonOnDevice.withUtf8(script) { bindings.PyRun_SimpleStringN(it) } + assertEquals(0, rc, "the worker thread script did not run") + + val seen = TraverseThreadProbe.lastThreadId + assertTrue( + seen != -1L, + "no slot callback reached Kotlin at all while the worker ran, so nothing here " + + "says which thread the dealloc below happened on", + ) + assertTrue( + seen != here, + "the slots ran on the instrumentation thread (id $here, " + + "${TraverseThreadProbe.lastThreadName}), so CPython's worker did not carry " + + "the callback and AttachCurrentThreadAsDaemon was still not exercised", + ) + + assertNull( + HandleTable.resolveRaw(handle), + "the proxy's last reference was dropped on a thread CPython created, and the " + + "handle is still rooted -- pmp_attach could not give tp_dealloc's callback a " + + "JNIEnv on a thread ART had never seen", + ) + assertEquals(liveBefore, HandleTable.liveCount, "the table did not return to its starting size") + assertEquals( + typeRefBefore, bindings.obRefCnt(proxyTypeAddr), + "tp_dealloc ran on a CPython-created thread but did not balance the instance's " + + "reference to its heap type", + ) + } + } + + /** + * The collector itself running on a thread CPython created -- `tp_traverse` and `tp_clear`, + * where [testDeallocOnAThreadCPythonCreated] covers `tp_dealloc`. + * + * `tp_traverse` is the harder of the two over JNI: it calls back into Kotlin for a `jlong[]` + * and reads it with `GetLongArrayElements`, all on a thread that may have been attached a + * microsecond earlier. + */ + @Test + fun testCycleCollectedOnAThreadCPythonCreated() { + withGIL { + val proxyTypeAddr = ProxyTypeFactory.createProxyType() + assertTrue(proxyTypeAddr != 0L, "Proxy type creation failed") + + val pyObjPtr = bindings.PyObject_CallObjectN(proxyTypeAddr, 0L) + assertTrue(pyObjPtr != 0L, "Failed to instantiate proxy type") + + val node = AndroidCycleNode() + val handle = HandleTable.register(node).raw + ProxyTypeFactory.setHandle(pyObjPtr, handle) + + node.ref = PyObject(assertNotNull(pyObjPtr.toNativePointer()), borrowed = false) + node.rawPtr = pyObjPtr + + assertNotNull(HandleTable.resolveRaw(handle), "handle did not resolve before collection") + + TraverseThreadProbe.reset() + val here = Thread.currentThread().id + val script = """ + import gc, threading + _t = threading.Thread(target=gc.collect) + _t.start() + _t.join() + """.trimIndent() + val rc = PythonOnDevice.withUtf8(script) { bindings.PyRun_SimpleStringN(it) } + assertEquals(0, rc, "the worker thread script did not run") + + val seen = TraverseThreadProbe.lastThreadId + assertTrue( + seen != -1L, + "tp_traverse never reached Kotlin during the worker's collection, so the cycle " + + "was invisible to it -- pmp_attach could not produce a JNIEnv there", + ) + assertTrue( + seen != here, + "tp_traverse ran on the instrumentation thread (id $here, " + + "${TraverseThreadProbe.lastThreadName}) rather than on CPython's worker, so " + + "AttachCurrentThreadAsDaemon was still not exercised", + ) + + assertNull( + HandleTable.resolveRaw(handle), + "the cycle was not collected when gc.collect() ran on a CPython-created thread, " + + "though it is on the instrumentation thread -- tp_traverse or tp_clear did " + + "not reach Kotlin through pmp_attach's AttachCurrentThreadAsDaemon branch", + ) + + // As in testCycleCollectionByGC: tp_clear broke the loop, `node` still holds the + // proxy's last reference, and this test gives it back rather than owing it. + disposeProxy(node) + } + } + + /** + * Verifies that ProxyTypeFactory.installGcBase() publishes `_pm_proxy_base` into `__main__`. + */ + @Test + fun testInstallGcBase() { + assertTrue(ProxyTypeFactory.installGcBase(), "ProxyTypeFactory.installGcBase() returned false") + withGIL { + val main = PythonOnDevice.withUtf8("__main__") { bindings.PyImport_AddModuleN(it) } + assertTrue(main != 0L, "could not get __main__") + val baseType = PythonOnDevice.withUtf8("_pm_proxy_base") { + bindings.PyObject_GetAttrStringN(main, it) + } + assertTrue(baseType != 0L, "_pm_proxy_base was not found in __main__") + assertEquals( + ProxyTypeFactory.createProxyType(), baseType, + "_pm_proxy_base in __main__ does not match ProxyTypeFactory.createProxyType()", + ) + bindings.Py_DecRefN(baseType) + } + } + + /** + * Verifies that a Python subclass of `_pm_proxy_base` sets `self._pm_handle` directly in Python + * via PyMemberDef and that CPython GC collects cycles formed through it. + */ + @Test + fun testPythonSubclassSetsHandleAndCollects() { + assertTrue(ProxyTypeFactory.installGcBase(), "ProxyTypeFactory.installGcBase() returned false") + withGIL { + val main = PythonOnDevice.withUtf8("__main__") { bindings.PyImport_AddModuleN(it) } + assertTrue(main != 0L, "could not get __main__") + val globals = PythonOnDevice.withUtf8("__dict__") { + bindings.PyObject_GetAttrStringN(main, it) + } + assertTrue(globals != 0L, "could not get __main__.__dict__") + + + val script = """ + import gc + class MySubclassProxy(_pm_proxy_base): + pass + _test_subclass_inst = MySubclassProxy() + """.trimIndent() + val rc = PythonOnDevice.withUtf8(script) { bindings.PyRun_SimpleStringN(it) } + assertEquals(0, rc, "Python script creating subclass instance failed") + + val pyObjPtr = PythonOnDevice.withUtf8("_test_subclass_inst") { + bindings.PyDict_GetItemStringN(globals, it) + } + assertTrue(pyObjPtr != 0L, "could not retrieve _test_subclass_inst") + + val node = AndroidCycleNode() + val handle = HandleTable.register(node).raw + + // Assign self._pm_handle = handle in Python + val setHandleScript = "_test_subclass_inst._pm_handle = $handle" + val setRc = PythonOnDevice.withUtf8(setHandleScript) { bindings.PyRun_SimpleStringN(it) } + assertEquals(0, setRc, "setting _pm_handle on subclass instance failed") + + assertEquals( + handle, ProxyTypeFactory.handleOf(pyObjPtr), + "the handle assigned via self._pm_handle in Python did not reach relative type data", + ) + + // Connect cycle: node holds PyObject (which decrefs pyObjPtr when node is disposed or collected), + // and Python object holds handle (which resolves to node). + node.ref = PyObject(assertNotNull(pyObjPtr.toNativePointer()), borrowed = false) + node.rawPtr = pyObjPtr + + // Remove global reference from __main__ so the cycle has no external references + PythonOnDevice.withUtf8("_test_subclass_inst") { + bindings.PyDict_DelItemStringN(globals, it) + } + + val gcRc = PythonOnDevice.withUtf8("import gc; gc.collect()") { + bindings.PyRun_SimpleStringN(it) + } + assertEquals(0, gcRc, "gc.collect() failed") + + assertNull( + HandleTable.resolveRaw(handle), + "the Python subclass cycle was not collected by GC", + ) + + bindings.Py_DecRefN(globals) + disposeProxy(node) + } + } +} + diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ref/GCLeakTest.androidInstrumented.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ref/GCLeakTest.androidInstrumented.kt new file mode 100644 index 000000000..898985f18 --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/multiplatform/ref/GCLeakTest.androidInstrumented.kt @@ -0,0 +1,95 @@ +package python.multiplatform.ref + +/** + * One-shot probe, evaluated on the first [forceGC] call, that records what it actually takes to + * get ART to collect. It exists because the answer is not the one every other JVM gives, and the + * suite spent a full run reading the consequence -- "no cleaner ran" -- as a cleaner-thread + * problem when the collector had simply never run. + * + * `bare` counts how many `System.gc()` calls it takes to clear a weak canary; `escalated` counts + * the same for `Runtime.getRuntime().gc()` + `System.runFinalization()`. Both are logged, so the + * claim in [forceGC]'s comment is a measurement in logcat rather than an assertion in a comment. + */ +private val gcProbe: Unit = run { + var bare = 0 + val c1 = java.lang.ref.WeakReference(Any()) + while (c1.get() != null && bare < 20) { + System.gc() + Thread.sleep(10) + bare++ + } + val bareCleared = c1.get() == null + + var escalated = 0 + val c2 = java.lang.ref.WeakReference(Any()) + while (c2.get() != null && escalated < 20) { + Runtime.getRuntime().gc() + System.runFinalization() + Thread.sleep(10) + escalated++ + } + val escalatedCleared = c2.get() == null + + android.util.Log.i( + "GCProbe", + "bare System.gc(): cleared=$bareCleared after $bare attempts; " + + "Runtime.gc()+runFinalization(): cleared=$escalatedCleared after $escalated attempts" + ) +} + +/** + * `actual` for `commonTest`'s `expect fun forceGC()`, for the instrumented (on-device) + * compilation. `androidUnitTest` has its own copy: the two are separate compilations of the same + * target, so each needs its own actual, and only this one ever runs against a live interpreter. + * + * Two things here differ from the obvious implementation, and both were paid for: + * + * 1. **`Runtime.getRuntime().gc()`, not `System.gc()`.** On Android `System.gc()` does not + * collect when you call it. libcore's implementation only records a request and defers the + * collection until the next `System.runFinalization()`, so a loop of bare `System.gc()` calls + * runs no collection at all -- which is exactly what the instrumented suite did, on both API + * levels, for the whole of GCLeakTest. `Runtime.gc()` goes straight to `Heap::CollectGarbage`. + * `runFinalization()` follows it so the reference queues are actually drained. See [gcProbe], + * which measures this rather than asserting it. + * + * 2. **The GIL is not touched.** An earlier version called `PyEval_SaveThread()` here and + * restored it afterwards. By the time a test body reaches this call the thread holds nothing: + * `Python3.initialize()` parked the main thread state and `withGIL` attaches per call through + * `PyGILState_Ensure`/`Release`. Mixing raw thread-state parking with the refcounted + * `PyGILState` scheme on one thread is what corrupted the interpreter on desktop and crashed + * the process inside `_PyObject_ClearFreeLists` during `Py_Finalize`; the desktop actual was + * fixed for that reason and this copy was left behind. + * + * Collection is not deterministic, so this stays best-effort and bounded: the caller loops until + * it observes what it is waiting for or gives up. + */ +actual fun forceGC() { + gcProbe + val canary = java.lang.ref.WeakReference(Any()) + var attempts = 0 + while (canary.get() != null && attempts < 20) { + Runtime.getRuntime().gc() + System.runFinalization() + Thread.sleep(10) + attempts++ + } + // The canary being gone means a collection happened; the cleaner still has to be scheduled + // and run its action, which is a separate event. On the pre-API-33 PhantomReference path + // that is a queue poll on a daemon thread, which needs longer than a Cleanable dispatch. + Thread.sleep(100) +} + +actual val cleanerReleasesAutomatically: Boolean = true + +/** + * This platform's finalisation runs on a thread, so a test can force a collection and watch for + * the result inside one call. `Unit` is what a `@Test` returns here; see `commonTest`'s + * [CollectorTestResult] for the target where it cannot be. + */ +actual typealias CollectorTestResult = Unit + +actual fun collectorTest( + maxAttempts: Int, + attempt: () -> Boolean, + finish: () -> Unit +): CollectorTestResult = runCollectorLoopBlocking(maxAttempts, attempt, finish) diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/CompositionBenchmark.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/CompositionBenchmark.kt new file mode 100644 index 000000000..55cdadf76 --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/CompositionBenchmark.kt @@ -0,0 +1,304 @@ +package python.native.ffi + +import android.os.Build +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Measures what composing a binder operation on the native side is worth. + * + * Every other benchmark here optimises the cost of ONE crossing. This one changes how many + * crossings happen. That was the shape the Android path was originally built around -- + * `artMain` assembling a whole operation and `androidMain` calling the assembly once -- and + * it is the only lever left now that the per-crossing cost has bottomed out at ~2-7ns. + * + * Two cases, chosen because they bracket the range: + * + * getAttr a fixed 4 crossings composed into 1 (alloc, call, error check, free) + * list -> array N+1 crossings composed into 1, so the gap grows with N + */ +@RunWith(AndroidJUnit4::class) +class CompositionBenchmark { + + private companion object { + const val TAG = "CompositionBenchmark" + const val WARMUP = 20_000 + const val ITERS = 200_000 + const val ROUNDS = 5 + const val LIST_SIZE = 1000 + } + + private var sink = 0L + + /** + * Every measurement here calls [bindings] directly, so nothing takes the GIL on its behalf + * the way the object model does. The interpreter's main thread state is parked (see + * [PythonOnDevice.ensureInitialised]), so the thread has to attach explicitly or these run + * the C API with no thread state at all. + * + * Once per test rather than once per call: an attach inside the timed loop would be the + * thing being measured. + */ + private var gilState = 0 + + @Before + fun attachToInterpreter() { + PythonOnDevice.ensureInitialised() + gilState = PythonOnDevice.attach() + } + + @After + fun detachFromInterpreter() { + PythonOnDevice.detach(gilState) + } + + private inline fun best(iters: Int, body: () -> Long): Double { + var b = Double.MAX_VALUE + repeat(ROUNDS) { + var local = 0L + val t0 = System.nanoTime() + for (i in 0 until iters) local += body() + val ns = (System.nanoTime() - t0).toDouble() / iters + sink += local + if (ns < b) b = ns + } + return b + } + + @Test + fun composedVersusPerCallCrossings() { + PythonOnDevice.ensureInitialised() + + val main = PythonOnDevice.withUtf8("__main__") { bindings.PyImport_ImportModuleN(it) } + assertTrue("could not import __main__", main != 0L) + + val setup = "_bench_list = list(range($LIST_SIZE))" + assertEquals(0, PythonOnDevice.withUtf8(setup) { bindings.PyRun_SimpleStringN(it).toInt() }) + + val list = PythonOnDevice.withUtf8("_bench_list") { bindings.PyObject_GetAttrStringN(main, it) } + assertTrue("could not read _bench_list", list != 0L) + assertEquals(LIST_SIZE.toLong(), bindings.PyList_SizeNormal(list)) + + // --- getAttr: 4 crossings vs 1 --- + val perCallAttr = best(ITERS) { + val p = bindings.ffiAllocUtf8("_bench_list") + try { + val r = bindings.PyObject_GetAttrStringN(main, p) + if (r == 0L) bindings.PyErr_ClearN() + r + } finally { + bindings.ffiFreeUtf8(p) + } + } + val composedAttr = best(ITERS) { bindings.asmGetAttr(main, "_bench_list") } + + assertTrue("composed getAttr returned 0", bindings.asmGetAttr(main, "_bench_list") != 0L) + + // --- list -> array: N+1 crossings vs 1 --- + val out = LongArray(LIST_SIZE) + val small = 200 + val perCallList = best(small) { + val n = bindings.PyList_SizeNormal(list).toInt() + var acc = 0L + for (i in 0 until n) acc += bindings.PyList_GetItemRaw(list, i.toLong()) + acc + } + val composedList = best(small) { bindings.asmListToArray(list, out).toLong() } + + assertEquals("composed copy wrote the wrong count", LIST_SIZE, bindings.asmListToArray(list, out)) + assertTrue("composed copy left the array empty", out[LIST_SIZE - 1] != 0L) + + Log.i(TAG, "=== API ${Build.VERSION.SDK_INT} / ${Build.MODEL} ===") + Log.i(TAG, String.format("getAttr per-call (4 crossings) %9.2f ns composed %9.2f ns %.1fx", + perCallAttr, composedAttr, perCallAttr / composedAttr)) + Log.i(TAG, String.format("list->arr per-call (%d crossings) %9.2f ns composed %9.2f ns %.1fx", + LIST_SIZE + 1, perCallList, composedList, perCallList / composedList)) + Log.i(TAG, "sink=$sink") + } + + + /** + * The same comparison on a call that does real Python work. + * + * The probes above wrap trivial callees, which shows the boundary at its most flattering. + * `Python3.exec` is a realistic binder operation: it crosses about eleven times -- three C + * strings each needing an alloc and a free, plus AddModuleRef, GetAttrString, RunString and + * three DecRefs -- but it also parses, compiles and executes Python, and that work appears + * in both numbers. Whatever ratio survives here is what composing is actually worth on a + * call people make. + */ + @Test + fun composedVersusPerCallOnRealWork() { + PythonOnDevice.ensureInitialised() + + val code = "x = 1 + 1" + val iters = 20_000 + + // Per-call: the sequence Python3.exec runs, one crossing at a time. + val perCall = best(iters) { + var rc = -1L + val mainName = bindings.ffiAllocUtf8("__main__") + try { + val mod = bindings.PyImport_ImportModuleN(mainName) + if (mod != 0L) { + val dictName = bindings.ffiAllocUtf8("__dict__") + try { + val globals = bindings.PyObject_GetAttrStringN(mod, dictName) + if (globals != 0L) { + val src = bindings.ffiAllocUtf8(code) + try { + rc = bindings.PyRun_SimpleStringN(src).toLong() + } finally { + bindings.ffiFreeUtf8(src) + } + } + } finally { + bindings.ffiFreeUtf8(dictName) + } + } + } finally { + bindings.ffiFreeUtf8(mainName) + } + rc + } + + val composed = best(iters) { bindings.asmExec(code).toLong() } + + assertEquals("composed exec reported failure", 0, bindings.asmExec(code)) + + Log.i(TAG, "--- Python3.exec(\"$code\"), real Python work in both ---") + Log.i(TAG, String.format("per-call %9.2f ns composed %9.2f ns %.2fx", perCall, composed, perCall / composed)) + Log.i(TAG, "sink=$sink") + } + + + /** + * Whether the composition win can be had without composing. + * + * Every composed-versus-per-call result so far turned out to be about string marshalling + * rather than crossing count: the per-call figures were identical on API 26 and API 36 + * despite a 20x difference in per-crossing cost. Desktop pays ~200 ns per string where + * Android pays ~2500 ns, and the difference is that Panama allocates off-heap inside the + * JVM while `ffiAllocUtf8` makes a JNI round trip to malloc and copy. + * + * A DirectByteBuffer allocates off-heap from Java too. Take its address once, and every + * call afterwards is encode-and-copy inside the JVM followed by passing a long. If that + * lands near the composed number, the marshalling is the whole story and composition is + * only needed where crossing count genuinely dominates -- bulk iteration. + */ + @Test + fun directBufferMarshallingVersusComposition() { + PythonOnDevice.ensureInitialised() + + val main = PythonOnDevice.withUtf8("__main__") { bindings.PyImport_ImportModuleN(it) } + assertTrue("could not import __main__", main != 0L) + PythonOnDevice.withUtf8("_bench_list = list(range(8))") { bindings.PyRun_SimpleStringN(it) } + + val name = "_bench_list" + + val perCall = best(ITERS) { + val p = bindings.ffiAllocUtf8(name) + try { bindings.PyObject_GetAttrStringN(main, p) } finally { bindings.ffiFreeUtf8(p) } + } + + val composed = best(ITERS) { bindings.asmGetAttr(main, name) } + + // One buffer, one address lookup. Everything per-call after this stays inside the JVM. + val scratch = java.nio.ByteBuffer.allocateDirect(256) + val scratchAddr = bindings.ffiDirectBufferAddress(scratch) + assertTrue("GetDirectBufferAddress returned 0", scratchAddr != 0L) + + val direct = best(ITERS) { + val bytes = name.toByteArray(Charsets.UTF_8) + scratch.clear() + scratch.put(bytes) + scratch.put(0) + bindings.PyObject_GetAttrStringN(main, scratchAddr) + } + + assertTrue("direct-buffer path returned 0", run { + val bytes = name.toByteArray(Charsets.UTF_8) + scratch.clear(); scratch.put(bytes); scratch.put(0) + bindings.PyObject_GetAttrStringN(main, scratchAddr) != 0L + }) + + Log.i(TAG, "--- getAttr: can marshalling alone close the gap? (API ${Build.VERSION.SDK_INT}) ---") + Log.i(TAG, String.format("per-call (ffiAllocUtf8) %9.2f ns", perCall)) + Log.i(TAG, String.format("composed (asmGetAttr) %9.2f ns %.2fx vs per-call", composed, perCall / composed)) + Log.i(TAG, String.format("direct buffer, no comp %9.2f ns %.2fx vs per-call", direct, perCall / direct)) + Log.i(TAG, "sink=$sink") + } + + + /** + * Two ways to close the gap that remains on old ART. + * + * The direct-buffer path beats composition on API 36 but loses to it on API 26, and the + * reason is not crossings -- both make exactly one ordinary-JNI call there. It is where the + * string gets encoded: composition hands the jstring to `GetStringUTFChars` and encodes in + * native code, while the direct-buffer path encodes in Java, and API 26's ART is much worse + * at that. + * + * So two candidates, both attacking the encode rather than the crossing: + * + * - **ASCII fast path**: write the chars straight into the buffer instead of going through + * `toByteArray`, which allocates a byte[] per call. Python identifiers are ASCII in + * practice; anything else would fall back. + * - **Interning**: attribute and module names are repeated literals, so encode once and + * cache the C string. Per-call cost becomes a map lookup. A real implementation would + * need a bound on the cache, since callers can pass arbitrary strings. + */ + @Test + fun cheaperMarshallingForOldArt() { + PythonOnDevice.ensureInitialised() + + val main = PythonOnDevice.withUtf8("__main__") { bindings.PyImport_ImportModuleN(it) } + assertTrue("could not import __main__", main != 0L) + PythonOnDevice.withUtf8("_bench_list = list(range(8))") { bindings.PyRun_SimpleStringN(it) } + + val name = "_bench_list" + val scratch = java.nio.ByteBuffer.allocateDirect(256) + val scratchAddr = bindings.ffiDirectBufferAddress(scratch) + + val composed = best(ITERS) { bindings.asmGetAttr(main, name) } + + val viaToByteArray = best(ITERS) { + scratch.clear() + scratch.put(name.toByteArray(Charsets.UTF_8)) + scratch.put(0) + bindings.PyObject_GetAttrStringN(main, scratchAddr) + } + + val viaAsciiFastPath = best(ITERS) { + scratch.clear() + for (i in name.indices) scratch.put(name[i].code.toByte()) + scratch.put(0) + bindings.PyObject_GetAttrStringN(main, scratchAddr) + } + + // Interning: the C string is encoded once, outside the loop. + val interned = bindings.ffiAllocUtf8(name) + val cache = HashMap().apply { put(name, interned) } + val viaInterning = try { + best(ITERS) { + val addr = cache[name]!! + bindings.PyObject_GetAttrStringN(main, addr) + } + } finally { + bindings.ffiFreeUtf8(interned) + } + + Log.i(TAG, "--- marshalling variants (API ${Build.VERSION.SDK_INT}) ---") + Log.i(TAG, String.format("composed %9.2f ns", composed)) + Log.i(TAG, String.format("direct + toByteArray %9.2f ns", viaToByteArray)) + Log.i(TAG, String.format("direct + ascii path %9.2f ns", viaAsciiFastPath)) + Log.i(TAG, String.format("interned C string %9.2f ns", viaInterning)) + Log.i(TAG, "sink=$sink") + } +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniOverheadBenchmark.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniOverheadBenchmark.kt new file mode 100644 index 000000000..9b12d7bbe --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniOverheadBenchmark.kt @@ -0,0 +1,297 @@ +package python.native.ffi + +import android.os.Build +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Measures what the JNI calling convention actually costs on this device. + * + * `echo0`, `echoFast` and `echoNormal` are the SAME one-line C body -- `jlong f(jlong x) + * { return x; }` -- registered three times under the three conventions (see + * artMain/cinterop/jni_onload.def). The callee work is identical and negligible, so the + * difference between them is the transition cost and nothing else. + * + * ordinary JNI receives JNIEnv and jclass, and ART performs the GC thread-state + * transition plus a local-reference frame around the call + * @FastNative still receives JNIEnv and jclass, but skips the thread-state transition + * @CriticalNative receives neither, and skips the transition -- arguments go straight + * through in registers + * + * A pure-Kotlin identity call is measured alongside as the floor, so loop and timing + * overhead get subtracted rather than silently counted as native cost. + * + * METHODOLOGY. An earlier version warmed up and measured one variant at a time, and produced + * results that contradicted each other across devices -- whichever native variant was measured + * first looked anomalous on API 34. ART compiles asynchronously, so a variant measured while + * the runtime is still settling is charged for that. This version therefore warms up ALL + * variants before measuring any, and then interleaves: every round measures all four, and each + * variant keeps its own best round. Drift that affects one round affects all four equally. + * + * These runs happen on an emulator, so absolute nanoseconds say nothing about real hardware. + * The ratios between rows are the output worth reading. + */ +@RunWith(AndroidJUnit4::class) +class JniOverheadBenchmark { + + private companion object { + const val TAG = "JniOverheadBenchmark" + const val WARMUP = 400_000 + const val ITERS = 2_000_000 + const val ROUNDS = 7 + } + + /** + * Whether it is safe to CALL the name-linked @CriticalNative variant at all. + * + * Measured, not assumed: on API 26 that call aborts the ART runtime outright -- + * "zygote64: runtime.cc:492] Runtime aborting..." -- and takes the instrumentation + * process with it. This is exactly the failure Google's guidance implies when it says to + * bind @CriticalNative through RegisterNatives before Android 12. Above 12 the call is + * merely slow, not fatal. + */ + private val nameLinkedIsSafe: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + /** Kept live across the whole test so the JIT cannot fold any call away. */ + private var sink = 0L + + /** + * 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 + * per test, outside the timed loops, so the attach is not what gets measured. + */ + private var gilState = 0 + + @Before + fun attachToInterpreter() { + PythonOnDevice.ensureInitialised() + gilState = PythonOnDevice.attach() + } + + @After + fun detachFromInterpreter() { + PythonOnDevice.detach(gilState) + } + + private fun kotlinEcho(x: Long): Long = x + + private inline fun timeOnce(body: (Long) -> Long): Double { + var local = 0L + val t0 = System.nanoTime() + for (i in 0 until ITERS) local += body(i.toLong()) + val elapsed = System.nanoTime() - t0 + sink += local + return elapsed.toDouble() / ITERS + } + + @Test + fun transitionCostByCallingConvention() { + Log.i(TAG, "=== ${Build.MODEL} / API ${Build.VERSION.SDK_INT} / ${Build.SUPPORTED_ABIS[0]} ===") + + // Warm every variant before timing any of them. + var w = 0L + repeat(WARMUP) { i -> + val x = i.toLong() + w += kotlinEcho(x) + bindings.echo0(x) + bindings.echoFast(x) + bindings.echoNormal(x) + if (nameLinkedIsSafe) w += bindings.echoCriticalNamed(x) + } + sink += w + + var bestKotlin = Double.MAX_VALUE + var bestCritical = Double.MAX_VALUE + var bestFast = Double.MAX_VALUE + var bestNormal = Double.MAX_VALUE + var bestCriticalNamed = Double.MAX_VALUE + + repeat(ROUNDS) { + timeOnce { kotlinEcho(it) }.let { if (it < bestKotlin) bestKotlin = it } + timeOnce { bindings.echo0(it) }.let { if (it < bestCritical) bestCritical = it } + if (nameLinkedIsSafe) { + timeOnce { bindings.echoCriticalNamed(it) }.let { if (it < bestCriticalNamed) bestCriticalNamed = it } + } + timeOnce { bindings.echoFast(it) }.let { if (it < bestFast) bestFast = it } + timeOnce { bindings.echoNormal(it) }.let { if (it < bestNormal) bestNormal = it } + } + + val critical = bestCritical - bestKotlin + val criticalNamed = bestCriticalNamed - bestKotlin + val fast = bestFast - bestKotlin + val normal = bestNormal - bestKotlin + + Log.i(TAG, String.format("kotlin floor %8.2f ns/call", bestKotlin)) + Log.i(TAG, String.format("@CriticalNative (reg) %8.2f ns/call net %7.2f ns", bestCritical, critical)) + if (nameLinkedIsSafe) { + Log.i(TAG, String.format("@CriticalNative (name) %8.2f ns/call net %7.2f ns", bestCriticalNamed, criticalNamed)) + } else { + Log.i(TAG, "@CriticalNative (name) SKIPPED -- aborts the ART runtime below Android 12") + } + Log.i(TAG, String.format("@FastNative (reg) %8.2f ns/call net %7.2f ns", bestFast, fast)) + Log.i(TAG, String.format("ordinary JNI (reg) %8.2f ns/call net %7.2f ns", bestNormal, normal)) + Log.i(TAG, "sink=$sink") + + // This test originally asserted that @CriticalNative must not be slower than ordinary + // JNI. Measurement disproved that on API 34 (critical ~24ns net, ordinary ~8ns), while + // API 26 shows the opposite (critical ~2ns, ordinary ~45ns). The assumption was wrong, + // so asserting it would only encode the wrong belief. See docs/design/downcall-design.md. + // + // What is still worth asserting is correctness: all three conventions must return the + // value they were handed. If a registration silently stopped taking effect, arguments + // would shift and these would return a pointer instead. + val probe = 0x0123_4567_89ABL + assertTrue("@CriticalNative echo returned ${bindings.echo0(probe)}", bindings.echo0(probe) == probe) + assertTrue("@FastNative echo returned ${bindings.echoFast(probe)}", bindings.echoFast(probe) == probe) + assertTrue("ordinary JNI echo returned ${bindings.echoNormal(probe)}", bindings.echoNormal(probe) == probe) + if (nameLinkedIsSafe) { + assertTrue("name-linked critical echo returned ${bindings.echoCriticalNamed(probe)}", bindings.echoCriticalNamed(probe) == probe) + } + } + + /** + * Same comparison at a much smaller batch size. + * + * A @CriticalNative call blocks GC for its duration, so a tight loop of millions of them + * can keep the collector waiting and have that delay charged back to the loop. If the + * anomaly seen at 2,000,000 iterations is that effect, the per-call cost should fall + * sharply here; if the cost is unchanged, the batch size is not the explanation. + */ + @Test + fun transitionCostAtSmallBatchSize() { + val small = 20_000 + var s = 0L + repeat(WARMUP) { i -> + val x = i.toLong() + s += kotlinEcho(x) + bindings.echo0(x) + bindings.echoFast(x) + bindings.echoNormal(x) + } + sink += s + + fun timeSmall(body: (Long) -> Long): Double { + var best = Double.MAX_VALUE + repeat(50) { + var local = 0L + val t0 = System.nanoTime() + for (i in 0 until small) local += body(i.toLong()) + val ns = (System.nanoTime() - t0).toDouble() / small + sink += local + if (ns < best) best = ns + } + return best + } + + val k = timeSmall { kotlinEcho(it) } + val c = timeSmall { bindings.echo0(it) } + val f = timeSmall { bindings.echoFast(it) } + val n = timeSmall { bindings.echoNormal(it) } + + Log.i(TAG, "--- batch=$small (vs $ITERS above) ---") + Log.i(TAG, String.format("kotlin floor %8.2f ns/call", k)) + Log.i(TAG, String.format("@CriticalNative %8.2f ns/call net %7.2f ns", c, c - k)) + Log.i(TAG, String.format("@FastNative %8.2f ns/call net %7.2f ns", f, f - k)) + Log.i(TAG, String.format("ordinary JNI %8.2f ns/call net %7.2f ns", n, n - k)) + Log.i(TAG, "sink=$sink") + } + + /** + * The same comparison on a call that does real work. + * + * Every number above measures a transition wrapped around `return x`, which makes the + * convention look as important as it can possibly look. What matters for this project is + * whether the difference survives once the callee actually does something. `PyList_Size` + * is close to the cheapest real C API function there is -- an O(1) read off the list + * header -- so it is the case where the transition still has the best chance of dominating. + * If the spread collapses even here, it collapses everywhere. + * + * The list under test is `sys.path`, reachable through functions already registered. + */ + @Test + fun realCPythonCallByCallingConvention() { + PythonOnDevice.ensureInitialised() + + val sys = PythonOnDevice.withUtf8("sys") { bindings.PyImport_ImportModuleN(it) } + assertTrue("could not import sys", sys != 0L) + val path = PythonOnDevice.withUtf8("path") { bindings.PyObject_GetAttrStringN(sys, it) } + assertTrue("could not read sys.path", path != 0L) + + val len = bindings.PyList_SizeNormal(path) // using SizeNormal because PyList_Size is broken without N (or it's what SizeNormal binds to) + assertTrue("sys.path should be a non-empty list, got len=$len", len > 0) + assertTrue("PyList_SizeFast disagrees: ${bindings.PyList_SizeFast(path)} vs $len", bindings.PyList_SizeFast(path) == len) + assertTrue("PyList_SizeNormal disagrees: ${bindings.PyList_SizeNormal(path)} vs $len", bindings.PyList_SizeNormal(path) == len) + + var w = 0L + repeat(WARMUP) { + w += bindings.PyList_SizeNormal(path) + bindings.PyList_SizeFast(path) + bindings.PyList_SizeNormal(path) + } + sink += w + + var bestCritical = Double.MAX_VALUE + var bestFast = Double.MAX_VALUE + var bestNormal = Double.MAX_VALUE + repeat(ROUNDS) { + timeOnce { bindings.PyList_SizeNormal(path) }.let { if (it < bestCritical) bestCritical = it } + timeOnce { bindings.PyList_SizeFast(path) }.let { if (it < bestFast) bestFast = it } + timeOnce { bindings.PyList_SizeNormal(path) }.let { if (it < bestNormal) bestNormal = it } + } + + Log.i(TAG, "--- PyList_Size on sys.path (len=$len), a real C API call ---") + Log.i(TAG, String.format("@CriticalNative %8.2f ns/call", bestCritical)) + Log.i(TAG, String.format("@FastNative %8.2f ns/call", bestFast)) + Log.i(TAG, String.format("ordinary JNI %8.2f ns/call", bestNormal)) + Log.i(TAG, "sink=$sink") + } + + + /** + * Checks that [bindings.preferFastNative] actually names the faster convention here. + * + * The threshold in that flag comes from measurement on API 26/30/31/33/34/36, not from a + * rule ART documents, so nothing stops a future release from moving it again. Rather than + * trusting the constant, this measures both conventions on whatever device is running and + * fails if the flag disagrees with the result. + * + * A failure here does not mean the dispatch is broken -- it means the boundary moved and + * the threshold needs re-deriving. The margin required is deliberately wide, since the two + * are genuinely close around API 33 and a coin-flip there is not worth failing over. + */ + @Test + fun dispatchPicksTheFasterConventionOnThisDevice() { + var w = 0L + repeat(WARMUP) { i -> w += bindings.echo0(i.toLong()) + bindings.echoFast(i.toLong()) } + sink += w + + var bestCritical = Double.MAX_VALUE + var bestFast = Double.MAX_VALUE + repeat(ROUNDS) { + timeOnce { bindings.echo0(it) }.let { if (it < bestCritical) bestCritical = it } + timeOnce { bindings.echoFast(it) }.let { if (it < bestFast) bestFast = it } + } + + val flag = bindings.preferFastNative + Log.i(TAG, "--- dispatch check, API ${Build.VERSION.SDK_INT} ---") + Log.i(TAG, String.format("preferFastNative=%s critical %.2f ns fast %.2f ns", flag, bestCritical, bestFast)) + Log.i(TAG, "sink=$sink") + + // Only complain when one convention is clearly ahead; a 2x gap is far below the ~10-20x + // separation seen on every device measured so far, and far above ordinary jitter. + val criticalClearlyBetter = bestCritical * 2 < bestFast + val fastClearlyBetter = bestFast * 2 < bestCritical + if (criticalClearlyBetter) { + assertTrue( + "API ${Build.VERSION.SDK_INT}: @CriticalNative measured clearly faster " + + "($bestCritical vs $bestFast ns) but preferFastNative is true", + !flag, + ) + } else if (fastClearlyBetter) { + assertTrue( + "API ${Build.VERSION.SDK_INT}: @FastNative measured clearly faster " + + "($bestFast vs $bestCritical ns) but preferFastNative is false", + flag, + ) + } + } +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniWiringTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniWiringTest.kt new file mode 100644 index 000000000..47d37eccf --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/JniWiringTest.kt @@ -0,0 +1,142 @@ +package python.native.ffi + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +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` + * 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. + * + * Zero-argument functions survive this by accident, since the callee simply ignores the extra + * registers. So the check has to pass a value and read it back: [bindings.diagEcho] returns its + * argument unchanged, which makes the shift immediately visible. + */ +@RunWith(AndroidJUnit4::class) +class JniWiringTest { + + /** + * These reach the C API directly rather than through the object model, and the interpreter's + * main thread state is parked (see [PythonOnDevice.ensureInitialised]), so the thread has to + * attach for itself. The echo probes do not need it, but attaching for the whole class costs + * one `PyGILState_Ensure` per test and removes the question. + */ + private var gilState = 0 + + @Before + fun attachToInterpreter() { + PythonOnDevice.ensureInitialised() + gilState = PythonOnDevice.attach() + } + + @After + fun detachFromInterpreter() { + PythonOnDevice.detach(gilState) + } + + @Test + fun argumentsArriveUnshifted() { + // echo0/echo1/echo2 are three separate registrations of the same C body, + // `jlong f(jlong x) { return x; }`. They are not testing different argument + // positions -- each takes exactly one argument. So under a correct calling + // convention all three must return the sentinel, and asserting only the first + // would hide a failure in the other two. + val sent = 0x5A5A_1234_5678L + assertEquals("echo0 must return the value it was sent", sent, bindings.echo0(sent)) + assertEquals("echo1 must return the value it was sent", sent, bindings.echo1(sent)) + assertEquals("echo2 must return the value it was sent", sent, bindings.echo2(sent)) + } + + @Test + fun zeroArgCallsWorkRegardless() { + // 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. + // + // 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 + // happened to be in the return register, which is not reliably 1. + python.multiplatform.ffi.PythonTestFixture.withInterpreter { + assertEquals( + "Py_IsInitialized should report 1 once the interpreter is up", + 1, bindings.Py_IsInitialized() + ) + // @CriticalNative and @FastNative are two separate registrations of the same C + // function, and EmbedAPI picks between them per API level. Both must agree with the + // actual, or one of them is bound to the wrong wrapper on this device. + assertEquals("the @FastNative twin must agree", 1, bindings.Py_IsInitializedF()) + assertEquals("the EmbedAPI actual must agree", 1, Py_IsInitialized()) + } + } + + /** + * `PyList_GetItemRawF` is the @FastNative twin added so the per-element call of bulk list + * iteration follows the device axis instead of being pinned to @CriticalNative (see + * `docs/investigations/jni-call-convention-audit.md`). It is a second registration of the same CPython + * function, so the two must be indistinguishable at every index. + * + * This is a wiring check, not a benchmark. Two ways to get it wrong are both caught here: a + * missing or misspelled table entry leaves the method unregistered and the call throws, and a + * wrapper written without the leading `JNIEnv*, jclass` shifts both arguments, which makes the + * returned pointers disagree rather than merely being slow. + */ + @Test + fun bothListGetItemConventionsAgree() { + PythonOnDevice.ensureInitialised() + + val sys = PythonOnDevice.withUtf8("sys") { bindings.PyImport_ImportModuleN(it) } + org.junit.Assert.assertTrue("could not import sys", sys != 0L) + val path = PythonOnDevice.withUtf8("path") { bindings.PyObject_GetAttrStringN(sys, it) } + org.junit.Assert.assertTrue("could not read sys.path", path != 0L) + + val len = bindings.PyList_SizeNormal(path) + org.junit.Assert.assertTrue("sys.path should be a non-empty list, got len=$len", len > 0) + + for (i in 0 until len) { + val critical = bindings.PyList_GetItemRaw(path, i) + val fast = bindings.PyList_GetItemRawF(path, i) + org.junit.Assert.assertTrue("PyList_GetItemRaw returned null at index $i", critical != 0L) + assertEquals( + "PyList_GetItemRawF disagrees with PyList_GetItemRaw at index $i", + critical, + fast, + ) + } + } + + @Test + fun cpythonInitialisesCorrectly() { + python.multiplatform.ffi.PythonTestFixture.withInterpreter { + android.util.Log.d("JniWiringTest", "Calling Py_IsInitialized()") + org.junit.Assert.assertNotEquals(0, Py_IsInitialized()) + + val code = "x = 1 + 1" + android.util.Log.d("JniWiringTest", "Calling PyRun_SimpleString()") + org.junit.Assert.assertEquals(0, PyRun_SimpleString(code)) + + android.util.Log.d("JniWiringTest", "Calling Py_GetVersion()") + val version = Py_GetVersion() + android.util.Log.d("JniWiringTest", "Version: $version") + // Against the *configured* version, not a literal: this checks that the interpreter + // loaded is the one the build downloaded, and does not need hand-editing on a bump. + val expected = python.multiplatform.Versions.currentVersion.compactVersionString + org.junit.Assert.assertTrue("Version should start with $expected, got $version", version?.startsWith(expected) == true) + + android.util.Log.d("JniWiringTest", "Calling PyImport_ImportModule()") + val sysModule = PyImport_ImportModule("sys") + org.junit.Assert.assertNotNull(sysModule) + } + } +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/PythonOnDevice.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/PythonOnDevice.kt new file mode 100644 index 000000000..b3a0cde41 --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/PythonOnDevice.kt @@ -0,0 +1,82 @@ +package python.native.ffi + +import androidx.test.platform.app.InstrumentationRegistry +import python.multiplatform.env.PythonBootstrap +import python.multiplatform.ffi.Python3 + +/** + * Gets a usable CPython interpreter running inside the instrumentation process. + * + * `Py_Initialize` aborts the whole process with "Failed to import encodings module" unless the + * standard library is present on disk and PYTHONHOME points at it. The library ships that + * stdlib as APK assets, so it has to be unpacked to app-private storage first. + * + * That unpacking used to be written out here, in a near-copy of what `sample`'s `MainActivity` and + * the external consumer app each wrote for themselves. It is [PythonBootstrap] now -- shipped in + * `androidMain`, so a host app gets it from the AAR -- and this fixture is one of its callers + * rather than a fourth implementation. Keeping the fixture on the same code path as host apps is + * the point: the 340-odd instrumented tests in this process are then also a test of the helper. + * + * Idempotent on purpose: JUnit does not guarantee test order, and one test finalises the + * interpreter when it is done. Every test that needs Python calls [ensureInitialised] and gets + * a live interpreter whether or not one was already running. + */ +object PythonOnDevice { + + /** + * Stages the stdlib, then brings the interpreter up **through [Python3.initialize]** rather + * than through a bare `Py_Initialize()`. + * + * The difference is the GIL, and it was worth three GCLeakTest failures on both API levels. + * `Py_Initialize()` returns with the GIL held by its caller. [Python3.initialize] parks that + * thread state with `PyEval_SaveThread()` immediately afterwards, which is what lets a cleaner + * thread attach through `PyGILState_Ensure` and call `Py_DecRef` (ROADMAP §1). + * + * 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 + * `PyGILState_Ensure` it reached. + * + * That state is invisible while nothing releases the GIL, which is why it survived: the old + * `forceGC()` called `PyEval_SaveThread()`/`PyEval_RestoreThread` around its sleep, and that + * accidental 200 ms window was the only thing letting cleaners through at all. + */ + fun ensureInitialised() { + // Stages the stdlib, sets PYTHONHOME, and calls Python3.initialize -- in that order, which + // is the order the parking above depends on. + PythonBootstrap.initialize( + InstrumentationRegistry.getInstrumentation().targetContext, + silent = true, + ) + check(Py_IsInitialized() != 0) { "Py_Initialize() did not take effect" } + } + + /** + * Attaches the calling thread for the body of a test that reaches [bindings] directly. + * + * The object model takes the GIL for itself on every call, so tests written against `Python3` + * and `PyObject` need nothing. Tests that call the raw JNI surface bypass that, and since + * [ensureInitialised] now parks the main thread state they would otherwise run the C API with + * no thread state attached, which is undefined behaviour rather than a clean failure. + */ + fun attach(): Int = PyGILState_Ensure() + + fun detach(state: Int) = PyGILState_Release(state) + + /** Allocates a C string the caller must release with [freeUtf8]. */ + fun utf8(s: String): Long = bindings.ffiAllocUtf8(s) + + fun freeUtf8(ptr: Long) = bindings.ffiFreeUtf8(ptr) + + /** Runs [block] with a temporary C string, releasing it even if [block] throws. */ + inline fun withUtf8(s: String, block: (Long) -> T): T { + val ptr = utf8(s) + try { + return block(ptr) + } finally { + freeUtf8(ptr) + } + } +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/StringMarshallingTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/StringMarshallingTest.kt new file mode 100644 index 000000000..0214ea889 --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/StringMarshallingTest.kt @@ -0,0 +1,45 @@ +package python.native.ffi + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue + +@RunWith(AndroidJUnit4::class) +class StringMarshallingTest { + + @Test + fun testInterning() { + val s1 = "intern_me" + val addr1 = internedUtf8(s1) + val addr2 = internedUtf8(s1) + assertEquals("Repeated interning of equal strings must return the same address", addr1, addr2) + + val s2 = "another_string" + val addr3 = internedUtf8(s2) + assertNotEquals("Different strings must return different addresses", addr1, addr3) + + val read1 = ffiReadUtf8(addr1) + assertEquals("Round trip must produce same string", s1, read1) + } + + @Test + fun testScratchEncoding() { + val s = "scratch_me" + val addr = encodeScratchUtf8(s) + val read = ffiReadUtf8(addr) + assertEquals("Scratch buffer round trip must produce same string", s, read) + } + + @Test + fun testNonAsciiFallback() { + val s = "non_ascii_🔥_test_ç" + val addr1 = internedUtf8(s) + assertEquals("Non-ASCII interned string round trip failed", s, ffiReadUtf8(addr1)) + + val addr2 = encodeScratchUtf8(s) + assertEquals("Non-ASCII scratch string round trip failed", s, ffiReadUtf8(addr2)) + } +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/UpcallOverheadTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/UpcallOverheadTest.kt new file mode 100644 index 000000000..3f0275638 --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/UpcallOverheadTest.kt @@ -0,0 +1,398 @@ +package python.native.ffi + +import android.os.Build +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.PythonTestFixture +import python.multiplatform.ffi.upcall.TrampolineFragment +import python.multiplatform.ffi.upcall.UpcallTrampoline +import python.multiplatform.overhead.Benchmark +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What an Android upcall actually costs, and whether the attach it needs is paid once per thread + * or once per call. + * + * The commit that landed the Android boundary named two costs and measured neither: + * + * 1. one JNI upcall per call, and + * 2. an `AttachCurrentThreadAsDaemon`/`DetachCurrentThread` pair whenever Python calls from a + * thread ART has never seen -- which every `threading.Thread` is, because CPython creates + * bare pthreads. + * + * The second is the one that decides whether anything needs changing, and it cannot be settled by + * reading `jni_onload.def`'s `pmp_attach` alone: that function only says *an* attach happens when + * `GetEnv` fails. Whether the attachment survives to the next call is a property of the caller + * *and* of ART, so it is observed here rather than argued. + * + * ### What it cost when it was per call, and what changed + * + * The first run of this test found the attach was paid on **every** call, and priced it: + * + * | | API 26 | API 36 | + * |---|---|---| + * | upcall, instrumentation thread | 1695 ns | 6693 ns | + * | upcall, Python worker | 63293 ns | 31219 ns | + * | attach, i.e. the difference | **61598 ns** | **24526 ns** | + * + * `pmp_attach` now keeps the attachment for the life of the thread and releases it from a + * `pthread_key_create` destructor, so the worker figure should collapse onto the instrumentation + * one. `UpcallThreadAttachTest` holds the correctness half of that change; this holds the price. + * + * ### How the attach is counted rather than inferred from a stopwatch + * + * `AttachCurrentThreadAsDaemon` builds a fresh `java.lang.Thread` for the pthread it attaches, and + * `Thread.getId()` comes from a monotonic counter that is never reused. So the identity of the + * thread the *Kotlin* side lands on is a direct readout of the attach: N calls from one Python + * worker report N distinct ids if the attach is per call, and one id if it is per thread. That is + * `probe.whichThread` -- the same fragment `UpcallEntryTest` uses to prove the attach branch is + * taken at all -- read back as a Python `set`. + * + * A timing-only answer would have been ambiguous on an emulator; this one is not. + * + * ### Why everything is in one test method + * + * The downcall figures are here rather than borrowed from `overhead/BenchmarkTest`, because a + * ratio between two *different runs* on an emulator is not a measurement. `commonMain/README.md`'s + * companion rule applies to warmup too: an unwarmed first row once made a subset of the work look + * cheaper than the whole of it in this repo, so every loop below is preceded by a warmup of the + * same shape, and the Python-side loop overhead is measured and reported alongside so the boundary + * cost can be read net of it. + * + * Results are printed rather than asserted on. A wall-clock threshold on an emulator would be a + * flake generator; what *is* asserted is the structural fact (how many attaches N calls cost), + * which is deterministic. + */ +class UpcallOverheadTest { + + private companion object { + /** Calls per timed loop. Large enough to swamp `perf_counter_ns`, small enough for API 26. */ + const val N = 10_000 + + /** + * Same shape as the measured loop, run first. + * + * ### Why 100 000 and not the 3 000 this used to be + * + * At 3 000 this test reported a same-process warm-up state rather than a boundary cost. + * The evidence is in this file's own history: the API 36 upcall row read `2982-5086` ns + * and later `1032-1229` ns with **the same warmup and the same emulator** -- the only + * thing that differed was how many tests ran before it. A number that moves by 4x on what + * ran first is not measuring the boundary. + * + * `UpcallBoundaryCostTest` diagnosed the mechanism and `7e9c6b8c` fixed it there: it is + * host JIT tier-up, not CPython-side state. The discriminator is that the rows with no + * boundary in them -- the empty Python loop and the pure-Python callee -- are flat from + * the first repetition, while only the rows crossing into host code move. On ART that + * host is a JIT too, so the same fix applies here. + * + * ART is the tightest of the hosts swept in `ec53b6e5`: API 36 only reached its plateau + * at roughly 90 000-100 000 calls, where desktop needed ~70 000 and wasm ~70 000. So + * 100 000 is ART's requirement with little margin to spare, and that is why this constant + * is 100 000 rather than something smaller that would do elsewhere. + * + * The cost is bounded by what it warms: three warmup loops of 100 000 calls, at the + * ~1 us per call this test reports for the dearest of them, is a fraction of a second. + * + * This deliberately does **not** warm the worker thread's upcall path -- see + * `_pm_worker_body`, whose first upcall is a measurement (`upcall_worker_first`), not + * waste. The worker still benefits from this warmup, because JIT tier-up is a property of + * the process rather than of the thread, which is what leaves the per-thread attach as + * the only thing that row still has to pay. + */ + const val WARMUP = 100_000 + + /** How many calls the attach is counted over. */ + const val PROBE_CALLS = 64 + + const val DOWNCALL_REFCOUNT = "Py_IncRef + Py_DecRef (2 JNI downcalls, a GIL scope each)" + const val DOWNCALL_CALL = "PyObject_CallObject on a Python def (downcall, same shape)" + const val TRAMPOLINE_ONLY = "UpcallTrampoline.invoke from Kotlin (no C shim, no JNI)" + } + + @BeforeTest + fun install() { + PythonOnDevice.ensureInitialised() + UpcallTable.install(listOf(TrampolineFragment, ThreadProbeFragment)) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + if (PythonTestFixture.available) Python3.exec("_pm_bench = None") + } + + private fun py(expression: String): String = PythonTestFixture.eval(expression).toString() + + private fun pyDouble(expression: String): Double = py(expression).toDouble() + + /** + * Publishes the bootstrap and binds the two entries this measures, exactly as + * `UpcallEntryTest` does: a name resolved once, an integer carried inside the callable's + * `self` from then on. Binding is deliberately outside every timed loop -- it is a cold path. + */ + private fun installBridge() { + val globals: PyObject = PythonTestFixture.mainGlobals() + assertTrue(UpcallEntry.publish(globals.pointer), "the upcall bootstrap could not be published") + Python3.exec( + """ + import threading, time + + _pm_add = _pm_bind(_pm_resolve('trampoline.add')) + _pm_probe = _pm_bind(_pm_resolve('probe.whichThread')) + + def _pm_py_add(a, b): + '''A pure-Python callee of the same shape: the floor a Kotlin one is measured against.''' + return a + b + + def _pm_time_calls(fn, n): + t0 = time.perf_counter_ns() + for _ in range(n): + fn(3, 4) + return (time.perf_counter_ns() - t0) / n + + def _pm_time_loop(n): + '''The same loop with no call in it, so the call cost can be read net of Python's own.''' + t0 = time.perf_counter_ns() + for _ in range(n): + pass + return (time.perf_counter_ns() - t0) / n + + def _pm_distinct_threads(calls): + '''One entry per JVM thread the upcalls landed on. 1 == the attach was amortised.''' + seen = set() + for _ in range(calls): + seen.add(_pm_probe(0)) + return len(seen) + + _pm_bench = {} + """.trimIndent(), + ) + } + + /** + * The three paths the design question is about, plus the downcall figures they are compared + * against, all inside one interpreter and one warmup regime. + */ + @Test + fun anUpcallFromAPythonWorkerThreadPaysAnAttachPerCallRatherThanPerThread() = + PythonTestFixture.withInterpreter { + installBridge() + + // ---- Path 1: the instrumentation thread, which ART already knows ---------------- + Python3.exec( + """ + _pm_time_calls(_pm_add, $WARMUP) + _pm_time_calls(_pm_py_add, $WARMUP) + _pm_time_loop($WARMUP) + + _pm_bench['loop_main'] = _pm_time_loop($N) + _pm_bench['py_main'] = _pm_time_calls(_pm_py_add, $N) + _pm_bench['upcall_main'] = _pm_time_calls(_pm_add, $N) + _pm_bench['threads_main'] = _pm_distinct_threads($PROBE_CALLS) + """.trimIndent(), + ) + + // ---- Paths 2 and 3: a threading.Thread, i.e. a pthread ART has never seen ------- + // The worker warms its own Python-side state on the pure-Python callee first, so the + // first *upcall* it makes is the first time anything crosses into ART on this thread + // and nothing else is cold at the same moment. If the attach were per thread, that one + // call would carry it and `upcall_worker` would fall back to `upcall_main`. + Python3.exec( + """ + def _pm_worker_body(n): + _pm_time_calls(_pm_py_add, n) + _pm_bench['loop_worker'] = _pm_time_loop(n) + _pm_bench['py_worker'] = _pm_time_calls(_pm_py_add, n) + + t0 = time.perf_counter_ns() + _pm_add(3, 4) + _pm_bench['upcall_worker_first'] = float(time.perf_counter_ns() - t0) + + _pm_bench['upcall_worker'] = _pm_time_calls(_pm_add, n) + _pm_bench['threads_worker'] = _pm_distinct_threads($PROBE_CALLS) + + _pm_t = threading.Thread(target=_pm_worker_body, args=($N,)) + _pm_t.start() + _pm_t.join() + """.trimIndent(), + ) + + val loopMain = pyDouble("_pm_bench['loop_main']") + val pyMain = pyDouble("_pm_bench['py_main']") + val upcallMain = pyDouble("_pm_bench['upcall_main']") + val threadsMain = py("_pm_bench['threads_main']").toInt() + + val loopWorker = pyDouble("_pm_bench['loop_worker']") + val pyWorker = pyDouble("_pm_bench['py_worker']") + val upcallWorkerFirst = pyDouble("_pm_bench['upcall_worker_first']") + val upcallWorker = pyDouble("_pm_bench['upcall_worker']") + val threadsWorker = py("_pm_bench['threads_worker']").toInt() + + // ---- The comparison basis: downcalls, same interpreter, same warmup regime ------- + val downcalls = measureDowncalls() + + report( + loopMain, pyMain, upcallMain, threadsMain, + loopWorker, pyWorker, upcallWorkerFirst, upcallWorker, threadsWorker, + downcalls, + ) + + // ---- What is actually asserted -------------------------------------------------- + assertEquals( + 1, threadsMain, + "the instrumentation thread is one ART thread throughout; $PROBE_CALLS upcalls " + + "reporting $threadsMain distinct ids would mean this probe measures something else", + ) + assertEquals( + 1, threadsWorker, + "every upcall from one Python worker thread should arrive on the same ART thread: " + + "the attach is paid once when the worker first crosses into ART and released " + + "by pmp_thread_exit_detach when the pthread dies. $threadsWorker distinct ids " + + "over $PROBE_CALLS calls means it is back to one attach per call", + ) + assertTrue( + upcallWorker < upcallWorkerFirst, + "the steady-state upcall from a Python worker is not cheaper than that thread's " + + "first one, so nothing is being amortised: steady ${upcallWorker.ns()} vs " + + "first ${upcallWorkerFirst.ns()}", + ) + } + + /** + * The downcall side of the ratio, measured here so it shares this run's device, interpreter + * and thermal state. + * + * Two of the four are the symmetric counterparts of the upcall rather than raw FFI: calling a + * Python function from Kotlin is what a Kotlin→Python call of the same shape costs, and + * calling [UpcallTrampoline] directly is the same upcall *minus* the C shim and the JNI + * boundary -- so the difference between it and the Python-driven figure is what the boundary + * itself adds, with the marshalling held constant. + */ + private fun measureDowncalls(): Map { + val pyAdd = PythonTestFixture.eval("_pm_py_add") + val handle = UpcallTable.resolve("trampoline.add").raw + + // (3, 4), built once. PyTuple_SetItem steals, so the two PyLongs are the tuple's. + val args = Python3.withPython { + val tuple = PyTuple_New(2L) ?: error("PyTuple_New failed") + PyTuple_SetItem(tuple, 0L, PyLong_FromLongLong(3L) ?: error("PyLong_FromLongLong failed")) + PyTuple_SetItem(tuple, 1L, PyLong_FromLongLong(4L) ?: error("PyLong_FromLongLong failed")) + tuple + } + + val results = LinkedHashMap() + // [WARMUP], not `N / 4`. These rows cross the same JNI boundary as the Python-driven ones + // and warm on the same curve, and 2 500 was far down it -- `UpcallBoundaryCostTest` swept + // its equivalent 40 times and read 653, 266, 266, 161 before it settled at ~134-141, i.e. + // three tiers early at 2 500. The Python-driven and Kotlin-driven halves have to be warmed + // alike, or the ratio this test exists to report is a ratio of two compilation states. + fun record(name: String, block: () -> Unit) { + results[name] = Benchmark.measure(warmupIterations = WARMUP, iterations = N, block = block) + } + + try { + // Balanced within one iteration, so the count is back where it started however many + // times the loop runs -- including the warmup. + val scratch = Python3.withPython { PyLong_FromLongLong(42L) } ?: error("PyLong_FromLongLong failed") + try { + record(DOWNCALL_REFCOUNT) { + Python3.withPython { Py_IncRef(scratch) } + Python3.withPython { Py_DecRef(scratch) } + } + } finally { + Python3.withPython { Py_DecRef(scratch) } + } + + record(DOWNCALL_CALL) { + val r = Python3.withPython { PyObject_CallObject(pyAdd.pointer, args) } + if (r != null) Python3.withPython { Py_DecRef(r) } + } + + record(TRAMPOLINE_ONLY) { + val r = UpcallTrampoline.invoke(handle, args.toRawValue()) + val p = r.toNativePointer() + if (p != null) Python3.withPython { Py_DecRef(p) } + } + } finally { + Python3.withPython { Py_DecRef(args) } + pyAdd.close() + } + return results + } + + /** + * The sign is handled separately because the difference this reports can now be negative: with + * the attach amortised, a Python worker's steady-state upcall is no longer reliably more + * expensive than the instrumentation thread's, and formatting the two halves independently + * printed that as "-1999.-50 ns". + */ + private fun Double.ns(): String = "${sign(this)}${fmt(kotlin.math.abs(this))} ns" + + private fun sign(v: Double): String = if (v < 0) "-" else "" + + private fun report( + loopMain: Double, pyMain: Double, upcallMain: Double, threadsMain: Int, + loopWorker: Double, pyWorker: Double, upcallWorkerFirst: Double, upcallWorker: Double, + threadsWorker: Int, + downcalls: Map, + ) { + val downcallBasis = (downcalls[DOWNCALL_CALL] ?: 1.0).coerceAtLeast(1.0) + val trampolineOnly = (downcalls[TRAMPOLINE_ONLY] ?: 1.0).coerceAtLeast(1.0) + + val lines = buildList { + add("") + add("--- Android upcall overhead (API ${Build.VERSION.SDK_INT}, ${Build.SUPPORTED_ABIS.firstOrNull()}) ---") + add("iterations per loop: $N, warmup: $WARMUP") + add("") + add("Python-driven, instrumentation thread (ART already knows it)") + add(" empty Python loop ${loopMain.ns()}") + add(" pure-Python callee ${pyMain.ns()}") + add(" upcall to Kotlin ${upcallMain.ns()}") + add(" upcall net of loop ${(upcallMain - loopMain).ns()}") + add(" distinct ART threads over $PROBE_CALLS calls $threadsMain") + add("") + add("Python-driven, threading.Thread (a pthread ART has never seen)") + add(" empty Python loop ${loopWorker.ns()}") + add(" pure-Python callee ${pyWorker.ns()}") + add(" first upcall on the thread ${upcallWorkerFirst.ns()}") + add(" upcall to Kotlin (steady state) ${upcallWorker.ns()}") + add(" upcall net of loop ${(upcallWorker - loopWorker).ns()}") + add(" distinct ART threads over $PROBE_CALLS calls $threadsWorker") + add("") + add("Attach") + add(" worker minus instrumentation thread ${(upcallWorker - upcallMain).ns()}") + add(" amortised across calls? ${if (threadsWorker == 1) "yes" else "no -- one attach/detach per call"}") + add("") + add("Comparison basis, same run") + // See UpcallBoundaryCostTest for why the space is load-bearing: `padEnd` is a floor, and + // the labels longer than it print glued to their value, which loses the row to any + // whitespace-based reader. + for ((name, ns) in downcalls) add(" ${name.padEnd(54)} ${ns.ns()}") + add("") + add("Ratios") + add(" upcall (instrumentation thread) / downcall of same shape ${fmt(upcallMain / downcallBasis)}x") + add(" upcall (worker thread) / downcall of same shape ${fmt(upcallWorker / downcallBasis)}x") + add(" upcall (instrumentation thread) / trampoline alone ${fmt(upcallMain / trampolineOnly)}x") + add(" upcall (worker thread) / trampoline alone ${fmt(upcallWorker / trampolineOnly)}x") + add("-".repeat(72)) + add("") + } + // One println per line: logcat truncates a long single write, and these have to survive + // into the XML the run is read from. + for (line in lines) println(line) + } + + private fun fmt(v: Double): String { + val whole = v.toLong() + val hundredths = ((v - whole) * 100).toLong() + return "$whole.${hundredths.toString().padStart(2, '0')}" + } +} diff --git a/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/UpcallThreadAttachTest.kt b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/UpcallThreadAttachTest.kt new file mode 100644 index 000000000..b8284cc6c --- /dev/null +++ b/python-multiplatform/src/androidInstrumentedTest/kotlin/python/native/ffi/UpcallThreadAttachTest.kt @@ -0,0 +1,448 @@ +package python.native.ffi + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PythonTestFixture +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Android's half of `UpcallEntryTest`'s (`commonTest`) contract resolves through the same + * `_pm_resolve`/`_pm_bind`/`_pm_release` `PyMethodDef` bootstrap as `nativeTest` -- see + * [UpcallEntry.publish] -- but with `androidMain`'s JNI inversion underneath: the entry points are + * C functions in `artMain/cinterop/jni_onload.def` and *they* call *Kotlin*, through + * [UpcallCallbacks]. + * + * The one case only this target has -- an upcall arriving on a thread ART has never seen -- has no + * `commonTest` counterpart (desktop, Kotlin/Native and wasmJs all reach Kotlin without a runtime + * boundary at all, so a `threading.Thread` there is just another thread) and stays here. + */ +actual fun bindUpcallOrNull(name: String): Boolean { + val globals: PyObject = PythonTestFixture.mainGlobals() + check(UpcallEntry.publish(globals.pointer)) { "the upcall bootstrap could not be published" } + Python3.exec("_pm_h = _pm_resolve('$name')") + val resolved = PythonTestFixture.eval("_pm_h").toString() != "-1" + Python3.exec(if (resolved) "_pm_bound = _pm_bind(_pm_h)" else "_pm_bound = None") + return resolved +} + +/** Releases through `_pm_release`, the same `PyMethodDef` a proxy's `tp_dealloc` would call. */ +actual fun releaseUpcallHandle(handle: Long): Int = + PythonTestFixture.eval("_pm_release($handle)").toString().toInt() + +class UpcallThreadAttachTest { + + private companion object { + /** Upcalls per worker, when the question is how many attaches they cost. */ + const val CALLS_PER_WORKER = 64 + + /** Workers alive at the same time in [manyConcurrentWorkersAreEachAttachedOnceAndAllReleased]. */ + const val CONCURRENT_WORKERS = 32 + + /** + * How long a released attachment is waited for. + * + * `threading.Thread.join()` is not the moment the pthread exits: CPython releases the + * join lock in `_bootstrap_inner`'s `finally`, and the thread's TSD destructors -- which + * is where the detach lives -- run after that, on the way out of the pthread. So the + * detach is polled for rather than assumed to have happened by the time `join()` returns. + * A deadline rather than a fixed sleep, so a slow emulator costs time and not a flake. + */ + const val RELEASE_TIMEOUT_MS = 15_000L + } + + @BeforeTest + fun install() { + PythonOnDevice.ensureInitialised() + UpcallTable.install(listOf(ThreadProbeFragment)) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + /** Evaluates [expression] in `__main__` and returns its `str()`. */ + private fun py(expression: String): String = PythonTestFixture.eval(expression).toString() + + private fun pyLong(expression: String): Long = py(expression).toLong() + + private fun pyInt(expression: String): Int = py(expression).toInt() + + /** + * How many of the `java.lang.Thread` peers recorded so far a collection cannot reclaim. + * + * This is the instrument the release assertions read, and it is deliberately *not* + * `ThreadGroup.enumerate`. That was tried first and is unusable: on API 26 it does not report + * the peer of an attached native thread even while that thread is running and upcalling, so + * "the id is not in the list" would have meant nothing and every release assertion would have + * passed vacuously. The positive control in + * [theAttachmentIsReleasedWhenThePythonWorkerThreadDies] is what caught that, and it is kept + * for the same reason on this instrument. + * + * Reachability works on both levels because it reads the thing that actually matters. ART + * holds an attached thread's peer as a GC root (`tlsPtr_.opeer`) and releases it in + * `Thread::Destroy`, so a peer that survives a collection is a peer that is still attached -- + * which is also the cost of keeping one, stated directly. + */ + private fun unreclaimedPeers(): Int = UpcallTarget.liveTrackedCount() + + /** + * Collects until [expected] tracked peers remain, or the deadline passes; returns what was + * actually left. A collection is asked for rather than waited for, because the peer only + * becomes unreachable at detach and nothing else in the process is allocating. + */ + private fun awaitUnreclaimedPeers(expected: Int): Int { + val deadline = System.nanoTime() + RELEASE_TIMEOUT_MS * 1_000_000L + while (true) { + Runtime.getRuntime().gc() + System.runFinalization() + val live = unreclaimedPeers() + if (live <= expected || System.nanoTime() >= deadline) return live + Thread.sleep(50) + } + } + + /** + * The case Android has and no other platform does: an upcall arriving on a thread ART has + * never seen. + * + * Desktop and Kotlin/Native reach Kotlin without a runtime boundary at all, so a + * `threading.Thread` is just another thread there. On ART the entry point has to obtain a + * `JNIEnv` before it can call anything, and `GetEnv` fails on a bare pthread -- which is what + * every `threading.Thread` is. That is the `AttachCurrentThreadAsDaemon` branch of + * `pmp_attach`, and without it every upcall from a Python worker thread would silently return + * `NULL`. + * + * The thread identity is recorded from *inside* the Kotlin the upcall lands in, so a pass + * cannot come from the work having been re-dispatched back onto the instrumentation thread. + */ + @Test + fun anUpcallArrivesOnAThreadCPythonCreatedRatherThanFailingToFindTheJvm() = + PythonTestFixture.withInterpreter { + assertTrue(bindUpcallOrNull("probe.whichThread")) + val instrumentationThread = Thread.currentThread().id + ThreadProbeFragment.lastThreadId = -1L + Python3.exec( + """ + import threading + _pm_worker_result = None + + def _pm_worker(): + global _pm_worker_result + _pm_worker_result = _pm_bound(0) + + _pm_t = threading.Thread(target=_pm_worker) + _pm_t.start() + _pm_t.join() + """.trimIndent(), + ) + + val reported = py("_pm_worker_result").toLong() + assertNotEquals( + -1L, reported, + "the upcall never reached Kotlin from the Python worker thread", + ) + assertEquals( + reported, ThreadProbeFragment.lastThreadId, + "the value Python read back is not the one the upcall recorded", + ) + assertNotEquals( + instrumentationThread, reported, + "the upcall was served on the instrumentation thread, so the attach branch was never taken", + ) + } + + /** + * What actually happens when an attached thread exits without detaching -- the fact the + * per-thread attach is built on, run rather than read. + * + * `jni_onload.def` used to assert, in a comment, that ART aborts the process for this, and + * that assertion is why every upcall from a Python worker paid an attach *and* a detach. + * `Thread::ThreadExitCallback` says otherwise: the `LOG(FATAL)` needs a second invocation of + * the callback, the only thing that arms one is a `pthread_setspecific` inside an `#else` + * branch Android does not compile, and bionic clears a key's value before running its + * destructor. So on Android the abort should be unreachable. Should is not is, and being wrong + * here means shipping a process abort, so the case is executed. + * + * Reaching the assertions at all is the result: this method runs in the same process as the + * thread that leaked, so an abort would take the whole instrumentation run with it, not fail + * one test. + * + * The second half is why the detach is still mandatory. Surviving is not the same as being + * free -- the peer stays in the thread list, a GC root with a recorded stack the pthread has + * already unmapped -- so the fix is to move the detach to thread death, not to drop it. + */ + @Test + fun aThreadThatExitsWithoutDetachingLeaksItsPeerRatherThanAbortingArt() { + UpcallTarget.clearTracked() + val leaked = bindings.testAttachWithoutDetach(0L) + assertNotEquals( + -1L, leaked, + "the probe thread never attached, so it cannot say anything about exiting attached", + ) + // Reaching here at all is the first result: ART logged its warning and carried on, so the + // abort the old comment promised is not what happens and the detach can be moved. + assertEquals( + 1, awaitUnreclaimedPeers(0), + "the peer of a pthread that exited without detaching became reclaimable on its own, " + + "so ART released the attachment unprompted and pmp_thread_exit_detach is buying " + + "nothing -- which is worth knowing, because it is not what Thread::Destroy does", + ) + UpcallTarget.clearTracked() + } + + /** + * How many attaches N upcalls from one Python worker cost. + * + * `AttachCurrentThreadAsDaemon` builds a fresh `java.lang.Thread` for the pthread, and + * `Thread.getId()` comes from a counter that is never reused, so the identity of the thread + * the Kotlin side lands on is a direct count of the attaches: N distinct ids means one attach + * per call, one id means one per thread. That is what makes this structural rather than a + * stopwatch reading -- `UpcallOverheadTest` prices the difference, this decides it. + * + * Two workers, not one, because "one id" on its own has a second explanation: a *shared* + * attachment, e.g. an env cached in a global rather than in thread-local storage, which would + * be a correctness bug (one thread's JNI local frame handed to another). Two workers must + * report two different ids, and neither may be the instrumentation thread's. + */ + @Test + fun aPythonWorkerThreadIsAttachedOncePerThreadRatherThanOncePerCall() = + PythonTestFixture.withInterpreter { + assertTrue(bindUpcallOrNull("probe.whichThread")) + val instrumentationThread = Thread.currentThread().id + Python3.exec( + """ + import threading + + _pm_ids_a = [] + _pm_ids_b = [] + + def _pm_collect(sink): + for _ in range($CALLS_PER_WORKER): + sink.append(_pm_bound(0)) + + _pm_ta = threading.Thread(target=_pm_collect, args=(_pm_ids_a,)) + _pm_ta.start(); _pm_ta.join() + _pm_tb = threading.Thread(target=_pm_collect, args=(_pm_ids_b,)) + _pm_tb.start(); _pm_tb.join() + """.trimIndent(), + ) + + val distinctA = pyInt("len(set(_pm_ids_a))") + val distinctB = pyInt("len(set(_pm_ids_b))") + val idA = pyLong("_pm_ids_a[0]") + val idB = pyLong("_pm_ids_b[0]") + + assertEquals( + 1, distinctA, + "$CALLS_PER_WORKER upcalls from one Python worker landed on $distinctA distinct " + + "ART threads, so the attach is still being paid per call rather than per thread", + ) + assertEquals( + 1, distinctB, + "the second worker's $CALLS_PER_WORKER upcalls landed on $distinctB distinct ART threads", + ) + assertNotEquals( + idA, idB, + "two different Python worker threads reported the same ART thread, so the " + + "attachment is shared between them rather than held per thread", + ) + assertNotEquals( + instrumentationThread, idA, + "the upcall was served on the instrumentation thread, so nothing was attached at all", + ) + } + + /** + * The other half of amortising the attach: it has to be given back when the pthread dies. + * + * Keeping an attachment costs an ART `Thread`, a `java.lang.Thread` peer that is a GC root, a + * JNI local reference table and an entry in the thread list that every `SuspendAll` walks. + * None of that is reclaimed by the pthread exiting on its own -- ART only logs + * *"Native thread exiting without having called DetachCurrentThread"* and hands the runtime's + * own `Thread` back to itself, leaving the peer live and its recorded stack pointing at memory + * the pthread has already unmapped. So a per-thread attach is only safe if something detaches + * at thread death, and this is that claim, asserted rather than argued: the peers the + * finished workers were attached to must leave the thread list. + */ + @Test + fun theAttachmentIsReleasedWhenThePythonWorkerThreadDies() = + PythonTestFixture.withInterpreter { + assertTrue(bindUpcallOrNull("probe.trackThread")) + UpcallTarget.clearTracked() + + // The worker upcalls once and then parks, so it is observably attached *while* the + // instrument is being read. Without this control, "the peer was reclaimed" has a + // second explanation -- that the peer was never held in the first place -- and the + // release assertions below would pass on an instrument that cannot see anything. That + // is not hypothetical: it is exactly how the first version of this test, which read + // ThreadGroup.enumerate, passed on API 26 while measuring nothing. + Python3.exec( + """ + import threading + + _pm_go = threading.Event() + _pm_ready = threading.Event() + _pm_id_held = [] + + def _pm_hold_open(): + _pm_id_held.append(_pm_bound(0)) + _pm_ready.set() + _pm_go.wait() + + _pm_th = threading.Thread(target=_pm_hold_open) + _pm_th.start() + _pm_ready.wait() + """.trimIndent(), + ) + val held = pyLong("_pm_id_held[0]") + assertEquals( + 1, awaitUnreclaimedPeers(0), + "the peer of ART thread $held was reclaimed while its Python worker is still " + + "running and attached, so this test cannot tell a released attachment from " + + "an unheld one and neither assertion below means anything", + ) + + Python3.exec("_pm_go.set()\n_pm_th.join()") + assertEquals( + 0, awaitUnreclaimedPeers(0), + "the peer of ART thread $held was still unreclaimable ${RELEASE_TIMEOUT_MS}ms " + + "after its Python worker finished, so the attachment outlives the pthread", + ) + + // And again over a run of threads, because one detach working is not the same as the + // destructor being armed on every attach. + UpcallTarget.clearTracked() + Python3.exec( + """ + _pm_ids_seq = [] + + def _pm_touch(sink): + sink.append(_pm_bound(0)) + + for _ in range(8): + _t = threading.Thread(target=_pm_touch, args=(_pm_ids_seq,)) + _t.start(); _t.join() + """.trimIndent(), + ) + assertEquals( + 8, pyInt("len(set(_pm_ids_seq))"), + "the eight workers did not land on eight distinct ART threads", + ) + assertEquals( + 0, awaitUnreclaimedPeers(0), + "of eight finished Python workers, some ART peers were still unreclaimable " + + "${RELEASE_TIMEOUT_MS}ms later, so the detach is not armed on every attach", + ) + } + + /** + * The scale question a per-thread attach raises: what a *pool* of Python workers costs while + * they are all alive at once. + * + * Peak simultaneous attachments are the same either way -- per-call attaching still has every + * concurrently-upcalling thread attached at the same instant -- so what changes is how long + * each one is resident, which is what makes "hundreds of Python workers" worth checking rather + * than assuming. [CONCURRENT_WORKERS] threads are held alive together on a barrier, each with + * its own attachment, and then all of them have to be released. + */ + @Test + fun manyConcurrentWorkersAreEachAttachedOnceAndAllReleased() = + PythonTestFixture.withInterpreter { + assertTrue(bindUpcallOrNull("probe.trackThread")) + UpcallTarget.clearTracked() + Python3.exec( + """ + import threading + + _pm_lock = threading.Lock() + _pm_ids_many = [] + _pm_gate = threading.Barrier($CONCURRENT_WORKERS) + + def _pm_hold(): + ids = set() + for _ in range(8): + ids.add(_pm_bound(0)) + _pm_gate.wait() # every worker is attached at this instant + for _ in range(8): + ids.add(_pm_bound(0)) + with _pm_lock: + _pm_ids_many.append((len(ids), ids.pop())) + + _pm_pool = [threading.Thread(target=_pm_hold) for _ in range($CONCURRENT_WORKERS)] + for _t in _pm_pool: _t.start() + for _t in _pm_pool: _t.join() + """.trimIndent(), + ) + + assertEquals( + CONCURRENT_WORKERS, pyInt("len(_pm_ids_many)"), + "not every worker completed its upcalls", + ) + assertEquals( + 1, pyInt("max(n for n, _ in _pm_ids_many)"), + "a worker saw more than one ART thread across the barrier, so its attachment did " + + "not survive being contended by ${CONCURRENT_WORKERS - 1} others", + ) + assertEquals( + CONCURRENT_WORKERS, pyInt("len(set(i for _, i in _pm_ids_many))"), + "two workers shared one ART thread", + ) + + assertEquals( + 0, awaitUnreclaimedPeers(0), + "of $CONCURRENT_WORKERS finished Python workers, some ART peers were still " + + "unreclaimable ${RELEASE_TIMEOUT_MS}ms later, so a pool of workers accumulates " + + "attachments rather than giving them back", + ) + } +} + +/** + * An exposed callable that reports the JVM thread it was executed on. + * + * Test-owned, and that is the point: nothing in production code needs a hook for this, because the + * lambda *is* where the upcall lands. `CycleCollectionTest.TraverseThreadProbe` records the same + * fact the same way for `tp_traverse`. + * + * Without it, "the worker's result came back" would only show the call completed, not that it was + * served on a thread ART had never seen -- which is the whole claim `pmp_attach`'s + * `AttachCurrentThreadAsDaemon` branch exists to support on this path. + */ +object ThreadProbeFragment : python.multiplatform.reflection.FunctionTableFragment { + override val moduleName: String = "test_android_upcall_probe" + + @Volatile + var lastThreadId: Long = -1L + + override fun entries(): List = listOf( + python.multiplatform.reflection.ExposedCallable( + name = "probe.whichThread", + arity = 1, + paramTypes = listOf(python.multiplatform.reflection.TypeTag.INT), + returnType = python.multiplatform.reflection.TypeTag.INT, + ) { + Thread.currentThread().id.also { lastThreadId = it } + }, + /** + * The same reading, plus a weak record of the peer the upcall landed on, so a later + * collection can be asked whether that attachment is still held. Delegates to + * [UpcallTarget] rather than keeping a second list, because the JNI probe for a thread + * that never detaches has to reach the same one from C. + */ + python.multiplatform.reflection.ExposedCallable( + name = "probe.trackThread", + arity = 1, + paramTypes = listOf(python.multiplatform.reflection.TypeTag.INT), + returnType = python.multiplatform.reflection.TypeTag.INT, + ) { + UpcallTarget.trackCurrentThread().also { lastThreadId = it } + }, + ) +} diff --git a/python-multiplatform/src/androidMain/README.md b/python-multiplatform/src/androidMain/README.md new file mode 100644 index 000000000..5e6a03298 --- /dev/null +++ b/python-multiplatform/src/androidMain/README.md @@ -0,0 +1,274 @@ +# androidMain — rules + +The JVM half of Android. Reaches CPython through JNI into the Kotlin/Native library built from +`artMain` + `nativeMain`. + +## 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 — +`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**: +each decided "already unpacked" by probing one entry of the result, which a copy interrupted +partway through satisfies. See ROADMAP §15f. + +Two rules follow for anything added here: + +- **Do not re-validate `PYTHONHOME`.** `Python3.initialize` runs `PythonHomeCheck` on every call. + This source set *produces* the layout; that check verifies it. A second copy of the rule is a + second thing that can drift from what `Py_Initialize()` actually wants, and + `PythonBootstrapTest.stagedPrefixIsOneThatPythonHomeCheckAccepts` is what keeps the two honest. +- **Completion markers, not result probes.** The stamp goes in after the last byte and comes out + 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 +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 +`openBasedDirectoryDetectionAgreesWithListBased` re-derives both classifications on device on +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 +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]`. + +Kept separate from `stageStdlib` rather than merged into it, for two reasons: + +- **The stdlib is ABI-scoped and the payload is not.** `assets//lib/python` exists per ABI + because of `lib-dynload`'s native extension modules. A resource payload is source and data, + identical on every ABI; giving it an ABI directory ships it once per ABI in the APK. +- **They change on different schedules.** One stamp covering both re-unpacks 800-odd stdlib files + because one application module was edited. + +Same discipline otherwise: the stamp comes out before the rewrite and goes in after the last byte, +and it carries `versionCode` + `lastUpdateTime` so an upgrade restages over a surviving `filesDir`. + +**An app that brings CPython up without `PythonBootstrap.initialize` gets no payload.** Reading an +APK's assets needs a `Context` and `Python3.initialize` has none, so staging and installing are two +steps: `PythonBootstrap` does the half that needs a `Context`, exactly as it already does for +`PYTHONHOME`. + +## Bind through `RegisterNatives`, not symbol names + +`JNI_OnLoad` (in `artMain`) registers CPython's own C functions directly against the +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 + 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 + +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. + +There are exactly four deliberate exceptions and they are named in ROADMAP §2. Adding a fifth +means writing down why. + +Two mechanical rules fall out of this: + +- **Pointers cross as `Long`, never as `JNIPointer?`.** A nullable `Long` is a boxed + `java.lang.Long` on the JVM, and no C function taking `jlong` can be registered against that + descriptor. Return `Long` and convert with `.toNativePointer()` on the Kotlin side. +- **Do not define a wrapper twice.** cinterop rejects the redefinition, and the error points at + the `.def` rather than at the merge that caused it. Before adding one: + + ```bash + 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 +source, because the descriptor the JVM will match on is the one in the bytecode: + +```bash +javap -p -s build/tmp/kotlin-classes/debug/python/native/ffi/bindings.class +``` + +## Pick the calling convention per API level *and* per function + +Two independent axes. Correctness first. + +**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 — +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 +floor, ns/call: + +| API | `@CriticalNative` | `@FastNative` | ordinary JNI | +|---|---|---|---| +| 26 | **1.86** | 38.69 | 45.33 | +| 30 | **-0.24** | 31.76 | 73.09 | +| 31 | **-0.02** | 24.29 | 45.97 | +| 33 | **-0.04** | 2.03 | 7.82 | +| 34 | 24.35 | **1.97** | 7.81 | +| 36 (hardware) | 44.05 | **3.55** | 18.32 | + +Hence `bindings.preferFastNative = SDK_INT >= 34`. `dispatchPicksTheFasterConventionOnThisDevice` +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 +worth, relative to leaving it ordinary: + +| API | ordinary → `@FastNative` | ordinary → `@CriticalNative` | +|---|---|---| +| 26 | −6.64 | −43.47 | +| 30 | −41.33 | −73.33 | +| 31 | −21.68 | −45.99 | +| 33 | −5.79 | −7.86 | +| 34 | −5.84 | **+16.54** | +| 36 | −14.77 | **+25.73** | + +`@FastNative` is a win on every level measured. `@CriticalNative` is the larger win below 34 and a +**loss** above it, so it is only ever correct behind the `preferFastNative` branch, never pinned. +That is the opposite of what a promotion audit written from ART's documentation will tell you. + +## Choosing a convention for a new call site + +Ordinary JNI is the default. A promotion has to survive all five steps. + +**1. Can it drop a reference?** → ordinary. Stop. + +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 +`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. + +**2. Can it allocate a GC-tracked container, or reach a Python-level hook?** → ordinary. + +Allocating a tuple, list, dict, set or instance can cross the collection threshold, and the cyclic +collector runs `__del__` on what it reclaims. Hooks reached by ordinary-looking calls: `__getattr__` +and descriptor `__get__` on any attribute access, `__hash__`/`__eq__` on any dict or set operation +*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 +thread, and is not the struct read its name implies. + +**3. Can it block?** → ordinary. `PyGILState_Ensure`, `PyEval_RestoreThread`. A thread waiting for +a lock under a GC-blocking convention never reaches a safepoint, so the JVM collector stalls behind +whatever holds the GIL. + +**4. Everything that is left can still raise.** → ordinary unless the win has been *measured* on +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 +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. + +**5. If promoted, register both twins and branch on `preferFastNative`.** Never pin one convention. +The C wrapper for the `@FastNative` twin is the same body with a leading `JNIEnv*, jclass`; the +`@CriticalNative` one must have neither. See `artMain/cinterop/jni_onload.def`. + +### The five steps are enforced, not just written down + +`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. + +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 + 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 + 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 + "unresolved entries: none" put three re-entrant functions on its safe-to-promote list. + +The counts are printed by the run rather than written here, for the reason this test exists: the +audit document classified 71 registrations, its own follow-up re-read the table at 187, and the table +holds 366 today. + +### Why the default is ordinary + +| guessed wrong toward | costs | +|---|---| +| 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. + +## The boundary carries primitives only + +`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 +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 +plus `GetStringUTFChars` plus malloc plus copy plus free. + +So string-carrying operations are the ones worth composing, regardless of how much real work +they do. + +## `System.gc()` does not collect on Android + +Measured on both emulators, reproduced on three consecutive runs (`GCProbe` in +`androidInstrumentedTest/.../GCLeakTest.androidInstrumented.kt` logs it every run): + +``` +bare System.gc(): canary cleared = false after 20 attempts +Runtime.gc() + System.runFinalization(): canary cleared = true after 1 attempt +``` + +libcore's `System.gc()` records a request and defers the collection until the next +`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 +`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. + +## The instrumentation runner must park the GIL, not hold it + +`Py_Initialize()` returns with the GIL held by its caller. `Python3.initialize()` parks that +thread state with `PyEval_SaveThread()` immediately afterwards, and that parking is what lets a +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` +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. + +Two consequences worth knowing, because neither looks like a GIL problem: + +- Cleaners block on their first `PyGILState_Ensure` and stay blocked, so the *delta* counters + in `GCLeakTest` read zero. `ranDelta > 0 && releasedDelta == 0` only catches a cleaner that + 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 — + `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/dalvik/annotation/optimization/CriticalNative.kt b/python-multiplatform/src/androidMain/kotlin/dalvik/annotation/optimization/CriticalNative.kt new file mode 100644 index 000000000..3939b5443 --- /dev/null +++ b/python-multiplatform/src/androidMain/kotlin/dalvik/annotation/optimization/CriticalNative.kt @@ -0,0 +1,29 @@ +package dalvik.annotation.optimization + +/** + * Local declaration of ART's @CriticalNative. The public SDK only exposes it from API 34, but + * ART matches the annotation by fully-qualified name at runtime, so declaring it here works on + * every level that honours it. + * + * A @CriticalNative method receives ONLY its declared arguments -- no JNIEnv*, no jclass -- and + * ART skips the GC thread-state transition. It must therefore be static, take and return only + * primitives, and must never call back into the JVM or block. + * + * Google advises binding these through RegisterNatives rather than name-based linking before + * Android 12, which is what JNI_OnLoad does here. Verified on API 26 (the earliest level that + * honours the annotation, and this project's minSdk) and on API 34. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.BINARY) +annotation class CriticalNative + +/** + * Local declaration of ART's @FastNative, kept alongside @CriticalNative for benchmarking. + * + * @FastNative still receives JNIEnv* and jclass -- so it may touch the JVM -- but skips the GC + * thread-state transition. It sits between ordinary JNI and @CriticalNative in cost, and is the + * right choice for a native call that must use JNIEnv yet stays short. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.BINARY) +annotation class FastNative diff --git a/python-multiplatform/src/androidMain/kotlin/python/multiplatform/env/PackagedPythonHome.android.kt b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/env/PackagedPythonHome.android.kt new file mode 100644 index 000000000..8c1f08419 --- /dev/null +++ b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/env/PackagedPythonHome.android.kt @@ -0,0 +1,4 @@ +package python.multiplatform.env + +/** No-op: `PythonBootstrap` stages the stdlib and sets `PYTHONHOME` with `Os.setenv`. */ +internal actual fun applyPackagedPythonHome() = Unit diff --git a/python-multiplatform/src/androidMain/kotlin/python/multiplatform/env/PythonBootstrap.kt b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/env/PythonBootstrap.kt new file mode 100644 index 000000000..75b418557 --- /dev/null +++ b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/env/PythonBootstrap.kt @@ -0,0 +1,408 @@ +package python.multiplatform.env + +import android.content.Context +import android.content.res.AssetManager +import android.system.Os +import python.multiplatform.Versions +import python.multiplatform.ffi.Python3 +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException + +/** + * Brings CPython up on Android in one call. + * + * Android is the only platform where `Python3.initialize()` is not the whole story. The library's + * AAR ships the interpreter under `jni//`, which the linker loads out of the APK, and the + * standard library as `assets//lib/python/`, which `Py_Initialize()` **cannot** read: it + * opens stdlib modules with ordinary `open(2)` against a filesystem path, and an APK's asset + * archive is not one. So the tree has to be unpacked to app-private storage once, and `PYTHONHOME` + * has to name the *prefix* -- the directory holding `lib/python`, not that directory itself. + * + * ```kotlin + * class MainActivity : ComponentActivity() { + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * PythonBootstrap.initialize(this) + * // CPython is up; the object model is usable from here. + * } + * } + * ``` + * + * This existed as a documented recipe before it existed as code, and the recipe was copied three + * times -- into `sample`'s `MainActivity`, into the external consumer app that verified the + * published AAR (ROADMAP §15d), and into this library's own `PythonOnDevice` test fixture. All + * three agreed on the copying and **disagreed on when to skip it**, which is the part that is + * actually hard: see [stageStdlib]. + * + * ### Relationship to [PythonHomeCheck] + * + * There is deliberately no `PYTHONHOME` validation here. [Python3.initialize] runs + * [PythonHomeCheck] on every call, and a second implementation of the same rule is a second thing + * that can drift from the layout `Py_Initialize()` actually wants. This class *produces* the + * layout; that check *verifies* it, on the value this class set. `PythonBootstrapTest` runs one + * against the other rather than asserting each separately. + */ +object PythonBootstrap { + + /** + * Stages the standard library if needed, points `PYTHONHOME` at [prefix], and starts the + * interpreter. + * + * Idempotent and safe to call from every entry point an app has -- a second call re-checks the + * stamp (two `stat` calls and a short file read) and returns without touching the interpreter. + * + * @param prefix where the stdlib tree lives, and the value `PYTHONHOME` is given. Defaults to + * `filesDir`, which is app-private, survives upgrades, and is not subject to the scoped + * storage rules that make external directories unreadable to `open(2)` without a content + * resolver. + * @param silent suppresses [Python3.initialize]'s success line on stdout. + * @return what staging did, for a host app that wants to log or display it. + */ + @JvmOverloads + fun initialize( + context: Context, + prefix: File = context.filesDir, + silent: Boolean = false, + ): Staging { + val staging = stageStdlib(context, prefix) + // CPython reads PYTHONHOME through getenv(3), from the process environment -- not from a + // JVM system property, which it has no way to see. On Android the single-argument + // System.getenv delegates to this same native environment (unlike the JVM's cached no-arg + // map), which is why PythonHomeCheck can read back what is set here. + Os.setenv(PYTHONHOME, staging.prefix.absolutePath, true) + // `tempfile` asks `os.environ` for TMPDIR/TEMP/TMP, then falls back to `/tmp`, `/var/tmp`, + // `/usr/tmp` and finally `os.getcwd()`. None of those hardcoded paths exist inside an app + // sandbox and the cwd of an Android process is `/`, so with nothing set every candidate is + // refused and `tempfile.mkstemp()` raises `[Errno 2] No usable temporary directory found`. + // That is not a test-only inconvenience: `tempfile` is what the standard library itself + // reaches for, so an embedder's Python code hits it the first time it needs a scratch file. + // + // The cache directory rather than filesDir, because that is the one Android is allowed to + // reclaim under storage pressure, which is what a temporary directory is for. Set through + // the same `Os.setenv` PYTHONHOME uses and for the same reason: CPython reads it with + // getenv(3) and cannot see a JVM system property. Not overwritten if the host app already + // set one -- an embedder that pointed TMPDIR somewhere deliberately keeps it. + if (Os.getenv(TMPDIR).isNullOrEmpty()) { + val cache = context.cacheDir.also { it.mkdirs() } + Os.setenv(TMPDIR, cache.absolutePath, true) + } + // Unpacked and *registered* here, put on sys.path by Python3.initialize below. The split + // is not a preference: reading assets needs this Context and sys.path does not exist until + // Py_Initialize() has run, so the two halves cannot happen at the same moment. See + // PythonPayload. + payloadStaging = stagePayload(context, prefix) + Python3.initialize(silent = silent) + return staging + } + + /** + * What the last [stagePayload] did, for a host app that wants to log it. Null until + * [initialize] or [stagePayload] has run, and after either of them when the APK carries no + * `assets/python/`. + */ + @Volatile + var payloadStaging: PayloadStaging? = null + private set + + /** + * Unpacks `assets/python/` into `/python/` and registers it as a `sys.path` entry for + * [Python3.initialize] to install, unless a stamp says the same build already did. + * + * This is the *consumer's own Python code* -- `pypackpack`'s `ResourceBundler` payload, staged + * into the APK by `toolchain`'s `stagePythonBundleAndroid`. It is a different tree from the one + * [stageStdlib] unpacks and is deliberately kept separate rather than merged into it: + * + * - **The stdlib is ABI-scoped and this is not.** `assets//lib/python` exists once + * per ABI because it carries `lib-dynload`'s native extension modules. A resource payload is + * pure data and source, identical on every ABI, and giving it an ABI directory would ship it + * as many times as the APK has ABIs. + * - **They change on different schedules.** The stdlib changes when this library's version + * does; the payload changes on every edit to the app's own Python. One stamp covering both + * would re-unpack 800-odd stdlib files because one application module was edited. + * + * Same discipline as [stageStdlib] otherwise, and for the same reason recorded there: the stamp + * is removed before the rewrite and written after the last byte, so an interrupted unpack + * cannot be mistaken for a finished one. + * + * @return null when the APK has no `assets/python/`, which is the normal state of an app that + * has not configured a Python package. + */ + @JvmOverloads + @Synchronized + fun stagePayload(context: Context, prefix: File = context.filesDir): PayloadStaging? { + val started = System.nanoTime() + val root = PythonPayload.PAYLOAD_ROOT + if (context.assets.list(root).isNullOrEmpty()) return null + + val payloadDir = File(prefix, root) + val stamp = expectedStamp(context, PAYLOAD_ABI_PLACEHOLDER) + val stampFile = payloadStampFile(prefix) + + if (stampFile.takeIf { it.isFile }?.readText() == stamp) { + AndroidPayloadRoots.register(payloadDir) + return PayloadStaging(payloadDir, unpacked = false, fileCount = 0, bytes = 0L, + elapsedMillis = elapsedMillis(started)) + } + + stampFile.delete() + payloadDir.deleteRecursively() + if (!payloadDir.mkdirs() && !payloadDir.isDirectory) { + throw IOException("could not create $payloadDir to unpack the Python payload into") + } + + val counter = Counter() + copyAssetTree(context.assets, root, payloadDir, ByteArray(COPY_BUFFER_BYTES), counter) + stampFile.writeText(stamp) + + AndroidPayloadRoots.register(payloadDir) + return PayloadStaging(payloadDir, unpacked = true, fileCount = counter.files, + bytes = counter.bytes, elapsedMillis = elapsedMillis(started)) + } + + /** The stamp [stagePayload] writes last. Public so a host app can implement "re-unpack next launch". */ + fun payloadStampFile(prefix: File): File = File(prefix, PAYLOAD_STAMP_NAME) + + /** What [stagePayload] did. */ + data class PayloadStaging( + /** `/python`, the directory that goes on `sys.path`. */ + val payloadDir: File, + /** False when a matching stamp was found and nothing was written. */ + val unpacked: Boolean, + /** Files written, zero when [unpacked] is false. */ + val fileCount: Int, + /** Bytes written, zero when [unpacked] is false. */ + val bytes: Long, + /** Wall time for this call, including the stamp check that decided to skip. */ + val elapsedMillis: Long, + ) + + /** + * Unpacks `assets//lib/python/` into `/lib/python/`, unless a stamp + * says the same build already did. + * + * ### Why a stamp rather than a probe + * + * The three hand-written copies this replaces each decided "already unpacked" by probing one + * entry of the result -- `target.list()` non-empty in one, `encodings/` being a directory in + * the other two. Both are true of a tree whose copy was interrupted: the app was killed, the + * device ran out of space, the user swiped it away during a cold start. The next launch then + * accepts a partial stdlib, and the failure surfaces as an `ImportError` for whichever module + * happened to sort after the interruption -- nowhere near the cause. + * + * The stamp is written **after** the last byte of the last file, so it cannot be true of a + * partial tree, and it names the build rather than just the Python version: an APK upgrade + * restages assets while `filesDir` survives the install, so "same Python version" is not + * enough to conclude "same files". Its content is + * `python-multiplatform `. + * + * No checksum: hashing the 804 files to decide whether to write the 804 files costs a large + * fraction of just writing them, and the thing it would protect against -- someone editing the + * staged tree in app-private storage -- is not a failure mode of this library. + * + * @return a [Staging] whose [Staging.unpacked] is false when the stamp matched and nothing was + * written. + */ + @JvmOverloads + @Synchronized + fun stageStdlib(context: Context, prefix: File = context.filesDir): Staging { + val started = System.nanoTime() + val relative = stdlibRelativePath() + val stdlibDir = File(prefix, relative) + val abi = assetAbiDirectory(context.assets) + val stamp = expectedStamp(context, abi) + + if (stampFile(prefix).takeIf { it.isFile }?.readText() == stamp) { + return Staging(prefix, stdlibDir, abi, unpacked = false, fileCount = 0, bytes = 0L, + elapsedMillis = elapsedMillis(started)) + } + + // Order matters: the stamp goes first and comes back last. A stamp left in place while + // the tree is being rewritten is exactly the "accepted a partial tree" failure this whole + // mechanism exists to prevent -- it would be a *valid* stamp describing files that are + // currently half-deleted. + stampFile(prefix).delete() + // Whatever is there is either from another build or unfinished; neither can be merged + // into safely, because a file that used to exist and no longer does would survive. + stdlibDir.deleteRecursively() + if (!stdlibDir.mkdirs() && !stdlibDir.isDirectory) { + throw IOException("could not create $stdlibDir to unpack the CPython standard library into") + } + + val counter = Counter() + copyAssetTree(context.assets, "$abi/$relative", stdlibDir, ByteArray(COPY_BUFFER_BYTES), counter) + if (counter.files == 0) { + throw IOException( + "assets/$abi/$relative is empty, so no CPython standard library was staged. The " + + "library module's copyAndroidPythonAssets task stages it under the ABI " + + "directory; check that the configured version (${Versions.currentVersion}) " + + "matches what was extracted into the AAR." + ) + } + stampFile(prefix).writeText(stamp) + + return Staging(prefix, stdlibDir, abi, unpacked = true, fileCount = counter.files, + bytes = counter.bytes, elapsedMillis = elapsedMillis(started)) + } + + /** + * `lib/python3.14` -- the layout `Py_Initialize()` looks for under `PYTHONHOME`, and the + * layout `copyAndroidPythonAssets` stages under each ABI directory. + * + * Derived from [Versions] rather than written out, and [Versions.taggedVersionString] rather + * than [Versions.compactVersionString]: the tagged form carries the `t` of a free-threaded + * build. A literal here does not fail loudly on a version bump -- it stops matching the staged + * assets, and the app dies in `onCreate`, which is how the sample once broke on the move to + * 3.14. + */ + fun stdlibRelativePath(): String = "lib/python${Versions.currentVersion.taggedVersionString}" + + /** The stamp [stageStdlib] writes last. Public so a host app can implement "re-unpack next launch". */ + fun stampFile(prefix: File): File = File(prefix, "$STAMP_NAME.${Versions.currentVersion.taggedVersionString}") + + /** + * The asset directory whose stdlib matches this process's ABI. + * + * Chosen by intersecting `Build.SUPPORTED_ABIS` -- which is ordered most-preferred first, and + * for a 64-bit process starts with the one the interpreter's own `.so` was loaded for -- with + * what is actually present in the merged assets. Intersecting rather than assuming is what + * turns "this AAR was repackaged without my ABI" into a message naming both lists instead of + * an empty copy. + */ + fun assetAbiDirectory(assets: AssetManager): String { + val relative = stdlibRelativePath() + val supported = android.os.Build.SUPPORTED_ABIS.orEmpty() + supported.firstOrNull { !assets.list("$it/$relative").isNullOrEmpty() }?.let { return it } + throw IOException( + "no CPython standard library in assets for any ABI this device supports. " + + "Build.SUPPORTED_ABIS=${supported.joinToString()}, looked for /$relative, " + + "assets root contains ${assets.list("")?.joinToString().orEmpty()}" + ) + } + + /** What [stageStdlib] did. */ + data class Staging( + /** The directory `PYTHONHOME` names -- the parent of `lib/python`. */ + val prefix: File, + /** `/lib/python`, where the standard library itself is. */ + val stdlibDir: File, + /** The asset ABI directory the tree came from, e.g. `arm64-v8a`. */ + val abi: String, + /** False when a matching stamp was found and nothing was written. */ + val unpacked: Boolean, + /** Files written, zero when [unpacked] is false. */ + val fileCount: Int, + /** Bytes written, zero when [unpacked] is false. */ + val bytes: Long, + /** Wall time for this call, including the stamp check that decided to skip. */ + val elapsedMillis: Long, + ) + + /** + * Recursive copy, with two departures from the obvious version that are worth the words + * because the obvious version is what all three hand-written copies used. + * + * **Directories are detected by failing to open them, not by listing them.** The obvious form + * calls `assets.list(child)` on every entry to ask "is this a directory", which is a native + * directory lookup per entry across 800-odd entries, *in addition to* the `open()` each file + * needs anyway. Opening first and treating `FileNotFoundException` as "directory" folds the + * classification into work that had to happen regardless, so files cost one native call + * instead of two and only the directories pay for a thrown exception. + * `PythonBootstrapTest.openBasedDirectoryDetectionAgreesWithListBased` asserts the two agree, + * on device, on each API level -- `AssetManager` was reimplemented wholesale in API 28 and + * this behaviour is not in its documented contract. + * + * **One 64 KB buffer for the whole tree.** `InputStream.copyTo` allocates a fresh 8 KB buffer + * per call; at 804 calls that is 804 allocations and eight times as many read/write pairs on + * the files that dominate the byte count (the `lib-dynload` extension modules, the `.pyc` + * caches). + * + * Together they are worth roughly 2x, measured on both emulators by + * `PythonBootstrapTest.optimisedCopyIsMeasuredAgainstTheHandWrittenOne`: 192 ms -> 124 ms on + * API 26, 324 ms -> 150 ms on API 36, for the same 804 files. + */ + private fun copyAssetTree( + assets: AssetManager, + from: String, + to: File, + buffer: ByteArray, + counter: Counter, + ) { + val entries = assets.list(from) ?: return + for (entry in entries) { + val source = "$from/$entry" + val destination = File(to, entry) + + val input = try { + assets.open(source, AssetManager.ACCESS_STREAMING) + } catch (_: FileNotFoundException) { + // Not an error: AssetManager.open() only opens files, so this is how a directory + // announces itself. A genuinely missing entry cannot occur here -- `entry` came + // from list() one line above. + destination.mkdirs() + copyAssetTree(assets, source, destination, buffer, counter) + continue + } + + input.use { stream -> + FileOutputStream(destination).use { output -> + while (true) { + val read = stream.read(buffer) + if (read <= 0) break + output.write(buffer, 0, read) + counter.bytes += read + } + } + } + counter.files++ + // The extension modules under lib-dynload are dlopen()ed from this tree by CPython's + // own importer. + if (entry.endsWith(".so")) destination.setExecutable(true) + } + } + + /** + * Identifies the build whose assets were staged. + * + * `lastUpdateTime` is what makes this correct across an app upgrade: `filesDir` survives the + * install, so a stale tree from the previous APK is still sitting there when the new one first + * runs, and its Python version may well be identical. `versionCode` alone is not enough + * either -- a debug build installed over itself by the IDE keeps the same code. + */ + private fun expectedStamp(context: Context, abi: String): String { + val info = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0) + }.getOrNull() + @Suppress("DEPRECATION") + val code = info?.versionCode ?: -1 + val updated = info?.lastUpdateTime ?: -1L + return "python-multiplatform ${Versions.currentVersion} $abi $code $updated" + } + + private fun elapsedMillis(startedNanos: Long) = (System.nanoTime() - startedNanos) / 1_000_000 + + private class Counter { + var files = 0 + var bytes = 0L + } + + private const val PYTHONHOME = "PYTHONHOME" + private const val TMPDIR = "TMPDIR" + private const val STAMP_NAME = ".python-multiplatform-stdlib" + private const val PAYLOAD_STAMP_NAME = ".python-multiplatform-payload" + + /** + * Stands in for the ABI in the payload's stamp. The payload is not ABI-scoped (see + * [stagePayload]), but the stamp shares [expectedStamp] with the stdlib's so that the two + * cannot drift on the parts that *do* matter -- the library version, the version code and + * `lastUpdateTime`, which is what makes an upgrade restage. + */ + private const val PAYLOAD_ABI_PLACEHOLDER = "payload" + + /** + * 64 KB. Large enough that the `lib-dynload` extension modules -- the biggest files in the + * tree -- cross in a handful of reads, small enough to stay off the large-object path. + */ + private const val COPY_BUFFER_BYTES = 64 * 1024 +} 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 new file mode 100644 index 000000000..2c4045920 --- /dev/null +++ b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/env/PythonPayload.android.kt @@ -0,0 +1,43 @@ +package python.multiplatform.env + +import java.io.File + +/** + * Android's answer to "where did the payload land": `assets/python/` in the APK. + * + * `toolchain`'s `stagePythonBundleAndroid` registers `build/pythonStaging/android` as an AGP asset + * source root, so the payload merges into the APK's assets under `python/`. Like the standard + * library that `PythonBootstrap.stageStdlib` unpacks out of `assets//`, this is **not a + * filesystem path**: `AssetManager` reads it out of the archive, and `Py_Initialize()`'s importer + * opens modules with `open(2)`. So the same trick applies, and [PythonBootstrap.stagePayload] is + * that trick with the same stamp discipline. + * + * ### Why discovery here is a registry rather than a scan + * + * 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 + * `PYTHONHOME`: it stages, sets the variable, and only then calls `Python3.initialize()`. + * + * An app that brings CPython up without going through `PythonBootstrap` gets no payload, and that + * is visible rather than silent: nothing on `sys.path`, `ModuleNotFoundError` on the first import + * of its own package. There is no `Context`-free way to read an APK's assets, so the alternative + * would be a `ContentProvider`-style ambient context grab, which is a worse thing to owe. + */ +internal actual fun discoverStagedPayloadRoots(): List = AndroidPayloadRoots.registered() + +/** The roots [PythonBootstrap.stagePayload] unpacked, in registration order. */ +internal object AndroidPayloadRoots { + + private val roots = mutableListOf() + + @Synchronized + fun register(directory: File) { + val path = directory.absolutePath + if (!roots.contains(path)) roots.add(path) + } + + @Synchronized + fun registered(): List = roots.toList() +} diff --git a/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ffi/ProxyTypeFactory.kt b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ffi/ProxyTypeFactory.kt new file mode 100644 index 000000000..af64544df --- /dev/null +++ b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ffi/ProxyTypeFactory.kt @@ -0,0 +1,192 @@ +package python.multiplatform.ffi + +import python.multiplatform.reflection.ClassLookup +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.ObjectReference +import python.native.ffi.bindings +import python.native.ffi.internedUtf8 + +/** + * The Kotlin half of the proxy type's `tp_traverse`, `tp_clear` and `tp_dealloc`. + * + * ### Why this shape and not the desktop one + * + * Desktop turns two Kotlin functions into C function pointers with Panama upcall stubs, and + * Kotlin/Native does the same with `staticCFunction`. Android has neither: `androidMain` is + * Kotlin/JVM, and the JVM it runs on is ART, which has no Panama. So the two slots are C + * functions in `artMain/cinterop/jni_onload.def`, and *they* call *this* -- the direction of the + * boundary is inverted relative to every other platform. + * + * That inversion is why [traverse] returns an array instead of taking a visitor. `visit` is a + * bare C function pointer; there is no way to call one from Kotlin/JVM without a downcall + * mechanism Android does not have. So the C side keeps the visiting and asks only which + * pointers to visit. + * + * ### Rules these two must obey + * + * CPython holds the GIL when it calls the slots, so neither of these may take it ([withGIL]) or + * release it. Neither may call back into the C API at all: `tp_clear` in particular must not + * touch reference counts, because the collector is in the middle of accounting for them and a + * `Py_DecRef` from here double-frees. Releasing the [HandleTable] entry is the whole job -- + * that entry is the Kotlin-side half of the cycle, and dropping it is what breaks the loop. + * + * Both swallow exceptions. A `Throwable` crossing back into a running collection has nowhere to + * go: the C side clears the pending exception because `RegisterNatives`-bound code cannot leave + * one set, and the only channel back into CPython is a return value that aborts the traversal + * rather than reporting anything. + * + * ### Overhead + * + * One JNI upcall per traversed proxy per collection, plus one `LongArray` allocation, plus the + * boxing `ReflectedClass.traverse`'s `(Long) -> Unit` visitor forces on the JVM. Desktop pays + * the boxing too; the upcall and the array are Android's alone. Nothing here is on a hot path + * until an application holds many proxies at once, but a collection sweeping N proxies costs N + * JVM allocations inside CPython's collector, which is where to look first if a GC pause on + * Android turns out worse than on desktop. + */ +object ProxyCallbacks { + + private val EMPTY = LongArray(0) + + /** + * The raw `PyObject*` addresses the Kotlin object behind [handle] holds, for the C side to + * hand to `visit`. + * + * A `null` return is not possible; an empty array means "nothing to report", which is also + * what an unregistered class produces. That silence is by design and is worth knowing + * about: it reads exactly like "the object was never GC-tracked". If a cycle is not + * collected, check that [ClassLookup] has the class before suspecting the slots -- a test + * that registers its class only on the path where it also initialises the interpreter + * registers nothing in a full-suite run, and both desktop and iOS hit that once. + */ + @JvmStatic + fun traverse(handle: Long): LongArray { + return try { + val obj = HandleTable.resolveRaw(handle) ?: return EMPTY + val cls = ClassLookup.find(obj::class.qualifiedName ?: "") ?: return EMPTY + if (!cls.hasTraverse) return EMPTY + + var out = LongArray(4) + var n = 0 + cls.traverse(obj) { raw -> + if (raw != 0L) { + if (n == out.size) out = out.copyOf(out.size * 2) + out[n++] = raw + } + } + if (n == 0) EMPTY else if (n == out.size) out else out.copyOf(n) + } catch (t: Throwable) { + EMPTY + } + } + + /** + * Drops the [HandleTable] root for [handle], and nothing else. + * + * Called from both C slots. The C side zeroes the proxy's handle slot before calling, so an + * object that dies inside a cycle -- which runs `tp_clear` and then `tp_dealloc` -- reaches + * here exactly once. [HandleTable.release] is a no-op for a stale handle anyway, which is + * the backstop that makes a double release safe rather than a slot hijack. + */ + @JvmStatic + fun clear(handle: Long) { + try { + if (handle != 0L) HandleTable.release(ObjectReference(handle)) + } catch (t: Throwable) { + // Same reason as in traverse: there is nowhere for this to go. + } + } +} + +/** + * Android's cycle-collecting proxy type. + * + * The type itself is built in C (`proxy_create_type` in `jni_onload.def`) because its three slots + * have to be C function pointers. This is the Kotlin entry point to that, plus the accessors a + * caller needs to put a handle into a proxy instance. + * + * ### The foreign-thread path is exercised now + * + * The slots used to be reached only from a thread that already runs Kotlin -- the one that called + * `gc.collect()` -- so `pmp_attach`'s `GetEnv` always succeeded and the + * `AttachCurrentThreadAsDaemon` branch beside it was, as far as any test went, dead code. + * + * `CycleCollectionTest.testCycleCollectedOnAThreadCPythonCreated` and + * `testDeallocOnAThreadCPythonCreated` close that. A `threading.Thread` is a bare pthread ART has + * never seen, and those two run the collection, and the last-reference drop, on one. Neither + * infers the thread from the effect: the `traverse` lambda they register records + * `Thread.currentThread().id`, and they assert it differs from the instrumentation thread's -- so + * a callback that had quietly been re-dispatched onto a thread ART already knew would fail rather + * than pass. Green on `pmp_api26` and `pmp_api36`, 216 tests each, 0 failed, with no + * `Native thread exiting without having called DetachCurrentThread` in either logcat. That line + * is what ART prints immediately before aborting if the detach on the way out is ever missed, so + * its absence is the attach/detach balance being checked rather than assumed. + * + * `tp_dealloc` (`pmp_proxy_dealloc` in `jni_onload.def`) closes what used to be the larger hole: + * a proxy that dies without a cycle never runs `tp_clear`, so its [HandleTable] entry leaked -- + * and since cycles are the exception, almost every proxy died that way. All three platforms + * carry the slot, and all three now measure it: + * `CycleCollectionTest.testHandleReleasedWhenProxyDiesWithoutCycle` drops 100 cycle-free proxies + * and checks both the handles and the type's own `ob_refcnt` (through [bindings.obRefCnt] here, + * `sun.misc.Unsafe` on desktop, a `LongVar` load on Kotlin/Native). + */ +actual object ProxyTypeFactory { + + // @Volatile, unlike desktop's plain field: the fast-path read below happens outside withGIL, + // and a non-volatile 64-bit field may be read torn on the JVM. The C side keeps its own + // guard, so a lost race only means PyType_FromSpec is asked twice and answers the same; + // a torn read would hand out a pointer that never existed. + @Volatile + private var proxyTypePtr: Long = 0L + + /** Creates the proxy type once and returns its address; later calls return the same one. */ + actual fun createProxyType(): Long { + if (proxyTypePtr != 0L) return proxyTypePtr + + // Forces ProxyCallbacks' here rather than inside a running collection. The + // class is only ever reached from tp_traverse otherwise, and initialising a JVM class + // for the first time while CPython's collector holds the GIL is a strictly worse place + // for it to happen. resolveRaw(0) is HandleTable's null handle, so this is a no-op. + ProxyCallbacks.traverse(0L) + + proxyTypePtr = withGIL { bindings.proxyCreateType() } + return proxyTypePtr + } + + /** + * Reads the handle stored in [proxy]'s relative type data, or 0. + * + * Returns 0 for anything that is not an instance of the proxy type, and for a proxy whose + * handle `tp_clear` has already zeroed. + */ + fun handleOf(proxy: Long): Long = bindings.proxyGetHandle(proxy) + + /** + * Stores [handle] in [proxy]'s relative type data, which is what makes the Python object + * hold the Kotlin one. + * + * A no-op if [proxy] is not an instance of the proxy type -- there is no error channel here, + * because `PyObject_GetTypeData` on a foreign object has no defined answer to report. + */ + fun setHandle(proxy: Long, handle: Long) = bindings.proxySetHandle(proxy, handle) + + /** + * Publishes [createProxyType]'s type into `__main__` as `_pm_proxy_base`. + * + * `PyObject_SetAttrStringN` takes the type's own address directly as the value -- a + * `PyTypeObject*` is a valid `PyObject*`, its header is the same one every other object here + * has -- so this needs no `ctypes` round trip through an integer and back. `PyObject_SetAttrStringN` + * does not steal the reference it is given; the type is never freed anyway (see + * `proxy_create_type`'s own spec/slots allocations in `jni_onload.def`), so nothing here needs + * an extra incref to keep it alive under `__main__`. + */ + actual fun installGcBase(): Boolean { + val type = createProxyType() + if (type == 0L) return false + return withGIL { + val main = bindings.PyImport_AddModuleN(internedUtf8("__main__")) + if (main == 0L) return@withGIL false + bindings.PyObject_SetAttrStringN(main, internedUtf8("_pm_proxy_base"), type) == 0 + } + } +} diff --git a/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ffi/PyObject.android.kt b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ffi/PyObject.android.kt deleted file mode 100644 index 05f57af05..000000000 --- a/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ffi/PyObject.android.kt +++ /dev/null @@ -1,11 +0,0 @@ -package python.multiplatform.ffi - -import python.native.ffi.NativePointer - - -actual open class PyObject actual constructor(actual override val pointer: NativePointer, borrowed: Boolean): PyObjectAutoCloseable(pointer, borrowed) { - - //actual external fun incRef() - - //... -} diff --git a/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ffi/upcall/UpcallBootstrap.android.kt b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ffi/upcall/UpcallBootstrap.android.kt new file mode 100644 index 000000000..115561164 --- /dev/null +++ b/python-multiplatform/src/androidMain/kotlin/python/multiplatform/ffi/upcall/UpcallBootstrap.android.kt @@ -0,0 +1,11 @@ +package python.multiplatform.ffi.upcall + +import python.multiplatform.ffi.Python3 +import python.native.ffi.UpcallEntry + +actual object UpcallBootstrap { + actual fun publishToGlobals(): Boolean { + val globals = Python3.import("__main__").dict + return UpcallEntry.publish(globals.pointer) + } +} 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 97152caee..c2ebe6f99 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 @@ -2,78 +2,77 @@ package python.multiplatform.ref import android.os.Build import android.os.Build.VERSION.SDK_INT -import python.multiplatform.currentPlatform import python.native.ffi.NativePointer import java.lang.ref.Cleaner -import java.lang.ref.PhantomReference -import java.lang.ref.ReferenceQueue -import java.util.concurrent.ConcurrentHashMap +actual interface PlatformCleaner : AutoCloseable { + actual override fun close() +} -actual abstract class PyAutoCloseable actual constructor(pointer: NativePointer): AutoCloseable { - private val cleaner: Cleaner? - private val cleanable: Cleaner.Cleanable? - private var phantomRef: PhantomCleanupReference? +/** + * The shared `Cleaner`, or `null` on a device that has none. + * + * `java.lang.ref.Cleaner` arrives in **API 33** (Tiramisu). Below that the release goes through + * [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 + * 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. + */ +private val sharedCleaner: Cleaner? = + if (SDK_INT >= Build.VERSION_CODES.TIRAMISU) Cleaner.create() else null - companion object { - private val referenceQueue = ReferenceQueue() - // TODO: activeReferences가 계속 커질 위험이 있음 - // TODO: 성능 오버헤드: 모든 객체마다 HashMap 엔트리 생성 - // TODO: 여러 이유로 TIRAMISU 미만 버전에서는 이 방법 말고 다른 방법으로 객체 소멸을 감지해야할 듯 - private val activeReferences = ConcurrentHashMap Unit>() +actual fun registerCleaner( + pointer: NativePointer, + closeAction: (NativePointer) -> Unit +): PlatformCleaner { + return AndroidCleaner(pointer, closeAction) +} - init { - Thread { - while (true) { - try { - val ref = referenceQueue.remove() as? PhantomCleanupReference - ref?.let { - activeReferences.remove(it)?.invoke() - } - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - break - } - } - }.apply { - isDaemon = true - name = "PyAutoCloseable-Cleaner" - }.start() - } - } +/** + * 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. + */ +private class AndroidCleaner( + pointer: NativePointer, + closeAction: (NativePointer) -> Unit +) : PlatformCleaner { + private val cleanable: Cleaner.Cleanable? + private val fallback: PhantomCleanerRegistry.Cleanable? init { - if (SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - cleaner = Cleaner.create() - cleanable = cleaner.register(this) { - clean() - } - phantomRef = null + // `action` holds the pointer value and a top-level function, and nothing else. It must not + // be able to reach `this`: both mechanisms hold the action strongly while watching `this` + // weakly, so an action that reached back would keep the wrapper alive forever and the + // release would never happen. That is why `CleanupAction` is a class taking the two values + // rather than a lambda written inline here. + val action = CleanupAction(pointer, closeAction) + val cleaner = sharedCleaner + if (cleaner != null) { + cleanable = cleaner.register(this, action) + fallback = null } else { - cleaner = null cleanable = null - phantomRef = PhantomCleanupReference(this, referenceQueue).also { - ref -> activeReferences[ref] = { clean() } - } + fallback = PhantomCleanerRegistry.register(this, action) } } - actual abstract fun clean() - override fun close() { - if (SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - cleanable?.clean() - } else { - phantomRef?.let { - ref -> activeReferences.remove(ref) - clean() - } - phantomRef = null - } + cleanable?.clean() + fallback?.clean() } +} - private class PhantomCleanupReference( - referent: PyAutoCloseable, - queue: ReferenceQueue - ): PhantomReference(referent, queue) -} \ No newline at end of file +private class CleanupAction( + private val pointer: NativePointer, + private val action: (NativePointer) -> Unit +) : Runnable { + override fun run() { + action(pointer) + } +} 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 06e082b95..45d6130c2 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 @@ -1,7 +1,7 @@ package python.native.ffi -actual inline fun memScoped(block: () -> R): R = block() +actual inline fun memScoped(block: () -> R): R = block() @JvmInline @@ -10,53 +10,578 @@ internal value class NativeAddressValue(val ptr: JNIPointer): AddressValue { } @HighOverheadNativeCall actual fun NativePointer.toAddressValue(): AddressValue = NativeAddressValue(toPlatformPointer()) -actual inline fun NativePointer.toRawValue(): Long = toPlatformPointer() -inline fun NativePointer.toPlatformPointer(): JNIPointer = this.address as JNIPointer @HighOverheadNativeCall actual fun AddressValue.toNativePointer(): NativePointer = NativePointer((this as NativeAddressValue).ptr) +/** + * 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 + * platform pointer *is* the `Long`, so the conversion is written out instead. + */ @HighOverheadNativeCall -actual fun Long.toNativePointer(): NativePointer? = toNativePointer() +actual fun Long.toNativePointer(): NativePointer? = if (this > 0) NativePointer(this) else null internal inline fun JNIPointer?.toNativePointer(): NativePointer? = this?.let { if (it > 0) NativePointer(it) else null } // Section 1 -actual inline fun Py_Initialize() = python.native.ffi.bindings.Py_Initialize() -actual inline fun Py_InitializeEx(initsigs: Int) = python.native.ffi.bindings.Py_InitializeEx(initsigs) -//actual fun Py_InitializeFromConfig(config) = python.native.ffi.bindings.Py_InitializeFromConfig() -actual inline fun Py_IsInitialized() = python.native.ffi.bindings.Py_IsInitialized() -actual inline fun Py_IsFinalizing() = python.native.ffi.bindings.Py_IsFinalizing() -actual inline fun Py_Finalize() = python.native.ffi.bindings.Py_Finalize() -actual inline fun Py_FinalizeEx(): Int = python.native.ffi.bindings.Py_FinalizeEx() -actual inline fun Py_BytesMain(args: Array): Int = python.native.ffi.bindings.Py_BytesMain(args) -actual inline fun Py_RunMain(): Int = python.native.ffi.bindings.Py_RunMain() -actual inline fun PyRun_SimpleString(command: String): Int = python.native.ffi.bindings.PyRun_SimpleString(command) -actual fun PyRun_String( - str: String, start: Int, globals: NativePointer, locals: NativePointer -): NativePointer? = python.native.ffi.bindings.PyRun_String( - str, start, globals.toPlatformPointer(), locals.toPlatformPointer() -).toNativePointer() -actual inline fun Py_GetVersion(): String? = python.native.ffi.bindings.Py_GetVersion() -actual inline fun Py_GetPlatform(): String? = python.native.ffi.bindings.Py_GetPlatform() -actual inline fun Py_GetCopyright(): String? = python.native.ffi.bindings.Py_GetCopyright() -actual inline fun Py_GetCompiler(): String? = python.native.ffi.bindings.Py_GetCompiler() -actual inline fun Py_GetBuildInfo(): String? = python.native.ffi.bindings.Py_GetBuildInfo() +// Runs site.py and other start-up Python; unbounded, so never on a GC-blocking path. +actual inline fun Py_Initialize() = python.native.ffi.bindings.Py_InitializeN() +actual inline fun Py_InitializeEx(initsigs: Int) = python.native.ffi.bindings.Py_InitializeExN(initsigs) +actual inline fun Py_IsInitialized(): Int = if (python.native.ffi.bindings.preferFastNative) python.native.ffi.bindings.Py_IsInitializedF() else python.native.ffi.bindings.Py_IsInitialized() +actual inline fun Py_IsFinalizing(): Int = python.native.ffi.bindings.Py_IsFinalizingN() +actual inline fun Py_FinalizeEx(): Int = python.native.ffi.bindings.Py_FinalizeExN() +// Runs atexit handlers and __del__ during teardown. +actual inline fun Py_Finalize() = python.native.ffi.bindings.Py_FinalizeN() +actual inline fun Py_RunMain(): Int = python.native.ffi.bindings.Py_RunMainN() // 수동 추가 +actual inline fun Py_GetVersion(): String? { + // CPython-owned const char* return value. JVM side must NOT free it. + val ptr = if (python.native.ffi.bindings.preferFastNative) python.native.ffi.bindings.Py_GetVersionF() else python.native.ffi.bindings.Py_GetVersion() + return if (ptr != 0L) python.native.ffi.bindings.ffiReadUtf8(ptr) else null +} +actual inline fun Py_GetPlatform(): String? { + val ptr = python.native.ffi.bindings.Py_GetPlatformN() + return if (ptr != 0L) python.native.ffi.bindings.ffiReadUtf8(ptr) else null +} +actual inline fun Py_GetCopyright(): String? { + val ptr = python.native.ffi.bindings.Py_GetCopyrightN() + return if (ptr != 0L) python.native.ffi.bindings.ffiReadUtf8(ptr) else null +} +actual inline fun Py_GetCompiler(): String? { + val ptr = python.native.ffi.bindings.Py_GetCompilerN() + return if (ptr != 0L) python.native.ffi.bindings.ffiReadUtf8(ptr) else null +} +actual inline fun Py_GetBuildInfo(): String? { + val ptr = python.native.ffi.bindings.Py_GetBuildInfoN() + return if (ptr != 0L) python.native.ffi.bindings.ffiReadUtf8(ptr) else null +} +// PyEval_InitThreads has no actual: it is a @Deprecated Kotlin no-op in EmbedAPI.kt, matching the empty CPython 3.9+ body. +actual fun PyThreadState_GetDict(): NativePointer? = python.native.ffi.bindings.PyThreadState_GetDictN().toNativePointer() + +actual inline fun PyGILState_Ensure(): Int = python.native.ffi.bindings.PyGILState_EnsureN() +actual inline fun PyGILState_Release(state: Int) = python.native.ffi.bindings.PyGILState_ReleaseN(state) +actual fun PyGILState_GetThisThreadState(): NativePointer? = python.native.ffi.bindings.PyGILState_GetThisThreadStateN().toNativePointer() +actual fun PyEval_SaveThread(): NativePointer? = python.native.ffi.bindings.PyEval_SaveThreadN().toNativePointer() +actual inline fun PyEval_RestoreThread(tstate: NativePointer) = python.native.ffi.bindings.PyEval_RestoreThreadN(tstate.toPlatformPointer()) +actual fun Py_MakePendingCalls(): Int = python.native.ffi.bindings.Py_MakePendingCallsN() +actual fun PyGC_Collect(): Long = python.native.ffi.bindings.PyGC_CollectN() // Section 2 -actual fun PyErr_Occurred(): NativePointer? = python.native.ffi.bindings.PyErr_Occurred().toNativePointer() +actual inline fun PyRun_SimpleString(command: String): Int { // 수동 추가 + val ptr = encodeScratchUtf8(command) + // Executes arbitrary Python. + return python.native.ffi.bindings.PyRun_SimpleStringN(ptr) +} +actual fun PyRun_String(str: String, start: Int, globals: NativePointer, locals: NativePointer): NativePointer? { + val ptr = encodeScratchUtf8(str) + return python.native.ffi.bindings.PyRun_StringN(ptr, start, globals.toPlatformPointer(), locals.toPlatformPointer()).toNativePointer() +} +// Two string arguments, two different judgements: `str` is the source text being compiled and is +// arbitrary content, while `filename` is the repeated label it is compiled under. +actual fun Py_CompileString(str: String, filename: String, start: Int): NativePointer? { + val _str = encodeScratchUtf8(str) + val _filename = internedUtf8(filename) + return python.native.ffi.bindings.Py_CompileStringN(_str, _filename, start).toNativePointer() +} +actual fun PyEval_EvalCode(co: NativePointer, globals: NativePointer, locals: NativePointer): NativePointer? = python.native.ffi.bindings.PyEval_EvalCodeN(co.toPlatformPointer(), globals.toPlatformPointer(), locals.toPlatformPointer()).toNativePointer() + + +// Section 3 +// Drops the last reference to the exception, which can run a Python __del__. +actual inline fun PyErr_Clear() = python.native.ffi.bindings.PyErr_ClearN() +actual inline fun PyErr_PrintEx(set_sys_last_vars: Int) = python.native.ffi.bindings.PyErr_PrintExN(set_sys_last_vars) +actual inline fun PyErr_Print() = python.native.ffi.bindings.PyErr_PrintN() +actual inline fun PyErr_WriteUnraisable(obj: NativePointer) = python.native.ffi.bindings.PyErr_WriteUnraisableN(obj.toPlatformPointer()) +actual inline fun PyErr_DisplayException(exc: NativePointer) = python.native.ffi.bindings.PyErr_DisplayExceptionN(exc.toPlatformPointer()) +// Exception messages are arbitrary content, not repeated identifiers, so they take the +// thread-local scratch rather than the intern cache -- see docs/design/marshalling-design.md. +actual inline fun PyErr_SetString(type: NativePointer, message: String) = + python.native.ffi.bindings.PyErr_SetStringN(type.toPlatformPointer(), encodeScratchUtf8(message)) +actual inline fun PyErr_SetObject(type: NativePointer, value: NativePointer) = python.native.ffi.bindings.PyErr_SetObjectN(type.toPlatformPointer(), value.toPlatformPointer()) +actual inline fun PyErr_SetNone(type: NativePointer) = python.native.ffi.bindings.PyErr_SetNoneN(type.toPlatformPointer()) +actual inline fun PyErr_BadArgument(): Int = python.native.ffi.bindings.PyErr_BadArgumentN() +actual fun PyErr_NoMemory(): NativePointer? = python.native.ffi.bindings.PyErr_NoMemoryN().toNativePointer() +actual fun PyErr_SetFromErrno(type: NativePointer): NativePointer? = python.native.ffi.bindings.PyErr_SetFromErrnoN(type.toPlatformPointer()).toNativePointer() +actual fun PyErr_SetFromErrnoWithFilenameObject(type: NativePointer, filenameObject: NativePointer): NativePointer? = python.native.ffi.bindings.PyErr_SetFromErrnoWithFilenameObjectN(type.toPlatformPointer(), filenameObject.toPlatformPointer()).toNativePointer() +actual fun PyErr_SetFromErrnoWithFilenameObjects(type: NativePointer, filenameObject: NativePointer, filenameObject2: NativePointer): NativePointer? = python.native.ffi.bindings.PyErr_SetFromErrnoWithFilenameObjectsN(type.toPlatformPointer(), filenameObject.toPlatformPointer(), filenameObject2.toPlatformPointer()).toNativePointer() +actual fun PyErr_SetFromErrnoWithFilename(type: NativePointer, filename: String): NativePointer? = + python.native.ffi.bindings.PyErr_SetFromErrnoWithFilenameN(type.toPlatformPointer(), encodeScratchUtf8(filename)).toNativePointer() +actual fun PyErr_SetImportError(msg: NativePointer, name: NativePointer, path: NativePointer): NativePointer? = python.native.ffi.bindings.PyErr_SetImportErrorN(msg.toPlatformPointer(), name.toPlatformPointer(), path.toPlatformPointer()).toNativePointer() +actual fun PyErr_SetImportErrorSubclass(exception: NativePointer, msg: NativePointer, name: NativePointer, path: NativePointer): NativePointer? = python.native.ffi.bindings.PyErr_SetImportErrorSubclassN(exception.toPlatformPointer(), msg.toPlatformPointer(), name.toPlatformPointer(), path.toPlatformPointer()).toNativePointer() +// A syntax-error location names the file the source came from, which for a caller reporting an +// error is a one-off path rather than a repeated identifier. +actual inline fun PyErr_SyntaxLocationEx(filename: String, lineno: Int, col_offset: Int) = + python.native.ffi.bindings.PyErr_SyntaxLocationExN(encodeScratchUtf8(filename), lineno, col_offset) +actual inline fun PyErr_SyntaxLocation(filename: String, lineno: Int) = + python.native.ffi.bindings.PyErr_SyntaxLocationN(encodeScratchUtf8(filename), lineno) +actual inline fun PyErr_BadInternalCall() = python.native.ffi.bindings.PyErr_BadInternalCallN() +// `message` is the warning text (arbitrary), while `filename` and `module` are the repeated +// registry keys CPython uses to decide whether the warning has already been shown. +// Three encodes, one of them scratch -- inside the 4-slot budget. +actual inline fun PyErr_WarnExplicit(category: NativePointer, message: String, filename: String, lineno: Int, module: String, registry: NativePointer): Int { + val _message = encodeScratchUtf8(message) + val _filename = internedUtf8(filename) + val _module = internedUtf8(module) + return python.native.ffi.bindings.PyErr_WarnExplicitN(category.toPlatformPointer(), _message, _filename, lineno, _module, registry.toPlatformPointer()) +} +actual fun PyErr_Occurred(): NativePointer? = + (if (python.native.ffi.bindings.preferFastNative) python.native.ffi.bindings.PyErr_OccurredF() else python.native.ffi.bindings.PyErr_Occurred()).toNativePointer() +actual inline fun PyErr_ExceptionMatches(exc: NativePointer): Int = python.native.ffi.bindings.PyErr_ExceptionMatchesN(exc.toPlatformPointer()) +actual inline fun PyErr_GivenExceptionMatches(given: NativePointer, exc: NativePointer): Int = python.native.ffi.bindings.PyErr_GivenExceptionMatchesN(given.toPlatformPointer(), exc.toPlatformPointer()) +actual fun PyErr_GetRaisedException(): NativePointer? = python.native.ffi.bindings.PyErr_GetRaisedExceptionN().toNativePointer() +actual inline fun PyErr_SetRaisedException(exc: NativePointer) = python.native.ffi.bindings.PyErr_SetRaisedExceptionN(exc.toPlatformPointer()) +actual inline fun PyErr_Restore(type: NativePointer, value: NativePointer, traceback: NativePointer) = python.native.ffi.bindings.PyErr_RestoreN(type.toPlatformPointer(), value.toPlatformPointer(), traceback.toPlatformPointer()) +actual fun PyErr_GetHandledException(): NativePointer? = python.native.ffi.bindings.PyErr_GetHandledExceptionN().toNativePointer() +actual inline fun PyErr_SetHandledException(exc: NativePointer) = python.native.ffi.bindings.PyErr_SetHandledExceptionN(exc.toPlatformPointer()) +actual inline fun PyErr_SetExcInfo(type: NativePointer, value: NativePointer, traceback: NativePointer) = python.native.ffi.bindings.PyErr_SetExcInfoN(type.toPlatformPointer(), value.toPlatformPointer(), traceback.toPlatformPointer()) +actual inline fun PyErr_CheckSignals(): Int = python.native.ffi.bindings.PyErr_CheckSignalsN() +actual inline fun PyErr_SetInterrupt() = python.native.ffi.bindings.PyErr_SetInterruptN() +actual inline fun PyErr_SetInterruptEx(signum: Int): Int = python.native.ffi.bindings.PyErr_SetInterruptExN(signum) +// `name` is the dotted exception type name -- an identifier. `doc` is the docstring, arbitrary prose. +actual fun PyErr_NewException(name: String, base: NativePointer, dict: NativePointer): NativePointer? = + python.native.ffi.bindings.PyErr_NewExceptionN(internedUtf8(name), base.toPlatformPointer(), dict.toPlatformPointer()).toNativePointer() +actual fun PyErr_NewExceptionWithDoc(name: String, doc: String, base: NativePointer, dict: NativePointer): NativePointer? { + val _name = internedUtf8(name) + val _doc = encodeScratchUtf8(doc) + return python.native.ffi.bindings.PyErr_NewExceptionWithDocN(_name, _doc, base.toPlatformPointer(), dict.toPlatformPointer()).toNativePointer() +} +actual fun PyException_GetTraceback(ex: NativePointer): NativePointer? = python.native.ffi.bindings.PyException_GetTracebackN(ex.toPlatformPointer()).toNativePointer() +actual inline fun PyException_SetTraceback(ex: NativePointer, tb: NativePointer): Int = python.native.ffi.bindings.PyException_SetTracebackN(ex.toPlatformPointer(), tb.toPlatformPointer()) +actual fun PyException_GetContext(ex: NativePointer): NativePointer? = python.native.ffi.bindings.PyException_GetContextN(ex.toPlatformPointer()).toNativePointer() +actual inline fun PyException_SetContext(ex: NativePointer, ctx: NativePointer) = python.native.ffi.bindings.PyException_SetContextN(ex.toPlatformPointer(), ctx.toPlatformPointer()) +actual fun PyException_GetCause(ex: NativePointer): NativePointer? = python.native.ffi.bindings.PyException_GetCauseN(ex.toPlatformPointer()).toNativePointer() +actual inline fun PyException_SetCause(ex: NativePointer, cause: NativePointer) = python.native.ffi.bindings.PyException_SetCauseN(ex.toPlatformPointer(), cause.toPlatformPointer()) +actual fun PyException_GetArgs(ex: NativePointer): NativePointer? = python.native.ffi.bindings.PyException_GetArgsN(ex.toPlatformPointer()).toNativePointer() +actual inline fun PyException_SetArgs(ex: NativePointer, args: NativePointer) = python.native.ffi.bindings.PyException_SetArgsN(ex.toPlatformPointer(), args.toPlatformPointer()) +actual fun PyUnicodeEncodeError_GetEncoding(exc: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicodeEncodeError_GetEncodingN(exc.toPlatformPointer()).toNativePointer() +actual fun PyUnicodeTranslateError_GetObject(exc: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicodeTranslateError_GetObjectN(exc.toPlatformPointer()).toNativePointer() +actual fun PyUnicodeTranslateError_GetReason(exc: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicodeTranslateError_GetReasonN(exc.toPlatformPointer()).toNativePointer() +actual inline fun PyUnicodeTranslateError_SetReason(exc: NativePointer, reason: String): Int = + python.native.ffi.bindings.PyUnicodeTranslateError_SetReasonN(exc.toPlatformPointer(), encodeScratchUtf8(reason)) +actual inline fun Py_EnterRecursiveCall(where: String): Int = + python.native.ffi.bindings.Py_EnterRecursiveCallN(encodeScratchUtf8(where)) +actual inline fun Py_LeaveRecursiveCall() = python.native.ffi.bindings.Py_LeaveRecursiveCallN() +actual inline fun Py_ReprEnter(o: NativePointer): Int = python.native.ffi.bindings.Py_ReprEnterN(o.toPlatformPointer()) +actual inline fun Py_ReprLeave(o: NativePointer) = python.native.ffi.bindings.Py_ReprLeaveN(o.toPlatformPointer()) + + +// Section 4 +actual fun Py_NewRef(o: NativePointer): NativePointer? = python.native.ffi.bindings.Py_NewRefN(o.toPlatformPointer()).toNativePointer() +actual fun Py_XNewRef(o: NativePointer): NativePointer? = python.native.ffi.bindings.Py_XNewRefN(o.toPlatformPointer()).toNativePointer() +actual inline fun Py_IncRef(o: NativePointer) = python.native.ffi.bindings.Py_IncRefN(o.toPlatformPointer()) +actual inline fun Py_DecRef(o: NativePointer) = python.native.ffi.bindings.Py_DecRefN(o.toPlatformPointer()) + + +// Section 5 +actual fun PyOS_FSPath(path: NativePointer): NativePointer? = python.native.ffi.bindings.PyOS_FSPathN(path.toPlatformPointer()).toNativePointer() + + +// Section 6 +// sys attribute names ("path", "modules", "stdout") and audit event names are fixed identifiers. +actual fun PySys_GetObject(name: String): NativePointer? = + python.native.ffi.bindings.PySys_GetObjectN(internedUtf8(name)).toNativePointer() +actual inline fun PySys_SetObject(name: String, v: NativePointer): Int = + python.native.ffi.bindings.PySys_SetObjectN(internedUtf8(name), v.toPlatformPointer()) +actual fun PySys_GetXOptions(): NativePointer? = python.native.ffi.bindings.PySys_GetXOptionsN().toNativePointer() +actual inline fun PySys_AuditTuple(event: String, args: NativePointer): Int = + python.native.ffi.bindings.PySys_AuditTupleN(internedUtf8(event), args.toPlatformPointer()) + + +// Section 7 +// The message is arbitrary and this call never returns, so caching it would be pure waste. +actual inline fun Py_FatalError(message: String) = python.native.ffi.bindings.Py_FatalErrorN(encodeScratchUtf8(message)) +actual inline fun Py_Exit(status: Int) = python.native.ffi.bindings.Py_ExitN(status) + + +// Section 8 +actual fun PyImport_ImportModule(name: String): NativePointer? { + val ptr = internedUtf8(name) + // Runs the module's top-level code. + return python.native.ffi.bindings.PyImport_ImportModuleN(ptr).toNativePointer() +} +actual fun PyImport_ImportModuleLevelObject(name: NativePointer, globals: NativePointer, locals: NativePointer, fromlist: NativePointer, level: Int): NativePointer? = python.native.ffi.bindings.PyImport_ImportModuleLevelObjectN(name.toPlatformPointer(), globals.toPlatformPointer(), locals.toPlatformPointer(), fromlist.toPlatformPointer(), level).toNativePointer() +actual fun PyImport_ImportModuleLevel(name: String, globals: NativePointer, locals: NativePointer, fromlist: NativePointer, level: Int): NativePointer? = + python.native.ffi.bindings.PyImport_ImportModuleLevelN(internedUtf8(name), globals.toPlatformPointer(), locals.toPlatformPointer(), fromlist.toPlatformPointer(), level).toNativePointer() +actual inline fun PyImport_Import(name: NativePointer): NativePointer? = python.native.ffi.bindings.PyImport_ImportN(name.toPlatformPointer()).toNativePointer() +actual fun PyImport_ReloadModule(m: NativePointer): NativePointer? = python.native.ffi.bindings.PyImport_ReloadModuleN(m.toPlatformPointer()).toNativePointer() +actual fun PyImport_AddModuleRef(name: String): NativePointer? { + val ptr = internedUtf8(name) + return python.native.ffi.bindings.PyImport_AddModuleRefN(ptr).toNativePointer() +} +actual fun PyImport_AddModuleObject(name: NativePointer): NativePointer? = python.native.ffi.bindings.PyImport_AddModuleObjectN(name.toPlatformPointer()).toNativePointer() +// Borrowed reference, unlike PyImport_AddModuleRef above -- the caller must not release it. +// Module names are repeated literals, so they intern. +actual fun PyImport_AddModule(name: String): NativePointer? { + val ptr = internedUtf8(name) + return python.native.ffi.bindings.PyImport_AddModuleN(ptr).toNativePointer() +} +// Module names intern; the file paths they were loaded from do not repeat the same way. +actual fun PyImport_ExecCodeModule(name: String, co: NativePointer): NativePointer? = + python.native.ffi.bindings.PyImport_ExecCodeModuleN(internedUtf8(name), co.toPlatformPointer()).toNativePointer() +actual fun PyImport_ExecCodeModuleEx(name: String, co: NativePointer, pathname: String): NativePointer? { + val _name = internedUtf8(name) + val _pathname = encodeScratchUtf8(pathname) + return python.native.ffi.bindings.PyImport_ExecCodeModuleExN(_name, co.toPlatformPointer(), _pathname).toNativePointer() +} +actual fun PyImport_ExecCodeModuleObject(name: NativePointer, co: NativePointer, pathname: NativePointer, cpathname: NativePointer): NativePointer? = python.native.ffi.bindings.PyImport_ExecCodeModuleObjectN(name.toPlatformPointer(), co.toPlatformPointer(), pathname.toPlatformPointer(), cpathname.toPlatformPointer()).toNativePointer() +// Two scratch encodes live at once here; the per-thread pool holds four, so both stay valid. +actual fun PyImport_ExecCodeModuleWithPathnames(name: String, co: NativePointer, pathname: String, cpathname: String): NativePointer? { + val _name = internedUtf8(name) + val _pathname = encodeScratchUtf8(pathname) + val _cpathname = encodeScratchUtf8(cpathname) + return python.native.ffi.bindings.PyImport_ExecCodeModuleWithPathnamesN(_name, co.toPlatformPointer(), _pathname, _cpathname).toNativePointer() +} +actual inline fun PyImport_GetMagicTag(): String? { + // CPython-owned const char*. JVM side must NOT free it. + val ptr = python.native.ffi.bindings.PyImport_GetMagicTagN() + return if (ptr != 0L) python.native.ffi.bindings.ffiReadUtf8(ptr) else null +} +actual fun PyImport_GetModuleDict(): NativePointer? = python.native.ffi.bindings.PyImport_GetModuleDictN().toNativePointer() +actual fun PyImport_GetModule(name: NativePointer): NativePointer? = python.native.ffi.bindings.PyImport_GetModuleN(name.toPlatformPointer()).toNativePointer() +actual fun PyImport_GetImporter(path: NativePointer): NativePointer? = python.native.ffi.bindings.PyImport_GetImporterN(path.toPlatformPointer()).toNativePointer() +actual inline fun PyImport_ImportFrozenModuleObject(name: NativePointer): Int = python.native.ffi.bindings.PyImport_ImportFrozenModuleObjectN(name.toPlatformPointer()) +actual inline fun PyImport_ImportFrozenModule(name: String): Int = + python.native.ffi.bindings.PyImport_ImportFrozenModuleN(internedUtf8(name)) + + +// Section 9 +actual fun PyEval_GetBuiltins(): NativePointer? = python.native.ffi.bindings.PyEval_GetBuiltinsN().toNativePointer() +actual fun PyEval_GetLocals(): NativePointer? = python.native.ffi.bindings.PyEval_GetLocalsN().toNativePointer() +actual fun PyEval_GetGlobals(): NativePointer? = python.native.ffi.bindings.PyEval_GetGlobalsN().toNativePointer() +actual fun PyEval_GetFrameBuiltins(): NativePointer? = python.native.ffi.bindings.PyEval_GetFrameBuiltinsN().toNativePointer() +actual fun PyEval_GetFrameLocals(): NativePointer? = python.native.ffi.bindings.PyEval_GetFrameLocalsN().toNativePointer() +actual fun PyEval_GetFrameGlobals(): NativePointer? = python.native.ffi.bindings.PyEval_GetFrameGlobalsN().toNativePointer() +actual inline fun PyEval_GetFuncName(func: NativePointer): String? { + val ptr = python.native.ffi.bindings.PyEval_GetFuncNameN(func.toPlatformPointer()) + return if (ptr != 0L) python.native.ffi.bindings.ffiReadUtf8(ptr) else null +} +actual inline fun PyEval_GetFuncDesc(func: NativePointer): String? { + val ptr = python.native.ffi.bindings.PyEval_GetFuncDescN(func.toPlatformPointer()) + return if (ptr != 0L) python.native.ffi.bindings.ffiReadUtf8(ptr) else null +} + + +// Section 10 +actual inline fun PyObject_HasAttrWithError(o: NativePointer, attr_name: NativePointer): Int = python.native.ffi.bindings.PyObject_HasAttrWithErrorN(o.toPlatformPointer(), attr_name.toPlatformPointer()) +actual inline fun PyObject_HasAttrStringWithError(o: NativePointer, attr_name: String): Int = + python.native.ffi.bindings.PyObject_HasAttrStringWithErrorN(o.toPlatformPointer(), internedUtf8(attr_name)) +actual inline fun PyObject_HasAttr(o: NativePointer, attr_name: NativePointer): Int = python.native.ffi.bindings.PyObject_HasAttrN(o.toPlatformPointer(), attr_name.toPlatformPointer()) +actual inline fun PyObject_HasAttrString(o: NativePointer, attr_name: String): Int = + python.native.ffi.bindings.PyObject_HasAttrStringN(o.toPlatformPointer(), internedUtf8(attr_name)) +actual fun PyObject_GetAttr(o: NativePointer, attr_name: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_GetAttrN(o.toPlatformPointer(), attr_name.toPlatformPointer()).toNativePointer() +actual fun PyObject_GetAttrString(o: NativePointer, attr_name: String): NativePointer? { + val ptr = internedUtf8(attr_name) + // Can dispatch to a Python __getattr__ / descriptor. + return python.native.ffi.bindings.PyObject_GetAttrStringN(o.toPlatformPointer(), ptr).toNativePointer() +} +actual fun PyObject_GenericGetAttr(o: NativePointer, name: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_GenericGetAttrN(o.toPlatformPointer(), name.toPlatformPointer()).toNativePointer() +actual inline fun PyObject_SetAttr(o: NativePointer, attr_name: NativePointer, v: NativePointer): Int = python.native.ffi.bindings.PyObject_SetAttrN(o.toPlatformPointer(), attr_name.toPlatformPointer(), v.toPlatformPointer()) +actual inline fun PyObject_SetAttrString(o: NativePointer, attr_name: String, v: NativePointer): Int = + python.native.ffi.bindings.PyObject_SetAttrStringN(o.toPlatformPointer(), internedUtf8(attr_name), v.toPlatformPointer()) +actual inline fun PyObject_GenericSetAttr(o: NativePointer, name: NativePointer, value: NativePointer): Int = python.native.ffi.bindings.PyObject_GenericSetAttrN(o.toPlatformPointer(), name.toPlatformPointer(), value.toPlatformPointer()) +actual inline fun PyObject_DelAttr(o: NativePointer, attr_name: NativePointer): Int = python.native.ffi.bindings.PyObject_DelAttrN(o.toPlatformPointer(), attr_name.toPlatformPointer()) +actual inline fun PyObject_DelAttrString(o: NativePointer, attr_name: String): Int = + python.native.ffi.bindings.PyObject_DelAttrStringN(o.toPlatformPointer(), internedUtf8(attr_name)) +actual fun PyObject_RichCompare(o1: NativePointer, o2: NativePointer, opid: Int): NativePointer? = python.native.ffi.bindings.PyObject_RichCompareN(o1.toPlatformPointer(), o2.toPlatformPointer(), opid).toNativePointer() +actual inline fun PyObject_RichCompareBool(o1: NativePointer, o2: NativePointer, opid: Int): Int = python.native.ffi.bindings.PyObject_RichCompareBoolN(o1.toPlatformPointer(), o2.toPlatformPointer(), opid) +actual fun PyObject_Format(obj: NativePointer, format_spec: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_FormatN(obj.toPlatformPointer(), format_spec.toPlatformPointer()).toNativePointer() +actual fun PyObject_Repr(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_ReprN(o.toPlatformPointer()).toNativePointer() +actual fun PyObject_ASCII(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_ASCIIN(o.toPlatformPointer()).toNativePointer() +actual fun PyObject_Str(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_StrN(o.toPlatformPointer()).toNativePointer() +actual fun PyObject_Bytes(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_BytesN(o.toPlatformPointer()).toNativePointer() +actual inline fun PyObject_IsSubclass(derived: NativePointer, cls: NativePointer): Int = python.native.ffi.bindings.PyObject_IsSubclassN(derived.toPlatformPointer(), cls.toPlatformPointer()) +actual inline fun PyObject_IsInstance(inst: NativePointer, cls: NativePointer): Int = python.native.ffi.bindings.PyObject_IsInstanceN(inst.toPlatformPointer(), cls.toPlatformPointer()) +actual inline fun PyObject_IsTrue(o: NativePointer): Int = python.native.ffi.bindings.PyObject_IsTrueN(o.toPlatformPointer()) +actual inline fun PyObject_Not(o: NativePointer): Int = python.native.ffi.bindings.PyObject_NotN(o.toPlatformPointer()) +actual fun PyObject_Type(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_TypeN(o.toPlatformPointer()).toNativePointer() +actual inline fun PyObject_Size(o: NativePointer): Long = python.native.ffi.bindings.PyObject_SizeN(o.toPlatformPointer()) +actual inline fun PyObject_Length(o: NativePointer): Long = python.native.ffi.bindings.PyObject_LengthN(o.toPlatformPointer()) +actual fun PyObject_GetItem(o: NativePointer, key: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_GetItemN(o.toPlatformPointer(), key.toPlatformPointer()).toNativePointer() +actual inline fun PyObject_SetItem(o: NativePointer, key: NativePointer, v: NativePointer): Int = python.native.ffi.bindings.PyObject_SetItemN(o.toPlatformPointer(), key.toPlatformPointer(), v.toPlatformPointer()) +actual inline fun PyObject_DelItem(o: NativePointer, key: NativePointer): Int = python.native.ffi.bindings.PyObject_DelItemN(o.toPlatformPointer(), key.toPlatformPointer()) +actual fun PyObject_Dir(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_DirN(o.toPlatformPointer()).toNativePointer() +actual fun PyObject_GetIter(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_GetIterN(o.toPlatformPointer()).toNativePointer() +actual fun PyObject_GetAIter(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_GetAIterN(o.toPlatformPointer()).toNativePointer() + + +// Section 11 +actual fun PyVectorcall_Call(callable: NativePointer, tuple: NativePointer, dict: NativePointer): NativePointer? = python.native.ffi.bindings.PyVectorcall_CallN(callable.toPlatformPointer(), tuple.toPlatformPointer(), dict.toPlatformPointer()).toNativePointer() +actual fun PyObject_Call(callable: NativePointer, args: NativePointer, kwargs: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_CallN(callable.toPlatformPointer(), args.toPlatformPointer(), kwargs.toPlatformPointer()).toNativePointer() +actual fun PyObject_CallNoArgs(callable: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_CallNoArgsN(callable.toPlatformPointer()).toNativePointer() +actual fun PyObject_CallObject(callable: NativePointer, args: NativePointer): NativePointer? = python.native.ffi.bindings.PyObject_CallObjectN(callable.toPlatformPointer(), args.toPlatformPointer()).toNativePointer() +actual inline fun PyCallable_Check(o: NativePointer): Int = python.native.ffi.bindings.PyCallable_CheckN(o.toPlatformPointer()) + + +// Section 12 +actual inline fun PyNumber_Check(o: NativePointer): Int = python.native.ffi.bindings.PyNumber_CheckN(o.toPlatformPointer()) +actual fun PyNumber_Add(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_AddN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Subtract(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_SubtractN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Multiply(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_MultiplyN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_MatrixMultiply(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_MatrixMultiplyN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_FloorDivide(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_FloorDivideN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_TrueDivide(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_TrueDivideN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Remainder(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_RemainderN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Divmod(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_DivmodN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Power(o1: NativePointer, o2: NativePointer, o3: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_PowerN(o1.toPlatformPointer(), o2.toPlatformPointer(), o3.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Negative(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_NegativeN(o.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Positive(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_PositiveN(o.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Absolute(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_AbsoluteN(o.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Invert(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InvertN(o.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Lshift(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_LshiftN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Rshift(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_RshiftN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_And(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_AndN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Xor(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_XorN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Or(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_OrN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceAdd(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceAddN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceSubtract(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceSubtractN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceMultiply(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceMultiplyN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceMatrixMultiply(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceMatrixMultiplyN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceFloorDivide(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceFloorDivideN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceTrueDivide(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceTrueDivideN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceRemainder(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceRemainderN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlacePower(o1: NativePointer, o2: NativePointer, o3: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlacePowerN(o1.toPlatformPointer(), o2.toPlatformPointer(), o3.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceLshift(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceLshiftN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceRshift(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceRshiftN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceAnd(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceAndN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceXor(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceXorN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_InPlaceOr(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_InPlaceOrN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Long(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_LongN(o.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Float(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_FloatN(o.toPlatformPointer()).toNativePointer() +actual fun PyNumber_Index(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyNumber_IndexN(o.toPlatformPointer()).toNativePointer() +actual fun PyNumber_ToBase(n: NativePointer, base: Int): NativePointer? = python.native.ffi.bindings.PyNumber_ToBaseN(n.toPlatformPointer(), base).toNativePointer() +actual inline fun PyIndex_Check(o: NativePointer): Int = python.native.ffi.bindings.PyIndex_CheckN(o.toPlatformPointer()) + + +// Section 13 +actual inline fun PySequence_Check(o: NativePointer): Int = python.native.ffi.bindings.PySequence_CheckN(o.toPlatformPointer()) +actual fun PySequence_Concat(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PySequence_ConcatN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual fun PySequence_InPlaceConcat(o1: NativePointer, o2: NativePointer): NativePointer? = python.native.ffi.bindings.PySequence_InPlaceConcatN(o1.toPlatformPointer(), o2.toPlatformPointer()).toNativePointer() +actual inline fun PySequence_Contains(o: NativePointer, value: NativePointer): Int = python.native.ffi.bindings.PySequence_ContainsN(o.toPlatformPointer(), value.toPlatformPointer()) +actual fun PySequence_List(o: NativePointer): NativePointer? = python.native.ffi.bindings.PySequence_ListN(o.toPlatformPointer()).toNativePointer() +actual fun PySequence_Tuple(o: NativePointer): NativePointer? = python.native.ffi.bindings.PySequence_TupleN(o.toPlatformPointer()).toNativePointer() +// `m` is the message raised when `o` is not a sequence -- arbitrary text, not an identifier. +actual fun PySequence_Fast(o: NativePointer, m: String): NativePointer? = + python.native.ffi.bindings.PySequence_FastN(o.toPlatformPointer(), encodeScratchUtf8(m)).toNativePointer() + + +// Section 14 +actual inline fun PyMapping_Check(o: NativePointer): Int = python.native.ffi.bindings.PyMapping_CheckN(o.toPlatformPointer()) +// Mapping keys reached through the *String entry points are the same literals as dict keys and +// attribute names, so they intern -- matching PyDict_*ItemString below. +actual fun PyMapping_GetItemString(o: NativePointer, key: String): NativePointer? = + python.native.ffi.bindings.PyMapping_GetItemStringN(o.toPlatformPointer(), internedUtf8(key)).toNativePointer() +actual inline fun PyMapping_SetItemString(o: NativePointer, key: String, v: NativePointer): Int = + python.native.ffi.bindings.PyMapping_SetItemStringN(o.toPlatformPointer(), internedUtf8(key), v.toPlatformPointer()) +actual inline fun PyMapping_HasKeyWithError(o: NativePointer, key: NativePointer): Int = python.native.ffi.bindings.PyMapping_HasKeyWithErrorN(o.toPlatformPointer(), key.toPlatformPointer()) +actual inline fun PyMapping_HasKeyStringWithError(o: NativePointer, key: String): Int = + python.native.ffi.bindings.PyMapping_HasKeyStringWithErrorN(o.toPlatformPointer(), internedUtf8(key)) +actual inline fun PyMapping_HasKey(o: NativePointer, key: NativePointer): Int = python.native.ffi.bindings.PyMapping_HasKeyN(o.toPlatformPointer(), key.toPlatformPointer()) +actual inline fun PyMapping_HasKeyString(o: NativePointer, key: String): Int = + python.native.ffi.bindings.PyMapping_HasKeyStringN(o.toPlatformPointer(), internedUtf8(key)) +actual fun PyMapping_Keys(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyMapping_KeysN(o.toPlatformPointer()).toNativePointer() +actual fun PyMapping_Values(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyMapping_ValuesN(o.toPlatformPointer()).toNativePointer() +actual fun PyMapping_Items(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyMapping_ItemsN(o.toPlatformPointer()).toNativePointer() + + +// Section 15 +actual inline fun PyIter_Check(o: NativePointer): Int = python.native.ffi.bindings.PyIter_CheckN(o.toPlatformPointer()) +actual inline fun PyAIter_Check(o: NativePointer): Int = python.native.ffi.bindings.PyAIter_CheckN(o.toPlatformPointer()) +actual fun PyIter_Next(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyIter_NextN(o.toPlatformPointer()).toNativePointer() + + +// Section 16 +actual fun PyLong_FromLongLong(v: Long): NativePointer? = + (if (python.native.ffi.bindings.preferFastNative) python.native.ffi.bindings.PyLong_FromLongLongF(v) else python.native.ffi.bindings.PyLong_FromLongLong(v)).toNativePointer() +actual fun PyLong_FromDouble(v: Double): NativePointer? = python.native.ffi.bindings.PyLong_FromDoubleN(v).toNativePointer() +actual inline fun PyLong_AsInt(obj: NativePointer): Int = python.native.ffi.bindings.PyLong_AsIntN(obj.toPlatformPointer()) +actual inline fun PyLong_AsLongLong(obj: NativePointer): Long = python.native.ffi.bindings.PyLong_AsLongLongN(obj.toPlatformPointer()) +actual inline fun PyLong_AsDouble(pylong: NativePointer): Double = python.native.ffi.bindings.PyLong_AsDoubleN(pylong.toPlatformPointer()) +actual fun PyLong_GetInfo(): NativePointer? = python.native.ffi.bindings.PyLong_GetInfoN().toNativePointer() + + +// Section 17 +actual fun PyBool_FromLong(v: Int): NativePointer? = python.native.ffi.bindings.PyBool_FromLongN(v.toLong()).toNativePointer() + + +// Section 18 +actual fun PyFloat_FromString(str: NativePointer): NativePointer? = python.native.ffi.bindings.PyFloat_FromStringN(str.toPlatformPointer()).toNativePointer() +actual fun PyFloat_FromDouble(v: Double): NativePointer? = python.native.ffi.bindings.PyFloat_FromDoubleN(v).toNativePointer() +actual inline fun PyFloat_AsDouble(pyfloat: NativePointer): Double = python.native.ffi.bindings.PyFloat_AsDoubleN(pyfloat.toPlatformPointer()) +actual fun PyFloat_GetInfo(): NativePointer? = python.native.ffi.bindings.PyFloat_GetInfoN().toNativePointer() +actual inline fun PyFloat_GetMax(): Double = python.native.ffi.bindings.PyFloat_GetMaxN() +actual inline fun PyFloat_GetMin(): Double = python.native.ffi.bindings.PyFloat_GetMinN() + + +// Section 19 +// Payload being turned into a bytes object -- user data, never a repeated identifier. +actual fun PyBytes_FromString(v: String): NativePointer? = + python.native.ffi.bindings.PyBytes_FromStringN(encodeScratchUtf8(v)).toNativePointer() +actual fun PyBytes_FromObject(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyBytes_FromObjectN(o.toPlatformPointer()).toNativePointer() +actual inline fun PyBytes_AsString(o: NativePointer): String? { + // Points into the bytes object's own buffer; owned by CPython, not freed here. + val ptr = python.native.ffi.bindings.PyBytes_AsStringN(o.toPlatformPointer()) + return if (ptr != 0L) python.native.ffi.bindings.ffiReadUtf8(ptr) else null +} + + +// Section 20 +actual fun PyByteArray_FromObject(o: NativePointer): NativePointer? = python.native.ffi.bindings.PyByteArray_FromObjectN(o.toPlatformPointer()).toNativePointer() +actual fun PyByteArray_Concat(a: NativePointer, b: NativePointer): NativePointer? = python.native.ffi.bindings.PyByteArray_ConcatN(a.toPlatformPointer(), b.toPlatformPointer()).toNativePointer() +actual inline fun PyByteArray_AsString(bytearray: NativePointer): String? { + val ptr = python.native.ffi.bindings.PyByteArray_AsStringN(bytearray.toPlatformPointer()) + return if (ptr != 0L) python.native.ffi.bindings.ffiReadUtf8(ptr) else null +} + + +// Section 21 +actual inline fun PyUnicode_IsIdentifier(unicode: NativePointer): Int = python.native.ffi.bindings.PyUnicode_IsIdentifierN(unicode.toPlatformPointer()) +actual fun PyUnicode_FromString(str: String): NativePointer? { + val ptr = encodeScratchUtf8(str) + return (if (python.native.ffi.bindings.preferFastNative) python.native.ffi.bindings.PyUnicode_FromStringF(ptr) else python.native.ffi.bindings.PyUnicode_FromString(ptr)).toNativePointer() +} +actual fun PyUnicode_FromObject(obj: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_FromObjectN(obj.toPlatformPointer()).toNativePointer() +// `encoding` is a codec name from a small fixed set ("utf-8", "latin-1"), so it interns. `errors` +// comes from a fixed set too, but CPython compares it by pointer-free strcmp on every call and it +// is short enough that the scratch path costs nothing -- desktop makes the same split. +actual fun PyUnicode_FromEncodedObject(obj: NativePointer, encoding: String, errors: String): NativePointer? { + val _encoding = internedUtf8(encoding) + val _errors = encodeScratchUtf8(errors) + return python.native.ffi.bindings.PyUnicode_FromEncodedObjectN(obj.toPlatformPointer(), _encoding, _errors).toNativePointer() +} +actual fun PyUnicode_DecodeLocale(str: String, errors: String): NativePointer? { + val _str = encodeScratchUtf8(str) + val _errors = encodeScratchUtf8(errors) + return python.native.ffi.bindings.PyUnicode_DecodeLocaleN(_str, _errors).toNativePointer() +} +actual fun PyUnicode_EncodeLocale(unicode: NativePointer, errors: String): NativePointer? = + python.native.ffi.bindings.PyUnicode_EncodeLocaleN(unicode.toPlatformPointer(), encodeScratchUtf8(errors)).toNativePointer() +actual fun PyUnicode_DecodeFSDefault(str: String): NativePointer? = + python.native.ffi.bindings.PyUnicode_DecodeFSDefaultN(encodeScratchUtf8(str)).toNativePointer() +actual fun PyUnicode_EncodeFSDefault(unicode: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_EncodeFSDefaultN(unicode.toPlatformPointer()).toNativePointer() +actual fun PyUnicode_AsEncodedString(unicode: NativePointer, encoding: String, errors: String): NativePointer? { + val _encoding = internedUtf8(encoding) + val _errors = encodeScratchUtf8(errors) + return python.native.ffi.bindings.PyUnicode_AsEncodedStringN(unicode.toPlatformPointer(), _encoding, _errors).toNativePointer() +} +actual fun PyUnicode_AsUTF8String(unicode: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_AsUTF8StringN(unicode.toPlatformPointer()).toNativePointer() +actual inline fun PyUnicode_AsUTF8(unicode: NativePointer): String? { + val ptr = if (python.native.ffi.bindings.preferFastNative) python.native.ffi.bindings.PyUnicode_AsUTF8F(unicode.toPlatformPointer()) else python.native.ffi.bindings.PyUnicode_AsUTF8(unicode.toPlatformPointer()) + return if (ptr != 0L) python.native.ffi.bindings.ffiReadUtf8(ptr) else null +} +actual fun PyUnicode_AsUTF32String(unicode: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_AsUTF32StringN(unicode.toPlatformPointer()).toNativePointer() +actual fun PyUnicode_AsUTF16String(unicode: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_AsUTF16StringN(unicode.toPlatformPointer()).toNativePointer() +actual fun PyUnicode_AsUnicodeEscapeString(unicode: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_AsUnicodeEscapeStringN(unicode.toPlatformPointer()).toNativePointer() +actual fun PyUnicode_AsRawUnicodeEscapeString(unicode: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_AsRawUnicodeEscapeStringN(unicode.toPlatformPointer()).toNativePointer() +actual fun PyUnicode_AsLatin1String(unicode: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_AsLatin1StringN(unicode.toPlatformPointer()).toNativePointer() +actual fun PyUnicode_AsASCIIString(unicode: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_AsASCIIStringN(unicode.toPlatformPointer()).toNativePointer() +actual fun PyUnicode_AsCharmapString(unicode: NativePointer, mapping: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_AsCharmapStringN(unicode.toPlatformPointer(), mapping.toPlatformPointer()).toNativePointer() +actual fun PyUnicode_Translate(unicode: NativePointer, table: NativePointer, errors: String): NativePointer? = + python.native.ffi.bindings.PyUnicode_TranslateN(unicode.toPlatformPointer(), table.toPlatformPointer(), encodeScratchUtf8(errors)).toNativePointer() +actual fun PyUnicode_Concat(left: NativePointer, right: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_ConcatN(left.toPlatformPointer(), right.toPlatformPointer()).toNativePointer() +actual fun PyUnicode_Splitlines(unicode: NativePointer, keepends: Int): NativePointer? = python.native.ffi.bindings.PyUnicode_SplitlinesN(unicode.toPlatformPointer(), keepends).toNativePointer() +actual fun PyUnicode_Join(separator: NativePointer, seq: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_JoinN(separator.toPlatformPointer(), seq.toPlatformPointer()).toNativePointer() +actual inline fun PyUnicode_Compare(left: NativePointer, right: NativePointer): Int = python.native.ffi.bindings.PyUnicode_CompareN(left.toPlatformPointer(), right.toPlatformPointer()) +// The comparand is a value being tested, not a key that will be reused; caching it would fill the +// intern table with one-shot entries. +actual inline fun PyUnicode_EqualToUTF8(unicode: NativePointer, string: String): Int = + python.native.ffi.bindings.PyUnicode_EqualToUTF8N(unicode.toPlatformPointer(), encodeScratchUtf8(string)) +actual inline fun PyUnicode_CompareWithASCIIString(unicode: NativePointer, string: String): Int = + python.native.ffi.bindings.PyUnicode_CompareWithASCIIStringN(unicode.toPlatformPointer(), encodeScratchUtf8(string)) +actual fun PyUnicode_RichCompare(left: NativePointer, right: NativePointer, op: Int): NativePointer? = python.native.ffi.bindings.PyUnicode_RichCompareN(left.toPlatformPointer(), right.toPlatformPointer(), op).toNativePointer() +actual fun PyUnicode_Format(format: NativePointer, args: NativePointer): NativePointer? = python.native.ffi.bindings.PyUnicode_FormatN(format.toPlatformPointer(), args.toPlatformPointer()).toNativePointer() +actual inline fun PyUnicode_Contains(unicode: NativePointer, substr: NativePointer): Int = python.native.ffi.bindings.PyUnicode_ContainsN(unicode.toPlatformPointer(), substr.toPlatformPointer()) +// The caller is asking CPython to intern this string, which is a statement that it repeats. +actual fun PyUnicode_InternFromString(str: String): NativePointer? = + python.native.ffi.bindings.PyUnicode_InternFromStringN(internedUtf8(str)).toNativePointer() + + +// Section 22 +actual fun PyList_New(len: Long): NativePointer? = python.native.ffi.bindings.PyList_NewN(len).toNativePointer() +actual inline fun PyList_Size(list: NativePointer): Long = + if (python.native.ffi.bindings.preferFastNative) python.native.ffi.bindings.PyList_SizeF(list.toPlatformPointer()) + else python.native.ffi.bindings.PyList_Size(list.toPlatformPointer()) +// Leaf on the success path, but the per-element call of bulk iteration, so the convention is the +// whole cost. Follows the same device axis as PyList_Size above rather than pinning @CriticalNative. +actual fun PyList_GetItem(list: NativePointer, index: Long): NativePointer? = + (if (python.native.ffi.bindings.preferFastNative) python.native.ffi.bindings.PyList_GetItemRawF(list.toPlatformPointer(), index) + else python.native.ffi.bindings.PyList_GetItemRaw(list.toPlatformPointer(), index)).toNativePointer() +actual inline fun PyList_SetItem(list: NativePointer, index: Long, item: NativePointer): Int = python.native.ffi.bindings.PyList_SetItemN(list.toPlatformPointer(), index, item.toPlatformPointer()) +actual inline fun PyList_Insert(list: NativePointer, index: Long, item: NativePointer): Int = python.native.ffi.bindings.PyList_InsertN(list.toPlatformPointer(), index, item.toPlatformPointer()) +actual inline fun PyList_Append(list: NativePointer, item: NativePointer): Int = python.native.ffi.bindings.PyList_AppendN(list.toPlatformPointer(), item.toPlatformPointer()) +actual inline fun PyList_Sort(list: NativePointer): Int = python.native.ffi.bindings.PyList_SortN(list.toPlatformPointer()) +actual inline fun PyList_Reverse(list: NativePointer): Int = python.native.ffi.bindings.PyList_ReverseN(list.toPlatformPointer()) +actual fun PyList_AsTuple(list: NativePointer): NativePointer? = python.native.ffi.bindings.PyList_AsTupleN(list.toPlatformPointer()).toNativePointer() + + +// Section 23 +actual fun PyDict_New(): NativePointer? = python.native.ffi.bindings.PyDict_NewN().toNativePointer() +actual inline fun PyDict_Size(p: NativePointer): Long = python.native.ffi.bindings.PyDict_SizeN(p.toPlatformPointer()) +actual fun PyDictProxy_New(mapping: NativePointer): NativePointer? = python.native.ffi.bindings.PyDictProxy_NewN(mapping.toPlatformPointer()).toNativePointer() +actual inline fun PyDict_Clear(p: NativePointer) = python.native.ffi.bindings.PyDict_ClearN(p.toPlatformPointer()) +actual inline fun PyDict_Contains(p: NativePointer, key: NativePointer): Int = python.native.ffi.bindings.PyDict_ContainsN(p.toPlatformPointer(), key.toPlatformPointer()) +actual fun PyDict_Copy(p: NativePointer): NativePointer? = python.native.ffi.bindings.PyDict_CopyN(p.toPlatformPointer()).toNativePointer() +actual inline fun PyDict_SetItem(p: NativePointer, key: NativePointer, v: NativePointer): Int = python.native.ffi.bindings.PyDict_SetItemN(p.toPlatformPointer(), key.toPlatformPointer(), v.toPlatformPointer()) +actual inline fun PyDict_SetItemString(p: NativePointer, key: String, v: NativePointer): Int { val ptr = internedUtf8(key); return python.native.ffi.bindings.PyDict_SetItemStringN(p.toPlatformPointer(), ptr, v.toPlatformPointer()) } +actual inline fun PyDict_DelItem(p: NativePointer, key: NativePointer): Int = python.native.ffi.bindings.PyDict_DelItemN(p.toPlatformPointer(), key.toPlatformPointer()) +actual inline fun PyDict_DelItemString(p: NativePointer, key: String): Int = + python.native.ffi.bindings.PyDict_DelItemStringN(p.toPlatformPointer(), internedUtf8(key)) +actual fun PyDict_GetItem(p: NativePointer, key: NativePointer): NativePointer? = python.native.ffi.bindings.PyDict_GetItemN(p.toPlatformPointer(), key.toPlatformPointer()).toNativePointer() +actual fun PyDict_GetItemWithError(p: NativePointer, key: NativePointer): NativePointer? = python.native.ffi.bindings.PyDict_GetItemWithErrorN(p.toPlatformPointer(), key.toPlatformPointer()).toNativePointer() +actual fun PyDict_GetItemString(p: NativePointer, key: String): NativePointer? { + val ptr = internedUtf8(key) + return python.native.ffi.bindings.PyDict_GetItemStringN(p.toPlatformPointer(), ptr).toNativePointer() +} +actual fun PyDict_Items(p: NativePointer): NativePointer? = python.native.ffi.bindings.PyDict_ItemsN(p.toPlatformPointer()).toNativePointer() +actual fun PyDict_Keys(p: NativePointer): NativePointer? = python.native.ffi.bindings.PyDict_KeysN(p.toPlatformPointer()).toNativePointer() +actual fun PyDict_Values(p: NativePointer): NativePointer? = python.native.ffi.bindings.PyDict_ValuesN(p.toPlatformPointer()).toNativePointer() +actual inline fun PyDict_Merge(a: NativePointer, b: NativePointer, override: Int): Int = python.native.ffi.bindings.PyDict_MergeN(a.toPlatformPointer(), b.toPlatformPointer(), override) +actual inline fun PyDict_Update(a: NativePointer, b: NativePointer): Int = python.native.ffi.bindings.PyDict_UpdateN(a.toPlatformPointer(), b.toPlatformPointer()) +actual inline fun PyDict_MergeFromSeq2(a: NativePointer, seq2: NativePointer, override: Int): Int = python.native.ffi.bindings.PyDict_MergeFromSeq2N(a.toPlatformPointer(), seq2.toPlatformPointer(), override) + + +// Section 24 +actual fun PySet_New(iterable: NativePointer): NativePointer? = python.native.ffi.bindings.PySet_NewN(iterable.toPlatformPointer()).toNativePointer() +actual fun PyFrozenSet_New(iterable: NativePointer): NativePointer? = python.native.ffi.bindings.PyFrozenSet_NewN(iterable.toPlatformPointer()).toNativePointer() +actual inline fun PySet_Contains(anyset: NativePointer, key: NativePointer): Int = python.native.ffi.bindings.PySet_ContainsN(anyset.toPlatformPointer(), key.toPlatformPointer()) +actual inline fun PySet_Size(anyset: NativePointer): Long = python.native.ffi.bindings.PySet_SizeN(anyset.toPlatformPointer()) +actual inline fun PySet_Add(set: NativePointer, key: NativePointer): Int = python.native.ffi.bindings.PySet_AddN(set.toPlatformPointer(), key.toPlatformPointer()) +actual inline fun PySet_Discard(set: NativePointer, key: NativePointer): Int = python.native.ffi.bindings.PySet_DiscardN(set.toPlatformPointer(), key.toPlatformPointer()) +actual fun PySet_Pop(set: NativePointer): NativePointer? = python.native.ffi.bindings.PySet_PopN(set.toPlatformPointer()).toNativePointer() +actual inline fun PySet_Clear(set: NativePointer): Int = python.native.ffi.bindings.PySet_ClearN(set.toPlatformPointer()) + +// Section 25 +actual fun PySeqIter_New(seq: NativePointer): NativePointer? = python.native.ffi.bindings.PySeqIter_NewN(seq.toPlatformPointer()).toNativePointer() +actual fun PyCallIter_New(callable: NativePointer, sentinel: NativePointer): NativePointer? = python.native.ffi.bindings.PyCallIter_NewN(callable.toPlatformPointer(), sentinel.toPlatformPointer()).toNativePointer() +// Section 26 +actual fun PyWeakref_NewRef(ob: NativePointer, callback: NativePointer): NativePointer? = python.native.ffi.bindings.PyWeakref_NewRefN(ob.toPlatformPointer(), callback.toPlatformPointer()).toNativePointer() +actual fun PyWeakref_NewProxy(ob: NativePointer, callback: NativePointer): NativePointer? = python.native.ffi.bindings.PyWeakref_NewProxyN(ob.toPlatformPointer(), callback.toPlatformPointer()).toNativePointer() +actual fun PyWeakref_GetRef(ref: NativePointer): NativePointer? = python.native.ffi.bindings.PyWeakref_GetRefN(ref.toPlatformPointer()).toNativePointer() +actual inline fun PyObject_ClearWeakRefs(o: NativePointer) = python.native.ffi.bindings.PyObject_ClearWeakRefsN(o.toPlatformPointer()) +// Section 27 +actual inline fun PyType_IsSubtype(a: NativePointer, b: NativePointer): Int = python.native.ffi.bindings.PyType_IsSubtypeN(a.toPlatformPointer(), b.toPlatformPointer()) +actual inline fun PyType_Ready(type: NativePointer): Int = python.native.ffi.bindings.PyType_ReadyN(type.toPlatformPointer()) +actual fun PyType_GetName(type: NativePointer): NativePointer? = python.native.ffi.bindings.PyType_GetNameN(type.toPlatformPointer()).toNativePointer() +actual fun PyType_GetFullyQualifiedName(type: NativePointer): NativePointer? = python.native.ffi.bindings.PyType_GetFullyQualifiedNameN(type.toPlatformPointer()).toNativePointer() +actual fun PyType_GetModuleName(type: NativePointer): NativePointer? = python.native.ffi.bindings.PyType_GetModuleNameN(type.toPlatformPointer()).toNativePointer() +actual fun PyType_GetModule(type: NativePointer): NativePointer? = python.native.ffi.bindings.PyType_GetModuleN(type.toPlatformPointer()).toNativePointer() -actual fun PyLong_FromLongLong(v: Long): NativePointer? = python.native.ffi.bindings.PyLong_FromLongLong(v).toNativePointer() -actual inline fun PyLong_AsLongLong(p: NativePointer): Long = python.native.ffi.bindings.PyLong_AsLongLong(p.toPlatformPointer()) -actual inline fun PyLong_AsInt(p: NativePointer): Int = python.native.ffi.bindings.PyLong_AsInt(p.toPlatformPointer()) +// Section 28 +actual fun PyTuple_New(len: Long): NativePointer? = python.native.ffi.bindings.PyTuple_NewN(len).toNativePointer() +actual inline fun PyTuple_Size(p: NativePointer): Long = python.native.ffi.bindings.PyTuple_SizeN(p.toPlatformPointer()) +actual fun PyTuple_GetItem(p: NativePointer, pos: Long): NativePointer? = python.native.ffi.bindings.PyTuple_GetItemN(p.toPlatformPointer(), pos).toNativePointer() +actual fun PyTuple_GetSlice(p: NativePointer, low: Long, high: Long): NativePointer? = python.native.ffi.bindings.PyTuple_GetSliceN(p.toPlatformPointer(), low, high).toNativePointer() +actual inline fun PyTuple_SetItem(p: NativePointer, pos: Long, o: NativePointer): Int = python.native.ffi.bindings.PyTuple_SetItemN(p.toPlatformPointer(), pos, o.toPlatformPointer()) -actual fun PyUnicode_FromString(str: String): NativePointer? = - python.native.ffi.bindings.PyUnicode_FromString(str).toNativePointer() -actual inline fun PyUnicode_AsUTF8(unicode: NativePointer): String? = - python.native.ffi.bindings.PyUnicode_AsUTF8(unicode.toPlatformPointer()) +// Section 29 +actual inline fun PyModule_GetName(module: NativePointer): String? = python.native.ffi.bindings.ffiReadUtf8(python.native.ffi.bindings.PyModule_GetNameN(module.toPlatformPointer())) +actual fun PyModule_GetDict(module: NativePointer): NativePointer? = python.native.ffi.bindings.PyModule_GetDictN(module.toPlatformPointer()).toNativePointer() +actual fun PyModule_GetFilenameObject(module: NativePointer): NativePointer? = python.native.ffi.bindings.PyModule_GetFilenameObjectN(module.toPlatformPointer()).toNativePointer() diff --git a/python-multiplatform/src/androidMain/kotlin/python/native/ffi/ShapeDowncalls.android.kt b/python-multiplatform/src/androidMain/kotlin/python/native/ffi/ShapeDowncalls.android.kt new file mode 100644 index 000000000..6c9f0f29c --- /dev/null +++ b/python-multiplatform/src/androidMain/kotlin/python/native/ffi/ShapeDowncalls.android.kt @@ -0,0 +1,126 @@ +package python.native.ffi +import java.util.concurrent.ConcurrentHashMap +import java.nio.ByteBuffer + +/** + * Android (ART) actuals for the shape vocabulary declared in `jvmMain/.../ShapeDowncalls.kt`. + * + * Each shape is one JNI native-method call into a `@CName`-exported trampoline compiled into + * `libmultiplatform_python3.13.so` (see `nativeMain/.../EmbedAPI.native.kt`), which casts the + * `fn` address to a typed `CFunction` pointer and invokes it directly. This is "path 1" from + * `docs/design/downcall-design.md`: ordinary JNI -> trampoline -> indirect call. ART cannot synthesize + * an arbitrary native call at runtime the way Kotlin/Native's cinterop can at compile time, so + * (unlike desktop, where a single cached `MethodHandle` per shape is built directly in this + * layer) the actual dispatch mechanism lives in the native trampoline; this file is a thin, + * zero-logic pass-through to `bindings`. `@CriticalNative`-based ART entry-point patching + * (path 2, replacing the JNI hop with a direct branch) is out of scope for this change. + */ + +internal actual fun downcall_V(fn: Long) = bindings.downcall_V(fn) +internal actual fun downcall_I(fn: Long): Long = bindings.downcall_I(fn) +internal actual fun downcall_F(fn: Long): Double = bindings.downcall_F(fn) +internal actual fun downcallI_V(fn: Long, a0: Long) = bindings.downcallI_V(fn, a0) +internal actual fun downcallI_I(fn: Long, a0: Long): Long = bindings.downcallI_I(fn, a0) +internal actual fun downcallI_F(fn: Long, a0: Long): Double = bindings.downcallI_F(fn, a0) +internal actual fun downcallF_I(fn: Long, a0: Double): Long = bindings.downcallF_I(fn, a0) +internal actual fun downcallII_V(fn: Long, a0: Long, a1: Long) = bindings.downcallII_V(fn, a0, a1) +internal actual fun downcallII_I(fn: Long, a0: Long, a1: Long): Long = bindings.downcallII_I(fn, a0, a1) +internal actual fun downcallIII_V(fn: Long, a0: Long, a1: Long, a2: Long) = bindings.downcallIII_V(fn, a0, a1, a2) +internal actual fun downcallIII_I(fn: Long, a0: Long, a1: Long, a2: Long): Long = bindings.downcallIII_I(fn, a0, a1, a2) +internal actual fun downcallIIII_I(fn: Long, a0: Long, a1: Long, a2: Long, a3: Long): Long = + bindings.downcallIIII_I(fn, a0, a1, a2, a3) +internal actual fun downcallIIIII_I(fn: Long, a0: Long, a1: Long, a2: Long, a3: Long, a4: Long): Long = + bindings.downcallIIIII_I(fn, a0, a1, a2, a3, a4) +internal actual fun downcallIIIIII_I(fn: Long, a0: Long, a1: Long, a2: Long, a3: Long, a4: Long, a5: Long): Long = + bindings.downcallIIIIII_I(fn, a0, a1, a2, a3, a4, a5) + +// ---- Symbol lookup ---- + +// Symbol names are a small fixed set (~330 CPython entry points) and `ffiSymbol` caches the +// resolved address by name, so each one is encoded at most once -- the intern cache, not scratch. +internal actual fun ffiSymbolRaw(name: String): Long = bindings.ffiSymbolRawN(internedUtf8(name)) + + + + +private class InternedString(val addr: Long, val buf: ByteBuffer) +private val internCache = ConcurrentHashMap() +// We cap at 4096. Python's own standard library and likely app code has many literals, +// but 4096 string pointers is very little memory and covers virtually all attribute accesses. +private const val CACHE_MAX_ENTRIES = 4096 + +private const val SCRATCH_SLOT_COUNT = 4 + +private class ScratchBuffer { + var buf: ByteBuffer = ByteBuffer.allocateDirect(1024) + var addr: Long = bindings.ffiDirectBufferAddress(buf) +} + +private class AndroidScratchSlots { + val slots = Array(SCRATCH_SLOT_COUNT) { ScratchBuffer() } + var index = 0 +} + +private val scratchThreadLocal = object : ThreadLocal() { + override fun initialValue() = AndroidScratchSlots() +} + +private fun encodeInto(s: String, buf: ByteBuffer) { + buf.clear() + var ascii = true + for (i in 0 until s.length) { + val c = s[i] + if (c.code > 0x7F) { + ascii = false + break + } + buf.put(c.code.toByte()) + } + if (ascii) { + buf.put(0) + return + } + // fallback for non-ASCII + val bytes = s.toByteArray(Charsets.UTF_8) + buf.clear() + buf.put(bytes) + buf.put(0) +} + +@PublishedApi internal actual fun internedUtf8(s: String): Long { + val cached = internCache[s] + if (cached != null) return cached.addr + + if (internCache.size >= CACHE_MAX_ENTRIES) { + return encodeScratchUtf8(s) + } + + val maxLen = s.length * 3 + 1 + val buf = ByteBuffer.allocateDirect(maxLen) + encodeInto(s, buf) + val addr = bindings.ffiDirectBufferAddress(buf) + val interned = InternedString(addr, buf) + internCache.putIfAbsent(s, interned)?.let { return it.addr } + return addr +} + +@PublishedApi internal actual fun encodeScratchUtf8(s: String): Long { + val maxLen = s.length * 3 + 1 + val state = scratchThreadLocal.get() + val slotIndex = state.index + state.index = (slotIndex + 1) % SCRATCH_SLOT_COUNT + val sb = state.slots[slotIndex] + if (sb.buf.capacity() < maxLen) { + var newCap = sb.buf.capacity() * 2 + while (newCap < maxLen) newCap *= 2 + sb.buf = ByteBuffer.allocateDirect(newCap) + sb.addr = bindings.ffiDirectBufferAddress(sb.buf) + } + encodeInto(s, sb.buf) + return sb.addr +} + +@PublishedApi internal actual fun freeUtf8(address: Long) = bindings.ffiFreeUtf8(address) + +internal actual fun ffiReadUtf8(ptr: Long): String? = bindings.ffiReadUtf8(ptr) + 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 new file mode 100644 index 000000000..350f4e300 --- /dev/null +++ b/python-multiplatform/src/androidMain/kotlin/python/native/ffi/UpcallEntry.android.kt @@ -0,0 +1,169 @@ +package python.native.ffi + +import python.multiplatform.ffi.upcall.UpcallTrampoline +import python.multiplatform.ffi.withGIL +import python.multiplatform.reflection.CallableHandle +import python.multiplatform.reflection.UpcallTable + +/** + * What the C entry points in `artMain/cinterop/jni_onload.def` call on every upcall. + * + * ### Why the boundary runs this way round + * + * Desktop turns a Kotlin method into a C function pointer with a Panama upcall stub, and + * Kotlin/Native does the same with `staticCFunction`. Android has neither: `androidMain` is + * Kotlin/JVM and the JVM it runs on is ART, which has no Panama. A `PyMethodDef`'s `ml_meth` has + * to be a real C function pointer, so the entry points are C functions in `jni_onload.def` and + * *they* call *this*, through `CallStaticLongMethod` against a class registered in `JNI_OnLoad`. + * + * That is the same inversion [python.multiplatform.ffi.ProxyCallbacks] already makes for + * `tp_traverse`/`tp_clear`, and this file is deliberately its twin: same registration mechanism, + * same `pmp_attach` on the C side, same rule that nothing may be thrown back into C. + * + * ### What is *not* here + * + * None of the marshalling. Turning a Python argument tuple into an `Array` and a Kotlin + * result back into a `PyObject *` is [UpcallTrampoline], which is `commonMain` and shared with + * every other platform -- `docs/design/upcall.md` §3.7 is only the + * address-publishing step, and on Android that step is the C shim plus these three methods. + * + * ### Rules these must obey + * + * - **Nothing may be thrown.** The caller is C reached from CPython. A Kotlin exception left + * pending across a JNI return aborts the ART runtime outright, so each of these is total: a + * failure leaves as `0` / `CallableHandle.NONE` / `0`, and the C side turns that into a Python + * error indicator. [UpcallTrampoline.invoke] already catches everything; the guards here are for + * the call itself, including a `` that fails. + * - **The GIL is taken by [UpcallTrampoline], not here.** CPython holds it when it calls a + * `PyCFunction`, but the trampoline still takes its own `PyGILState_Ensure`/`Release` pair + * unconditionally -- `commonMain/README.md`'s rule for any entry point reached from C, since the + * nesting-depth counter records only scopes Kotlin opened. `PyGILState_Ensure` is reentrant, so + * the second one costs a counter bump. + * - **Reference conventions belong to the trampoline too.** Arguments arrive borrowed and the + * result leaves as a new reference; nothing on this file's path touches a refcount. + * + * ### What this still costs + * + * Measured by `UpcallOverheadTest` on `pmp_api26` / `pmp_api36`, two runs each, per upcall: + * + * | | API 26 | API 36 | + * |---|---|---| + * | steady state, any thread | 1209–1329 ns | 2301–3086 ns | + * | **first upcall on a Python worker thread** | **139625–141958 ns** | **55917–125083 ns** | + * | the same call shape as a downcall, for scale | 1213–1217 ns | 980–1541 ns | + * + * The first row is the JNI upcall and the trampoline's marshalling, and it is now within ~1-2x of + * a downcall of the same shape. The second is the one cost that could not be removed: a + * `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. + * + * 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 + * that can act on it will look: here, in `pmp_upcall_invoke_meth`, and in `docs/design/upcall.md`. + */ +object UpcallCallbacks { + + /** + * `pm_invoke`: a resolved handle plus a borrowed `PyObject *` argument tuple, returning a + * **new** reference (or `0` with Python's error indicator set). + * + * @param callableHandle a raw [CallableHandle]; `-1` for a `self` that was not a valid one. + */ + @JvmStatic + fun invoke(callableHandle: Long, argsTuple: Long): Long = + try { + UpcallTrampoline.invoke(callableHandle, argsTuple) + } catch (t: Throwable) { + 0L + } + + /** + * `_pm_resolve`: a name resolved once, an integer from then on. + * + * Returns `CallableHandle.NONE.raw` (-1) for a name nothing claims, exactly as + * [UpcallTable.resolve] does; raising `AttributeError` is Python's job. Touches no C API, so + * unlike [invoke] it needs no GIL. + */ + @JvmStatic + fun resolve(name: String?): Long = + try { + if (name == null) CallableHandle.NONE.raw else UpcallTable.resolve(name).raw + } catch (t: Throwable) { + CallableHandle.NONE.raw + } + + /** + * `_pm_release`: what a proxy's `tp_dealloc` calls. + * + * 1 if it released a live entry, 0 for a handle already released or never issued. A double + * release must stay a no-op: the slot may belong to someone else by then. + */ + @JvmStatic + fun release(objectHandle: Long): Int = + try { + UpcallTrampoline.releaseObject(objectHandle) + } catch (t: Throwable) { + 0 + } + + /** + * `_pm_cancel`: what a cancelled `asyncio.Future`'s done callback calls, so a suspended Kotlin + * coroutine hears about the cancellation at its next `ensureActive()` rather than only when it + * finishes. + * + * 1 if it cancelled a live call, 0 for a handle that is stale, never issued, or names something + * that is not a pending call. Same `(long) -> int` shape as [release], so the C side is a copy + * of `pmp_upcall_release_meth` with one method ID changed. + */ + @JvmStatic + fun cancel(callHandle: Long): Int = + try { + UpcallTrampoline.cancelCall(callHandle) + } catch (t: Throwable) { + 0 + } +} + +/** + * Android's half of the upcall boundary: the few lines that give Python the *address* of an entry + * point. Named to match `nativeMain`'s `python.native.ffi.UpcallEntry`, because it is the same + * thing reached by this platform's own mechanism. + * + * The entry points themselves are C (see [UpcallCallbacks] for why), so all that is left here is + * asking for them to be installed under the GIL. + */ +object UpcallEntry { + + /** + * Installs `_pm_resolve`, `_pm_invoke`, `_pm_bind`, `_pm_release` and `_pm_cancel` into + * [namespace] (a Python dict). + * + * That set is the whole bootstrap: everything after it is Python calling Python objects. + * + * `_pm_invoke` -- the unbound `(handle, args_tuple) -> result` form -- used to be withheld, on + * the argument that it would put the handle in Python's hands as a separate argument. But + * `_pm_bind` already takes a raw handle from Python and gives back a callable over it, so + * `_pm_bind(h)(*a)` and `_pm_invoke(h, a)` are one capability with one failure mode, and the + * only consumer this bootstrap has is + * [python.multiplatform.ffi.upcall.PythonProxySource]'s generated module, every call in which + * is `_pm_invoke(handle, args)`. `nativeMain`'s `UpcallEntry.publish` reversed the same + * decision first, for the same reason. + * + * Callable from Kotlin without the GIL; takes it for the duration. + * + * @return true if all five landed. + */ + fun publish(namespace: NativePointer): Boolean { + // Forces UpcallCallbacks' -- and with it UpcallTrampoline's and UpcallTable's -- + // here rather than inside a running upcall. Otherwise the first class initialisation on + // this path happens on whatever thread CPython called from, while it holds the GIL, which + // is a strictly worse place for it; ProxyTypeFactory.createProxyType does the same for + // ProxyCallbacks and for the same reason. Resolving a name nothing claims is a pure + // lookup that returns -1. + UpcallCallbacks.resolve(null) + return withGIL { bindings.upcallPublish(namespace.toRawValue()) != 0 } + } +} diff --git a/python-multiplatform/src/androidMain/kotlin/python/native/ffi/UpcallTarget.kt b/python-multiplatform/src/androidMain/kotlin/python/native/ffi/UpcallTarget.kt new file mode 100644 index 000000000..fdd57a533 --- /dev/null +++ b/python-multiplatform/src/androidMain/kotlin/python/native/ffi/UpcallTarget.kt @@ -0,0 +1,45 @@ +package python.native.ffi + +import java.lang.ref.WeakReference + +/** + * The Kotlin side of the JNI upcall probes in `artMain/cinterop/jni_onload.def`. Test support + * that has to live in `androidMain` because `JNI_OnLoad` resolves it by name at load time. + */ +object UpcallTarget { + @JvmStatic + fun upcallPrimitive(x: Long): Long { + return x + } + + @JvmStatic + fun upcallString(s: String): Long { + return s.length.toLong() + } + + /** + * Weakly held `java.lang.Thread` peers, one per [trackCurrentThread] call. + * + * Weak, because whether the peer is still reachable *is* the measurement. ART holds an + * attached thread's peer as a GC root (`tlsPtr_.opeer`) and drops it in `Thread::Destroy`, so + * a reference that survives a collection means the attachment does too. That readout works on + * every API level, which `ThreadGroup.enumerate` does not: on API 26 it does not report the + * peer of an attached native thread even while that thread is running. + */ + private val tracked = mutableListOf>() + + /** Records the calling thread's peer weakly and returns its id. Called from JNI. */ + @JvmStatic + fun trackCurrentThread(): Long { + val self = Thread.currentThread() + synchronized(tracked) { tracked.add(WeakReference(self)) } + return self.id + } + + /** How many tracked peers a collection has *not* been able to reclaim. */ + @JvmStatic + fun liveTrackedCount(): Int = synchronized(tracked) { tracked.count { it.get() != null } } + + @JvmStatic + fun clearTracked(): Unit = synchronized(tracked) { tracked.clear() } +} diff --git a/python-multiplatform/src/androidMain/kotlin/python/native/ffi/bindings.kt b/python-multiplatform/src/androidMain/kotlin/python/native/ffi/bindings.kt index 0c98bd4d1..1a55088e5 100644 --- a/python-multiplatform/src/androidMain/kotlin/python/native/ffi/bindings.kt +++ b/python-multiplatform/src/androidMain/kotlin/python/native/ffi/bindings.kt @@ -10,6 +10,7 @@ object bindings { loadLibPython() } + /** external fun Py_Initialize() external fun Py_InitializeEx(sigint: Int) //external fun Py_InitializeFromConfig() @@ -20,7 +21,7 @@ object bindings { external fun Py_BytesMain(args: Array): Int external fun Py_RunMain(): Int external fun PyRun_SimpleString(command: String): Int - external fun PyRun_String(str: String, start: Int, globals: JNIPointer, locals: JNIPointer): JNIPointer? + // external fun PyRun_String(str: String, start: Int, globals: JNIPointer, locals: JNIPointer): JNIPointer? external fun Py_GetVersion(): String? external fun Py_GetPlatform(): String? external fun Py_GetCopyright(): String? @@ -36,6 +37,683 @@ object bindings { external fun PyLong_AsLongLong(p: JNIPointer): Long external fun PyLong_AsInt(p: JNIPointer): Int - external fun PyUnicode_FromString(str: String): JNIPointer? - external fun PyUnicode_AsUTF8(unicode: JNIPointer): String? + // external fun PyUnicode_FromString(str: String): JNIPointer? + // external fun PyUnicode_AsUTF8(unicode: JNIPointer): String? + */ + + + //************************************************** + // Section 1 + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun Py_Initialize() + @JvmStatic external fun Py_InitializeExN(initsigs: Int) + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun echo0(x: Long): Long + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun echo1(x: Long): Long + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun echo2(x: Long): Long + + // Same C body as echo0, bound under the other two conventions so a benchmark can + // isolate the transition cost. See jni_onload.def. + @JvmStatic + external fun echoNormal(x: Long): Long + @JvmStatic + @dalvik.annotation.optimization.FastNative + external fun echoFast(x: Long): Long + + // @CriticalNative reached by name-based linking, NOT via RegisterNatives -- see + // JNIOnLoadExporter.echoCriticalNamed. Isolates the binding method from the convention. + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun echoCriticalNamed(x: Long): Long + + // The same real CPython function (PyList_Size) under the other two conventions, so the + // comparison can be made on a call that does actual work rather than on an empty echo. + @JvmStatic + @dalvik.annotation.optimization.FastNative + external fun PyList_SizeFast(list: Long): Long + @JvmStatic + external fun PyList_SizeNormal(list: Long): Long + + // ---- @FastNative twins of the migrated functions ---- + // + // ART fast-paths @CriticalNative up to API 33 and @FastNative from 34 onward; each is + // roughly 20x the other on the wrong side of that line (docs/design/downcall-design.md). Both + // are declared so EmbedAPI.android.kt can select per device. @FastNative still receives + // JNIEnv and jclass, hence the separate C wrappers in jni_onload.def. + @JvmStatic @dalvik.annotation.optimization.FastNative external fun Py_InitializeF() + @JvmStatic @dalvik.annotation.optimization.FastNative external fun Py_IsInitializedF(): Int + @JvmStatic @dalvik.annotation.optimization.FastNative external fun Py_FinalizeF() + @JvmStatic @dalvik.annotation.optimization.FastNative external fun PyErr_ClearF() + @JvmStatic @dalvik.annotation.optimization.FastNative external fun PyLong_FromLongLongF(v: Long): Long + @JvmStatic @dalvik.annotation.optimization.FastNative external fun PyList_SizeF(list: Long): Long + @JvmStatic @dalvik.annotation.optimization.FastNative external fun PyRun_SimpleStringF(command: Long): Int + @JvmStatic @dalvik.annotation.optimization.FastNative external fun Py_GetVersionF(): Long + @JvmStatic @dalvik.annotation.optimization.FastNative external fun PyImport_ImportModuleF(name: Long): Long + @JvmStatic @dalvik.annotation.optimization.FastNative external fun PyObject_GetAttrStringF(o: Long, name: Long): Long + @JvmStatic @dalvik.annotation.optimization.FastNative external fun PyErr_OccurredF(): Long + + // ---- Ordinary JNI, for calls that must NOT block GC or forbid JVM re-entry ---- + // + // @CriticalNative and @FastNative both stop the collector for the duration of the call, + // and @CriticalNative additionally has no JNIEnv, so nothing under it can re-enter the + // runtime. These functions can execute arbitrary Python -- module top-level code, a + // __getattr__, a __del__ reached by dropping the last reference, interpreter start-up and + // shutdown -- which is unbounded in time and, once Kotlin callables are exposed to + // Python, re-enters the JVM. They stay on the ordinary path at every API level. + @JvmStatic external fun Py_InitializeN() + @JvmStatic external fun Py_FinalizeN() + @JvmStatic external fun PyErr_ClearN() + @JvmStatic external fun PyRun_SimpleStringN(command: Long): Int + @JvmStatic external fun PyImport_ImportModuleN(name: Long): Long + @JvmStatic external fun PyImport_ImportN(name: Long): Long + @JvmStatic external fun PyObject_GetAttrStringN(o: Long, name: Long): Long + + // ---- Composition probes: the whole binder operation in one crossing ---- + // asmGetAttr replaces ffiAllocUtf8 + PyObject_GetAttrString + PyErr_Clear + ffiFreeUtf8. + // asmListToArray replaces PyList_Size + N x PyList_GetItem, filling the caller's array + // and returning how many elements it wrote. + // Borrowed reference, leaf: pure index into the list's item array. + // + // Both conventions, selected by `preferFastNative` like every other pair here. This one was + // @CriticalNative-only until the audit follow-up, which made it the single declaration that + // ignored the device axis -- and it is the per-element call of bulk list iteration, so on API + // 34+ it was paying 24-44 ns per element where @FastNative pays 2-4. See docs/design/downcall-design.md + // and docs/investigations/jni-call-convention-audit.md. + @JvmStatic @dalvik.annotation.optimization.CriticalNative external fun PyList_GetItemRaw(list: Long, i: Long): Long + @JvmStatic @dalvik.annotation.optimization.FastNative external fun PyList_GetItemRawF(list: Long, i: Long): Long + // Python3.exec composed into one crossing -- see jni_onload.def. + // Address of a DirectByteBuffer's off-heap storage. Called once per buffer, not per + // string -- see jni_onload.def. + @JvmStatic external fun ffiDirectBufferAddress(buf: java.nio.ByteBuffer): Long + @JvmStatic external fun asmExec(code: String): Int + @JvmStatic external fun asmGetAttr(obj: Long, name: String): Long + @JvmStatic external fun asmListToArray(list: Long, out: LongArray): Int + + @JvmStatic external fun testUpcallPrimitive(x: Long): Long + @JvmStatic external fun testUpcallString(s: String): Long + @JvmStatic external fun testUpcallUnattached(x: Long): Long + @JvmStatic external fun testThreadCreateFloor(x: Long): Long + + /** + * Attaches a bare pthread as a daemon, reads back the `java.lang.Thread` id it was given, and + * lets the pthread exit **without** detaching. Returns that id, or -1. + * + * The evidence `pmp_attach` rests on when it keeps an attachment rather than dropping it per + * call: whether ART aborts for this is the fact the whole choice turns on, and it is observed + * here rather than read off `Thread::ThreadExitCallback`. See + * `UpcallThreadAttachTest.aThreadThatExitsWithoutDetachingLeaksItsPeerRatherThanAbortingArt`. + */ + @JvmStatic external fun testAttachWithoutDetach(x: Long): Long + + /** + * Which JNI calling convention this device fast-paths. + * + * Measured, not guessed: @CriticalNative is at or below the timing floor from API 26 + * through 33 and costs ~24ns on 34 and ~44ns on API 36 hardware, while @FastNative is + * ~24-39ns through API 31 and ~2ns from 33 on. Both are cheap at 33, so the threshold is + * placed where being off by one costs the least. See docs/design/downcall-design.md. + * + * Read once into a static final so the JIT folds the branch at each call site. + */ + @JvmStatic + val preferFastNative: Boolean = + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE + + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun Py_IsInitialized(): Int + @JvmStatic external fun Py_IsFinalizingN(): Int + @JvmStatic external fun Py_FinalizeExN(): Int + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun Py_Finalize() + // external fun Py_BytesMain(args: Array): Int // 수동 추가 + @JvmStatic external fun Py_RunMainN(): Int + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun Py_GetVersion(): Long + @JvmStatic external fun Py_GetPlatformN(): Long + @JvmStatic external fun Py_GetCopyrightN(): Long + @JvmStatic external fun Py_GetCompilerN(): Long + @JvmStatic external fun Py_GetBuildInfoN(): Long + // external fun PyEval_InitThreads() + // external fun PyThreadState_GetDict(): JNIPointer? + + // external fun PyGILState_Ensure(): Int + // external fun PyGILState_Release(state: Int) + // external fun PyGILState_GetThisThreadState(): JNIPointer? + // external fun PyEval_SaveThread(): JNIPointer? + // external fun PyEval_RestoreThread(tstate: JNIPointer) + + + // Section 2 + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun PyRun_SimpleString(command: Long): Int // 수동 추가 + // external fun PyRun_String(str: String, start: Int, globals: JNIPointer, locals: JNIPointer): JNIPointer? // 수동 추가 + // `str` is arbitrary source text and `filename` a repeated identifier, so the two arguments + // take different marshalling paths on the Kotlin side -- see EmbedAPI.android.kt. + @JvmStatic external fun Py_CompileStringN(str: Long, filename: Long, start: Int): Long + @JvmStatic external fun PyEval_EvalCodeN(co: Long, globals: Long, locals: Long): Long + + + // Section 3 + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun PyErr_Clear() + @JvmStatic external fun PyErr_PrintExN(set_sys_last_vars: Int) + @JvmStatic external fun PyErr_PrintN() + @JvmStatic external fun PyErr_WriteUnraisableN(obj: Long) + @JvmStatic external fun PyErr_DisplayExceptionN(exc: Long) + // PyErr_SetString is bound as PyErr_SetStringN below; the String-taking declaration is gone + // deliberately, because reaching it would fall back to the name-linked @CName export (ROADMAP §2). + @JvmStatic external fun PyErr_SetObjectN(type: Long, value: Long) + @JvmStatic external fun PyErr_SetNoneN(type: Long) + @JvmStatic external fun PyErr_BadArgumentN(): Int + @JvmStatic external fun PyErr_NoMemoryN(): Long + @JvmStatic external fun PyErr_SetFromErrnoN(type: Long): Long + @JvmStatic external fun PyErr_SetFromErrnoWithFilenameObjectN(type: Long, filenameObject: Long): Long + @JvmStatic external fun PyErr_SetFromErrnoWithFilenameObjectsN(type: Long, filenameObject: Long, filenameObject2: Long): Long + @JvmStatic external fun PyErr_SetFromErrnoWithFilenameN(type: Long, filename: Long): Long + @JvmStatic external fun PyErr_SetImportErrorN(msg: Long, name: Long, path: Long): Long + @JvmStatic external fun PyErr_SetImportErrorSubclassN(exception: Long, msg: Long, name: Long, path: Long): Long + @JvmStatic external fun PyErr_SyntaxLocationExN(filename: Long, lineno: Int, col_offset: Int) + @JvmStatic external fun PyErr_SyntaxLocationN(filename: Long, lineno: Int) + @JvmStatic external fun PyErr_BadInternalCallN() + @JvmStatic external fun PyErr_WarnExplicitN(category: Long, message: Long, filename: Long, lineno: Int, module: Long, registry: Long): Int + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun PyErr_Occurred(): Long + @JvmStatic external fun PyErr_ExceptionMatchesN(exc: Long): Int + @JvmStatic external fun PyErr_GivenExceptionMatchesN(given: Long, exc: Long): Int + @JvmStatic external fun PyErr_GetRaisedExceptionN(): Long + @JvmStatic external fun PyErr_SetRaisedExceptionN(exc: Long) + @JvmStatic external fun PyErr_RestoreN(type: Long, value: Long, traceback: Long) + @JvmStatic external fun PyErr_GetHandledExceptionN(): Long + @JvmStatic external fun PyErr_SetHandledExceptionN(exc: Long) + @JvmStatic external fun PyErr_SetExcInfoN(type: Long, value: Long, traceback: Long) + @JvmStatic external fun PyErr_CheckSignalsN(): Int + @JvmStatic external fun PyErr_SetInterruptN() + @JvmStatic external fun PyErr_SetInterruptExN(signum: Int): Int + @JvmStatic external fun PyErr_NewExceptionN(name: Long, base: Long, dict: Long): Long + @JvmStatic external fun PyErr_NewExceptionWithDocN(name: Long, doc: Long, base: Long, dict: Long): Long + @JvmStatic external fun PyException_GetTracebackN(ex: Long): Long + @JvmStatic external fun PyException_SetTracebackN(ex: Long, tb: Long): Int + @JvmStatic external fun PyException_GetContextN(ex: Long): Long + @JvmStatic external fun PyException_SetContextN(ex: Long, ctx: Long) + @JvmStatic external fun PyException_GetCauseN(ex: Long): Long + @JvmStatic external fun PyException_SetCauseN(ex: Long, cause: Long) + @JvmStatic external fun PyException_GetArgsN(ex: Long): Long + @JvmStatic external fun PyException_SetArgsN(ex: Long, args: Long) + @JvmStatic external fun PyUnicodeEncodeError_GetEncodingN(exc: Long): Long + @JvmStatic external fun PyUnicodeTranslateError_GetObjectN(exc: Long): Long + @JvmStatic external fun PyUnicodeTranslateError_GetReasonN(exc: Long): Long + @JvmStatic external fun PyUnicodeTranslateError_SetReasonN(exc: Long, reason: Long): Int + @JvmStatic external fun Py_EnterRecursiveCallN(where: Long): Int + @JvmStatic external fun Py_LeaveRecursiveCallN() + @JvmStatic external fun Py_ReprEnterN(o: Long): Int + @JvmStatic external fun Py_ReprLeaveN(o: Long) + + + // Section 4 + // external fun Py_NewRef(o: JNIPointer): JNIPointer? + // external fun Py_XNewRef(o: JNIPointer): JNIPointer? + // external fun Py_IncRef(o: JNIPointer) + // external fun Py_DecRef(o: JNIPointer) + + + // Section 5 + @JvmStatic external fun PyOS_FSPathN(path: Long): Long + + + // Section 6 + @JvmStatic external fun PySys_GetObjectN(name: Long): Long + @JvmStatic external fun PySys_SetObjectN(name: Long, v: Long): Int + @JvmStatic external fun PySys_GetXOptionsN(): Long + @JvmStatic external fun PySys_AuditTupleN(event: Long, args: Long): Int + + + // Section 7 + @JvmStatic external fun Py_FatalErrorN(message: Long) + @JvmStatic external fun Py_ExitN(status: Int) + + + // Section 8 + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun PyImport_ImportModule(name: Long): Long + @JvmStatic external fun PyImport_ImportModuleLevelObjectN(name: Long, globals: Long, locals: Long, fromlist: Long, level: Int): Long + @JvmStatic external fun PyImport_ImportModuleLevelN(name: Long, globals: Long, locals: Long, fromlist: Long, level: Int): Long + // external fun PyImport_Import(name: JNIPointer): JNIPointer? + @JvmStatic external fun PyImport_ReloadModuleN(m: Long): Long + // external fun PyImport_AddModuleRef(name: String): JNIPointer? + @JvmStatic external fun PyImport_AddModuleObjectN(name: Long): Long + // PyImport_AddModule is bound as PyImport_AddModuleN below. + @JvmStatic external fun PyImport_ExecCodeModuleN(name: Long, co: Long): Long + @JvmStatic external fun PyImport_ExecCodeModuleExN(name: Long, co: Long, pathname: Long): Long + @JvmStatic external fun PyImport_ExecCodeModuleObjectN(name: Long, co: Long, pathname: Long, cpathname: Long): Long + @JvmStatic external fun PyImport_ExecCodeModuleWithPathnamesN(name: Long, co: Long, pathname: Long, cpathname: Long): Long + // CPython-owned const char*; the JVM side reads it with ffiReadUtf8 and must not free it. + @JvmStatic external fun PyImport_GetMagicTagN(): Long + @JvmStatic external fun PyImport_GetModuleDictN(): Long + @JvmStatic external fun PyImport_GetModuleN(name: Long): Long + @JvmStatic external fun PyImport_GetImporterN(path: Long): Long + @JvmStatic external fun PyImport_ImportFrozenModuleObjectN(name: Long): Int + @JvmStatic external fun PyImport_ImportFrozenModuleN(name: Long): Int + + + // Section 9 + @JvmStatic external fun PyEval_GetBuiltinsN(): Long + @JvmStatic external fun PyEval_GetLocalsN(): Long + @JvmStatic external fun PyEval_GetGlobalsN(): Long + @JvmStatic external fun PyEval_GetFrameBuiltinsN(): Long + @JvmStatic external fun PyEval_GetFrameLocalsN(): Long + @JvmStatic external fun PyEval_GetFrameGlobalsN(): Long + // Both return a CPython-owned const char*; read with ffiReadUtf8, never freed here. + @JvmStatic external fun PyEval_GetFuncNameN(func: Long): Long + @JvmStatic external fun PyEval_GetFuncDescN(func: Long): Long + + + // Section 10 + @JvmStatic external fun PyObject_HasAttrWithErrorN(o: Long, attr_name: Long): Int + @JvmStatic external fun PyObject_HasAttrStringWithErrorN(o: Long, attr_name: Long): Int + @JvmStatic external fun PyObject_HasAttrN(o: Long, attr_name: Long): Int + @JvmStatic external fun PyObject_HasAttrStringN(o: Long, attr_name: Long): Int + @JvmStatic external fun PyObject_GetAttrN(o: Long, attr_name: Long): Long + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun PyObject_GetAttrString(o: Long, attr_name: Long): Long + @JvmStatic external fun PyObject_GenericGetAttrN(o: Long, name: Long): Long + @JvmStatic external fun PyObject_SetAttrN(o: Long, attr_name: Long, v: Long): Int + // PyObject_SetAttrString / PyObject_DelAttrString are bound as the *N variants below. + @JvmStatic external fun PyObject_GenericSetAttrN(o: Long, name: Long, value: Long): Int + @JvmStatic external fun PyObject_DelAttrN(o: Long, attr_name: Long): Int + @JvmStatic external fun PyObject_RichCompareN(o1: Long, o2: Long, opid: Int): Long + @JvmStatic external fun PyObject_RichCompareBoolN(o1: Long, o2: Long, opid: Int): Int + @JvmStatic external fun PyObject_FormatN(obj: Long, format_spec: Long): Long + @JvmStatic external fun PyObject_ReprN(o: Long): Long + @JvmStatic external fun PyObject_ASCIIN(o: Long): Long + // external fun PyObject_Str(o: JNIPointer): JNIPointer? + @JvmStatic external fun PyObject_BytesN(o: Long): Long + @JvmStatic external fun PyObject_IsSubclassN(derived: Long, cls: Long): Int + @JvmStatic external fun PyObject_IsInstanceN(inst: Long, cls: Long): Int + // external fun PyObject_IsTrue(o: JNIPointer): Int + @JvmStatic external fun PyObject_NotN(o: Long): Int + @JvmStatic external fun PyObject_TypeN(o: Long): Long + @JvmStatic external fun PyObject_SizeN(o: Long): Long + @JvmStatic external fun PyObject_LengthN(o: Long): Long + @JvmStatic external fun PyObject_GetItemN(o: Long, key: Long): Long + @JvmStatic external fun PyObject_SetItemN(o: Long, key: Long, v: Long): Int + @JvmStatic external fun PyObject_DelItemN(o: Long, key: Long): Int + @JvmStatic external fun PyObject_DirN(o: Long): Long + @JvmStatic external fun PyObject_GetIterN(o: Long): Long + @JvmStatic external fun PyObject_GetAIterN(o: Long): Long + + + // Section 11 + @JvmStatic external fun PyVectorcall_CallN(callable: Long, tuple: Long, dict: Long): Long + // A second, name-linked `PyObject_Call` declaration used to sit next to PyObject_CallN with no + // call site. Removed rather than registered: an unregistered declaration is the landmine, and + // a third binding for a function that already has one buys nothing. + @JvmStatic external fun PyObject_CallN(callable: Long, args: Long, kwargs: Long): Long + // external fun PyObject_CallNoArgs(callable: JNIPointer): JNIPointer? + // external fun PyObject_CallObject(callable: JNIPointer, args: JNIPointer): JNIPointer? + @JvmStatic external fun PyCallable_CheckN(o: Long): Int + + + // Section 12 + @JvmStatic external fun PyNumber_CheckN(o: Long): Int + @JvmStatic external fun PyNumber_AddN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_SubtractN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_MultiplyN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_MatrixMultiplyN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_FloorDivideN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_TrueDivideN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_RemainderN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_DivmodN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_PowerN(o1: Long, o2: Long, o3: Long): Long + @JvmStatic external fun PyNumber_NegativeN(o: Long): Long + @JvmStatic external fun PyNumber_PositiveN(o: Long): Long + @JvmStatic external fun PyNumber_AbsoluteN(o: Long): Long + @JvmStatic external fun PyNumber_InvertN(o: Long): Long + @JvmStatic external fun PyNumber_LshiftN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_RshiftN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_AndN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_XorN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_OrN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlaceAddN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlaceSubtractN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlaceMultiplyN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlaceMatrixMultiplyN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlaceFloorDivideN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlaceTrueDivideN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlaceRemainderN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlacePowerN(o1: Long, o2: Long, o3: Long): Long + @JvmStatic external fun PyNumber_InPlaceLshiftN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlaceRshiftN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlaceAndN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlaceXorN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_InPlaceOrN(o1: Long, o2: Long): Long + @JvmStatic external fun PyNumber_LongN(o: Long): Long + @JvmStatic external fun PyNumber_FloatN(o: Long): Long + @JvmStatic external fun PyNumber_IndexN(o: Long): Long + @JvmStatic external fun PyNumber_ToBaseN(n: Long, base: Int): Long + @JvmStatic external fun PyIndex_CheckN(o: Long): Int + + + // Section 13 + @JvmStatic external fun PySequence_CheckN(o: Long): Int + @JvmStatic external fun PySequence_ConcatN(o1: Long, o2: Long): Long + @JvmStatic external fun PySequence_InPlaceConcatN(o1: Long, o2: Long): Long + @JvmStatic external fun PySequence_ContainsN(o: Long, value: Long): Int + @JvmStatic external fun PySequence_ListN(o: Long): Long + @JvmStatic external fun PySequence_TupleN(o: Long): Long + @JvmStatic external fun PySequence_FastN(o: Long, m: Long): Long + + + // Section 14 + @JvmStatic external fun PyMapping_CheckN(o: Long): Int + @JvmStatic external fun PyMapping_GetItemStringN(o: Long, key: Long): Long + @JvmStatic external fun PyMapping_SetItemStringN(o: Long, key: Long, v: Long): Int + @JvmStatic external fun PyMapping_HasKeyWithErrorN(o: Long, key: Long): Int + @JvmStatic external fun PyMapping_HasKeyStringWithErrorN(o: Long, key: Long): Int + @JvmStatic external fun PyMapping_HasKeyN(o: Long, key: Long): Int + @JvmStatic external fun PyMapping_HasKeyStringN(o: Long, key: Long): Int + @JvmStatic external fun PyMapping_KeysN(o: Long): Long + @JvmStatic external fun PyMapping_ValuesN(o: Long): Long + @JvmStatic external fun PyMapping_ItemsN(o: Long): Long + + + // Section 15 + @JvmStatic external fun PyIter_CheckN(o: Long): Int + @JvmStatic external fun PyAIter_CheckN(o: Long): Int + @JvmStatic external fun PyIter_NextN(o: Long): Long + + + // Section 16 + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun PyLong_FromLongLong(v: Long): Long + @JvmStatic external fun PyLong_FromDoubleN(v: Double): Long + @JvmStatic external fun PyLong_AsIntN(obj: Long): Int + @JvmStatic external fun PyLong_AsLongLongN(obj: Long): Long + @JvmStatic external fun PyLong_AsDoubleN(pylong: Long): Double + @JvmStatic external fun PyLong_GetInfoN(): Long + + + // Section 17 + @JvmStatic external fun PyBool_FromLongN(v: Long): Long + + + // Section 18 + @JvmStatic external fun PyFloat_FromStringN(str: Long): Long + @JvmStatic external fun PyFloat_FromDoubleN(v: Double): Long + @JvmStatic external fun PyFloat_AsDoubleN(pyfloat: Long): Double + @JvmStatic external fun PyFloat_GetInfoN(): Long + @JvmStatic external fun PyFloat_GetMaxN(): Double + @JvmStatic external fun PyFloat_GetMinN(): Double + + + // Section 19 + @JvmStatic external fun PyBytes_FromStringN(v: Long): Long + @JvmStatic external fun PyBytes_FromObjectN(o: Long): Long + // char* into the bytes object's own buffer -- valid while `o` lives, never freed here. + @JvmStatic external fun PyBytes_AsStringN(o: Long): Long + + + // Section 20 + @JvmStatic external fun PyByteArray_FromObjectN(o: Long): Long + @JvmStatic external fun PyByteArray_ConcatN(a: Long, b: Long): Long + @JvmStatic external fun PyByteArray_AsStringN(bytearray: Long): Long + + + // Section 21 + @JvmStatic external fun PyUnicode_IsIdentifierN(unicode: Long): Int + // external fun PyUnicode_FromString(str: String): JNIPointer? + @JvmStatic external fun PyUnicode_FromObjectN(obj: Long): Long + @JvmStatic external fun PyUnicode_FromEncodedObjectN(obj: Long, encoding: Long, errors: Long): Long + @JvmStatic external fun PyUnicode_DecodeLocaleN(str: Long, errors: Long): Long + @JvmStatic external fun PyUnicode_EncodeLocaleN(unicode: Long, errors: Long): Long + @JvmStatic external fun PyUnicode_DecodeFSDefaultN(str: Long): Long + @JvmStatic external fun PyUnicode_EncodeFSDefaultN(unicode: Long): Long + @JvmStatic external fun PyUnicode_AsEncodedStringN(unicode: Long, encoding: Long, errors: Long): Long + @JvmStatic external fun PyUnicode_AsUTF8StringN(unicode: Long): Long + // external fun PyUnicode_AsUTF8(unicode: JNIPointer): String? // 수동 추가 + @JvmStatic external fun PyUnicode_AsUTF32StringN(unicode: Long): Long + @JvmStatic external fun PyUnicode_AsUTF16StringN(unicode: Long): Long + @JvmStatic external fun PyUnicode_AsUnicodeEscapeStringN(unicode: Long): Long + @JvmStatic external fun PyUnicode_AsRawUnicodeEscapeStringN(unicode: Long): Long + @JvmStatic external fun PyUnicode_AsLatin1StringN(unicode: Long): Long + @JvmStatic external fun PyUnicode_AsASCIIStringN(unicode: Long): Long + @JvmStatic external fun PyUnicode_AsCharmapStringN(unicode: Long, mapping: Long): Long + @JvmStatic external fun PyUnicode_TranslateN(unicode: Long, table: Long, errors: Long): Long + @JvmStatic external fun PyUnicode_ConcatN(left: Long, right: Long): Long + @JvmStatic external fun PyUnicode_SplitlinesN(unicode: Long, keepends: Int): Long + @JvmStatic external fun PyUnicode_JoinN(separator: Long, seq: Long): Long + @JvmStatic external fun PyUnicode_CompareN(left: Long, right: Long): Int + @JvmStatic external fun PyUnicode_EqualToUTF8N(unicode: Long, string: Long): Int + @JvmStatic external fun PyUnicode_CompareWithASCIIStringN(unicode: Long, string: Long): Int + @JvmStatic external fun PyUnicode_RichCompareN(left: Long, right: Long, op: Int): Long + @JvmStatic external fun PyUnicode_FormatN(format: Long, args: Long): Long + @JvmStatic external fun PyUnicode_ContainsN(unicode: Long, substr: Long): Int + @JvmStatic external fun PyUnicode_InternFromStringN(str: Long): Long + + + // Section 22 + @JvmStatic external fun PyList_NewN(len: Long): Long + @JvmStatic + @dalvik.annotation.optimization.CriticalNative + external fun PyList_Size(list: Long): Long + // PyList_GetItem is bound as the PyList_GetItemRaw / PyList_GetItemRawF pair above; the + // name-linked `PyList_GetItem` declaration that used to sit here had no call site. + @JvmStatic external fun PyList_SetItemN(list: Long, index: Long, item: Long): Int + @JvmStatic external fun PyList_InsertN(list: Long, index: Long, item: Long): Int + @JvmStatic external fun PyList_AppendN(list: Long, item: Long): Int + @JvmStatic external fun PyList_SortN(list: Long): Int + @JvmStatic external fun PyList_ReverseN(list: Long): Int + @JvmStatic external fun PyList_AsTupleN(list: Long): Long + + + // Section 23 + // external fun PyDict_New(): JNIPointer? + @JvmStatic external fun PyDict_SizeN(p: Long): Long + @JvmStatic external fun PyDictProxy_NewN(mapping: Long): Long + @JvmStatic external fun PyDict_ClearN(p: Long) + @JvmStatic external fun PyDict_ContainsN(p: Long, key: Long): Int + @JvmStatic external fun PyDict_CopyN(p: Long): Long + // external fun PyDict_SetItem(p: JNIPointer, key: JNIPointer, v: JNIPointer): Int + // external fun PyDict_SetItemString(p: JNIPointer, key: String, v: JNIPointer): Int + @JvmStatic external fun PyDict_DelItemN(p: Long, key: Long): Int + @JvmStatic external fun PyDict_DelItemStringN(p: Long, key: Long): Int + @JvmStatic external fun PyDict_GetItemN(p: Long, key: Long): Long + @JvmStatic external fun PyDict_GetItemWithErrorN(p: Long, key: Long): Long + // external fun PyDict_GetItemString(p: JNIPointer, key: String): JNIPointer? + @JvmStatic external fun PyDict_ItemsN(p: Long): Long + @JvmStatic external fun PyDict_KeysN(p: Long): Long + @JvmStatic external fun PyDict_ValuesN(p: Long): Long + @JvmStatic external fun PyDict_MergeN(a: Long, b: Long, override: Int): Int + @JvmStatic external fun PyDict_UpdateN(a: Long, b: Long): Int + @JvmStatic external fun PyDict_MergeFromSeq2N(a: Long, seq2: Long, override: Int): Int + + + // Section 24 + @JvmStatic external fun PySet_NewN(iterable: Long): Long + @JvmStatic external fun PyFrozenSet_NewN(iterable: Long): Long + @JvmStatic external fun PySet_ContainsN(anyset: Long, key: Long): Int + @JvmStatic external fun PySet_SizeN(anyset: Long): Long + @JvmStatic external fun PySet_AddN(set: Long, key: Long): Int + @JvmStatic external fun PySet_DiscardN(set: Long, key: Long): Int + @JvmStatic external fun PySet_PopN(set: Long): Long + @JvmStatic external fun PySet_ClearN(set: Long): Int + + + // Section 25 + @JvmStatic external fun PySeqIter_NewN(seq: Long): Long + @JvmStatic external fun PyCallIter_NewN(callable: Long, sentinel: Long): Long + + + // Section 26 + @JvmStatic external fun PyWeakref_NewRefN(ob: Long, callback: Long): Long + @JvmStatic external fun PyWeakref_NewProxyN(ob: Long, callback: Long): Long + @JvmStatic external fun PyWeakref_GetRefN(ref: Long): Long + @JvmStatic external fun PyObject_ClearWeakRefsN(o: Long) + + + // Section 27 + @JvmStatic external fun PyType_IsSubtypeN(a: Long, b: Long): Int + @JvmStatic external fun PyType_ReadyN(type: Long): Int + @JvmStatic external fun PyType_GetNameN(type: Long): Long + @JvmStatic external fun PyType_GetFullyQualifiedNameN(type: Long): Long + @JvmStatic external fun PyType_GetModuleNameN(type: Long): Long + @JvmStatic external fun PyType_GetModuleN(type: Long): Long + + + // Section 28 + // external fun PyTuple_New(len: Long): JNIPointer? + @JvmStatic external fun PyTuple_SizeN(p: Long): Long + @JvmStatic external fun PyTuple_GetItemN(p: Long, pos: Long): Long + @JvmStatic external fun PyTuple_GetSliceN(p: Long, low: Long, high: Long): Long + // external fun PyTuple_SetItem(p: JNIPointer, pos: Long, o: JNIPointer): Int + + + // Section 29 + @JvmStatic external fun PyModule_GetNameN(module: Long): Long + @JvmStatic external fun PyModule_GetDictN(module: Long): Long + @JvmStatic external fun PyModule_GetFilenameObjectN(module: Long): Long + + + //************************************************** + // Shape vocabulary (see docs/design/downcall-design.md and jvmMain/.../ShapeDowncalls.kt). + // + // Unlike every other declaration in this file (one native method per CPython function), + // these 14 are reused across all ~330 CPython functions of a given shape -- the target + // function's address travels as the leading `fn` argument. + // + // They used to reach the `@CName` trampolines in `nativeMain/.../EmbedAPI.native.kt` by name. + // That is the broken path, and it is worse here than anywhere else: the shifted argument + // register would be the *function pointer*, so the failure mode is a wild call rather than a + // bad PyObject*. The indirect call happens in `jni_onload.def` now, which removes the + // fallback and the hop through Kotlin/Native at the same time. + @JvmStatic external fun downcall_V(fn: Long) + @JvmStatic external fun downcall_I(fn: Long): Long + @JvmStatic external fun downcall_F(fn: Long): Double + @JvmStatic external fun downcallI_V(fn: Long, a0: Long) + @JvmStatic external fun downcallI_I(fn: Long, a0: Long): Long + @JvmStatic external fun downcallI_F(fn: Long, a0: Long): Double + @JvmStatic external fun downcallF_I(fn: Long, a0: Double): Long + @JvmStatic external fun downcallII_V(fn: Long, a0: Long, a1: Long) + @JvmStatic external fun downcallII_I(fn: Long, a0: Long, a1: Long): Long + @JvmStatic external fun downcallIII_V(fn: Long, a0: Long, a1: Long, a2: Long) + @JvmStatic external fun downcallIII_I(fn: Long, a0: Long, a1: Long, a2: Long): Long + @JvmStatic external fun downcallIIII_I(fn: Long, a0: Long, a1: Long, a2: Long, a3: Long): Long + @JvmStatic external fun downcallIIIII_I(fn: Long, a0: Long, a1: Long, a2: Long, a3: Long, a4: Long): Long + @JvmStatic external fun downcallIIIIII_I(fn: Long, a0: Long, a1: Long, a2: Long, a3: Long, a4: Long, a5: Long): Long + + // Symbol lookup (uncached primitive -- caching happens on the jvmMain side, see ffiSymbol). + // `name` is an already-encoded C string address, not a jstring: ffiSymbol keys its cache by + // name, so each symbol name is encoded once and interning it costs nothing extra. + @JvmStatic external fun ffiSymbolRawN(name: Long): Long + + // UTF-8 string marshalling. See the lifetime discussion in jvmMain/.../ShapeDowncalls.kt: + // ffiAllocUtf8's result must be released with ffiFreeUtf8 by the caller; ffiReadUtf8 never + // frees its input. + // + // These three are the only declarations left in this file that ART resolves by name, and they + // are safe: they are hand-written in `artMain/.../JNIOnLoadExporter.kt` with the correct + // `JNIEnv*`/`jclass` prologue and a real `jstring`, so the convention matches. They cannot + // move into `jni_onload.def` -- the buffers they allocate and read belong to Kotlin/Native. + external fun ffiAllocUtf8(str: String): Long + external fun ffiFreeUtf8(ptr: Long) + external fun ffiReadUtf8(ptr: Long): String? + + @JvmStatic @dalvik.annotation.optimization.CriticalNative external fun PyUnicode_AsUTF8(unicode: Long): Long + @JvmStatic @dalvik.annotation.optimization.FastNative external fun PyUnicode_AsUTF8F(unicode: Long): Long + @JvmStatic @dalvik.annotation.optimization.CriticalNative external fun PyUnicode_FromString(str: Long): Long + @JvmStatic @dalvik.annotation.optimization.FastNative external fun PyUnicode_FromStringF(str: Long): Long + + @JvmStatic external fun PyImport_AddModuleRefN(name: Long): Long + // Registered after the name-linked fallback crashed the object model on Android: the second + // commonTest case died with SIGSEGV at a truncated pointer inside PyImport_AddModule. + @JvmStatic external fun PyImport_AddModuleN(name: Long): Long + @JvmStatic external fun PyErr_SetStringN(type: Long, message: Long) + @JvmStatic external fun PyObject_SetAttrStringN(o: Long, attr_name: Long, v: Long): Int + @JvmStatic external fun PyObject_DelAttrStringN(o: Long, attr_name: Long): Int + @JvmStatic external fun PyRun_StringN(str: Long, start: Int, globals: Long, locals: Long): Long + @JvmStatic external fun PyObject_StrN(o: Long): Long + @JvmStatic external fun PyObject_IsTrueN(o: Long): Int + @JvmStatic external fun PyObject_CallNoArgsN(callable: Long): Long + @JvmStatic external fun PyObject_CallObjectN(callable: Long, args: Long): Long + + + @JvmStatic external fun Py_DecRefN(o: Long) + @JvmStatic external fun Py_IncRefN(o: Long) + @JvmStatic external fun Py_NewRefN(o: Long): Long + @JvmStatic external fun Py_XNewRefN(o: Long): Long + @JvmStatic external fun PyGILState_EnsureN(): Int + @JvmStatic external fun PyGILState_ReleaseN(state: Int) + @JvmStatic external fun PyGILState_GetThisThreadStateN(): Long + @JvmStatic external fun PyEval_SaveThreadN(): Long + @JvmStatic external fun PyEval_RestoreThreadN(tstate: Long) + @JvmStatic external fun Py_MakePendingCallsN(): Int + @JvmStatic external fun PyGC_CollectN(): Long + @JvmStatic external fun PyEval_InitThreadsN() + @JvmStatic external fun PyThreadState_GetDictN(): Long + @JvmStatic external fun PyTuple_NewN(len: Long): Long + @JvmStatic external fun PyTuple_SetItemN(p: Long, pos: Long, o: Long): Int + @JvmStatic external fun PyDict_NewN(): Long + @JvmStatic external fun PyDict_SetItemStringN(p: Long, key: Long, v: Long): Int + @JvmStatic external fun PyDict_SetItemN(p: Long, key: Long, v: Long): Int + @JvmStatic external fun PyDict_GetItemStringN(p: Long, key: Long): Long + + //************************************************** + // Cycle-collecting proxy type. + // + // Composed on the native side (jni_onload.def), not one-per-C-API-call like the rest of this + // file. The type's tp_traverse and tp_clear have to be C function pointers, and Kotlin/JVM + // has no way to make one -- so C owns the type, and androidMain only asks for it and reads + // and writes the handle inside an instance. The traffic in the other direction (C calling + // Kotlin during a collection) goes to python.multiplatform.ffi.ProxyCallbacks. + // + // Ordinary JNI on all three: proxyCreateType runs type creation, and the accessors are cold. + @JvmStatic external fun proxyCreateType(): Long + @JvmStatic external fun proxyGetHandle(proxy: Long): Long + @JvmStatic external fun proxySetHandle(proxy: Long, handle: Long) + + /** + * `ob_refcnt` of the object at [obj], read straight out of the header. + * + * `Py_REFCNT` is a macro and `sys.getrefcount` adds its argument's own temporary to the + * answer, so neither can say whether `tp_dealloc` released the instance's reference to its + * heap type exactly once. `ob_refcnt` is the first field of `PyObject` and its offset is + * fixed by the stable ABI, which is the one layout fact this file is allowed to know. + * + * Composed on the C side for the same reason [proxySetHandle] is: reading 8 bytes at a raw + * address from Kotlin/JVM would need `sun.misc.Unsafe`, a restricted non-SDK interface here. + * + * A probe, not a production call -- nothing outside the cycle-collection tests uses it, and + * a caller that passes a non-`PyObject` address gets whatever is at that address. + */ + @JvmStatic external fun obRefCnt(obj: Long): Long + + //************************************************** + // Upcalls: Python -> Kotlin/JVM. + // + // Composed on the native side for the same reason the proxy type is: a `PyMethodDef`'s + // `ml_meth` has to be a C function pointer and Kotlin/JVM cannot make one. So C owns the + // entry points and androidMain only asks for them to be installed; the traffic in the other + // direction (C calling Kotlin on every upcall) goes to [UpcallCallbacks]. + // + // Ordinary JNI. It builds function objects and stores them in a dict, so it allocates + // GC-tracked containers and can reach a Python-level hook -- step 2 of the promotion + // checklist in this source set's README rules it out on its own -- and it runs once per + // interpreter, so a promotion would buy nothing even if it were safe. The caller holds the + // GIL; see [UpcallEntry.publish]. + @JvmStatic external fun upcallPublish(namespace: Long): Int + } diff --git a/python-multiplatform/src/androidUnitTest/kotlin/python/multiplatform/ref/GCLeakTest.android.kt b/python-multiplatform/src/androidUnitTest/kotlin/python/multiplatform/ref/GCLeakTest.android.kt new file mode 100644 index 000000000..fab6c614d --- /dev/null +++ b/python-multiplatform/src/androidUnitTest/kotlin/python/multiplatform/ref/GCLeakTest.android.kt @@ -0,0 +1,34 @@ +package python.multiplatform.ref + +/** + * `actual` for the local (JVM) unit-test compilation of the Android target. The native library is + * not loadable here, so nothing in this compilation ever reaches a live interpreter -- but the + * declaration still has to exist, and it must not carry the GIL-parking mistake the instrumented + * copy did. See `GCLeakTest.androidInstrumented.kt` for both reasons. + */ +actual fun forceGC() { + val canary = java.lang.ref.WeakReference(Any()) + var attempts = 0 + while (canary.get() != null && attempts < 20) { + Runtime.getRuntime().gc() + System.runFinalization() + Thread.sleep(10) + attempts++ + } + Thread.sleep(50) +} + +actual val cleanerReleasesAutomatically: Boolean = true + +/** + * This platform's finalisation runs on a thread, so a test can force a collection and watch for + * the result inside one call. `Unit` is what a `@Test` returns here; see `commonTest`'s + * [CollectorTestResult] for the target where it cannot be. + */ +actual typealias CollectorTestResult = Unit + +actual fun collectorTest( + maxAttempts: Int, + attempt: () -> Boolean, + finish: () -> Unit +): CollectorTestResult = runCollectorLoopBlocking(maxAttempts, attempt, finish) diff --git a/python-multiplatform/src/artMain/README.md b/python-multiplatform/src/artMain/README.md new file mode 100644 index 000000000..05beb7ca0 --- /dev/null +++ b/python-multiplatform/src/artMain/README.md @@ -0,0 +1,33 @@ +# 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. + +## This is where JNI lives, and only here + +`nativeMain` is shared with iOS. Anything JNI-shaped placed there compiles for iOS as well, +where it is dead weight at best. Exports, `JNI_OnLoad`, and the cinterop shim belong here. + +## `JNI_OnLoad` binds by registration + +`jni_onload.def` builds the `JNINativeMethod` table and calls `RegisterNatives`, pointing +declarations in `androidMain` straight at CPython's own C functions. No trampoline sits in the +hot path. + +Keep the count argument in step with the table. `RegisterNatives(env, clazz, methods, N)` with +the wrong `N` fails silently or reads past the array. + +## Composed functions go here + +A composed function does a whole binder operation on the native side and crosses the boundary +once. Measured against the per-call equivalent on device: + +| | per-call | composed | | +|---|---|---|---| +| `getAttr` | 3929.84 ns | 713.34 ns | 5.5x | +| `list → LongArray`, 1000 elems | 50065.89 ns | 4532.55 ns | 11.0x | +| `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 +convention would be wrong regardless of speed. diff --git a/python-multiplatform/src/artMain/cinterop/jni_onload.def b/python-multiplatform/src/artMain/cinterop/jni_onload.def new file mode 100644 index 000000000..92f0e76e3 --- /dev/null +++ b/python-multiplatform/src/artMain/cinterop/jni_onload.def @@ -0,0 +1,2263 @@ +package = python.native.ffi.jni +--- +#include +#include +#include + +extern void Py_Initialize(void); +extern jint Py_IsInitialized(void); +extern void Py_Finalize(void); +extern void PyErr_Clear(void); + +extern void Py_DecRef(jlong); +extern void Py_IncRef(jlong); +extern jlong Py_NewRef(jlong); +extern jlong Py_XNewRef(jlong); +extern jint PyGILState_Ensure(void); +extern void PyGILState_Release(jint); +extern jlong PyGILState_GetThisThreadState(void); +extern jlong PyEval_SaveThread(void); +extern void PyEval_RestoreThread(jlong); +extern jint Py_MakePendingCalls(void); +extern jlong PyGC_Collect(void); +extern void PyEval_InitThreads(void); +extern jlong PyThreadState_GetDict(void); +extern jlong PyTuple_New(jlong); +extern jint PyTuple_SetItem(jlong, jlong, jlong); +extern jlong PyDict_New(void); +extern jint PyDict_SetItemString(jlong, const char*, jlong); +extern jint PyDict_SetItem(jlong, jlong, jlong); +extern jlong PyDict_GetItemString(jlong, const char*); + + +extern jlong PyObject_Str(jlong); +extern jint PyObject_IsTrue(jlong); +extern jlong PyObject_CallNoArgs(jlong); +extern jlong PyObject_CallObject(jlong, jlong); +extern const char* PyUnicode_AsUTF8(jlong); +extern jlong PyUnicode_FromString(const char*); + +extern jlong PyLong_FromLongLong(jlong); +extern jlong PyList_Size(jlong); +extern jint PyRun_SimpleString(const char*); +extern const char* Py_GetVersion(void); +extern jlong PyImport_ImportModule(const char*); +extern jlong PyImport_Import(jlong); +extern jlong PyObject_GetAttrString(jlong, const char*); +extern jlong PyErr_Occurred(void); +extern jlong PyList_GetItem(jlong, jlong); +extern jlong PyImport_AddModuleRef(const char*); +extern jlong PyImport_AddModule(const char*); +extern void PyErr_SetString(jlong, const char*); +extern jint PyObject_SetAttrString(jlong, const char*, jlong); +extern jint PyObject_DelAttrString(jlong, const char*); +extern jlong PyRun_String(const char*, jint, jlong, jlong); +extern void Py_DecRef(jlong); +extern void PyErr_Clear(void); +extern void PyErr_Print(void); +extern jlong PyErr_GetRaisedException(void); +extern void PyErr_SetRaisedException(jlong); +extern jlong PyException_GetTraceback(jlong); +extern jlong PyException_GetContext(jlong); +extern jlong PyException_GetCause(jlong); + +extern jlong PyObject_Type(jlong); +extern jint PyObject_IsInstance(jlong, jlong); +extern jlong PyObject_Repr(jlong); +extern jlong PyObject_RichCompare(jlong, jlong, jint); +extern jint PyObject_RichCompareBool(jlong, jlong, jint); +extern jlong PyObject_GetIter(jlong); +extern jlong PyObject_GetItem(jlong, jlong); +extern jint PyObject_SetItem(jlong, jlong, jlong); +extern jint PyObject_DelItem(jlong, jlong); +extern jlong PyObject_Call(jlong, jlong, jlong); +extern const char* PyType_GetName(jlong); +extern jint PyType_IsSubtype(jlong, jlong); +extern jlong PyModule_GetDict(jlong); +extern jlong PyModule_GetFilenameObject(jlong); +extern const char* PyModule_GetName(jlong); + +extern void PyDict_Clear(jlong); +extern jint PyDict_Contains(jlong, jlong); +extern jint PyDict_DelItem(jlong, jlong); +extern jlong PyDict_GetItem(jlong, jlong); +extern jlong PyDict_Items(jlong); +extern jlong PyDict_Size(jlong); +extern jlong PyDict_Values(jlong); +extern jint PyList_Append(jlong, jlong); +extern jlong PyList_AsTuple(jlong); +extern jint PyList_Insert(jlong, jlong, jlong); +extern jint PyList_Reverse(jlong); +extern jint PyList_Sort(jlong); +extern jlong PyList_New(jlong); +extern jint PyList_SetItem(jlong, jlong, jlong); +extern jlong PyTuple_GetItem(jlong, jlong); +extern jlong PyTuple_GetSlice(jlong, jlong, jlong); +extern jlong PyTuple_Size(jlong); + +extern jint PySet_Add(jlong, jlong); +extern jint PySet_Clear(jlong); +extern jint PySet_Contains(jlong, jlong); +extern jint PySet_Discard(jlong, jlong); +extern jlong PySet_New(jlong); +extern jlong PySet_Pop(jlong); +extern jlong PySet_Size(jlong); +extern jlong PyFrozenSet_New(jlong); +extern jint PySequence_Contains(jlong, jlong); +extern jlong PySequence_List(jlong); +extern jlong PySequence_Tuple(jlong); + +extern jlong PyNumber_Add(jlong, jlong); +extern jlong PyNumber_And(jlong, jlong); +extern jlong PyNumber_Multiply(jlong, jlong); +extern jlong PyNumber_Or(jlong, jlong); +extern jlong PyNumber_Subtract(jlong, jlong); +extern double PyFloat_AsDouble(jlong); +extern jlong PyFloat_FromDouble(double); +extern jint PyLong_AsInt(jlong); +extern jlong PyLong_AsLongLong(jlong); +extern jlong PyBool_FromLong(jlong); +extern jlong PyUnicode_Concat(jlong, jlong); +extern jint PyUnicode_Contains(jlong, jlong); +extern jint PyCallable_Check(jlong); +extern jlong PyEval_GetBuiltins(void); +extern jlong PyIter_Next(jlong); +extern const char* Py_GetBuildInfo(void); +extern const char* Py_GetCompiler(void); +extern const char* Py_GetCopyright(void); +extern const char* Py_GetPlatform(void); +extern jint Py_RunMain(void); +extern jlong PySys_GetObject(const char*); + +extern void Py_DecRef(jlong); +extern void Py_IncRef(jlong); +extern jlong Py_NewRef(jlong); +extern jlong Py_XNewRef(jlong); +extern jint PyGILState_Ensure(void); +extern void PyGILState_Release(jint); +extern jlong PyGILState_GetThisThreadState(void); +extern jlong PyEval_SaveThread(void); +extern void PyEval_RestoreThread(jlong); +extern void PyEval_InitThreads(void); +extern jlong PyThreadState_GetDict(void); +extern jlong PyTuple_New(jlong); +extern jint PyTuple_SetItem(jlong, jlong, jlong); +extern jlong PyDict_New(void); +extern jint PyDict_SetItemString(jlong, const char*, jlong); +extern jint PyDict_SetItem(jlong, jlong, jlong); +extern jlong PyDict_GetItemString(jlong, const char*); + + +extern jlong PyObject_Str(jlong); +extern jint PyObject_IsTrue(jlong); +extern jlong PyObject_CallNoArgs(jlong); +extern jlong PyObject_CallObject(jlong, jlong); +extern const char* PyUnicode_AsUTF8(jlong); +extern jlong PyUnicode_FromString(const char*); + + +/* + * The remaining string-carrying surface. + * + * Every one of these used to be reached with a Kotlin `String` argument through the name-linked + * @CName export, which does not fail cleanly -- it corrupts a pointer and kills the process + * (ROADMAP §2). The Kotlin side now hands over an already-encoded C string address as a jlong, + * from the intern cache for repeated identifiers and from the per-thread scratch pool for + * arbitrary content, so these wrappers only have to cast. + * + * All ordinary JNI, per §3's default: several of them run arbitrary Python (module top-level + * code, __getattr__, codec lookup), and the rest are cold enough that a promotion would buy + * nothing. + */ +extern jlong Py_CompileString(const char*, const char*, jint); +extern jlong PyErr_SetFromErrnoWithFilename(jlong, const char*); +extern void PyErr_SyntaxLocationEx(const char*, jint, jint); +extern void PyErr_SyntaxLocation(const char*, jint); +extern jint PyErr_WarnExplicit(jlong, const char*, const char*, jint, const char*, jlong); +extern jlong PyErr_NewException(const char*, jlong, jlong); +extern jlong PyErr_NewExceptionWithDoc(const char*, const char*, jlong, jlong); +extern jint PyUnicodeTranslateError_SetReason(jlong, const char*); +extern jint Py_EnterRecursiveCall(const char*); +extern jlong PySys_GetObject(const char*); +extern jint PySys_SetObject(const char*, jlong); +extern jint PySys_AuditTuple(const char*, jlong); +extern void Py_FatalError(const char*); +extern jlong PyImport_ImportModuleLevel(const char*, jlong, jlong, jlong, jint); +extern jlong PyImport_ExecCodeModule(const char*, jlong); +extern jlong PyImport_ExecCodeModuleEx(const char*, jlong, const char*); +extern jlong PyImport_ExecCodeModuleWithPathnames(const char*, jlong, const char*, const char*); +extern const char* PyImport_GetMagicTag(void); +extern jint PyImport_ImportFrozenModule(const char*); +extern const char* PyEval_GetFuncName(jlong); +extern const char* PyEval_GetFuncDesc(jlong); +extern jint PyObject_HasAttrStringWithError(jlong, const char*); +extern jint PyObject_HasAttrString(jlong, const char*); +extern jlong PySequence_Fast(jlong, const char*); +extern jlong PyMapping_GetItemString(jlong, const char*); +extern jint PyMapping_SetItemString(jlong, const char*, jlong); +extern jint PyMapping_HasKeyStringWithError(jlong, const char*); +extern jint PyMapping_HasKeyString(jlong, const char*); +extern jlong PyBytes_FromString(const char*); +extern char* PyBytes_AsString(jlong); +extern char* PyByteArray_AsString(jlong); +extern jlong PyUnicode_FromEncodedObject(jlong, const char*, const char*); +extern jlong PyUnicode_DecodeLocale(const char*, const char*); +extern jlong PyUnicode_EncodeLocale(jlong, const char*); +extern jlong PyUnicode_DecodeFSDefault(const char*); +extern jlong PyUnicode_AsEncodedString(jlong, const char*, const char*); +extern jlong PyUnicode_Translate(jlong, jlong, const char*); +extern jint PyUnicode_EqualToUTF8(jlong, const char*); +extern jint PyUnicode_CompareWithASCIIString(jlong, const char*); +extern jlong PyUnicode_InternFromString(const char*); +extern jint PyDict_DelItemString(jlong, const char*); + + +/* + * The rest of the CPython surface `bindings.kt` declares. + * + * Everything below used to be reached by name-based `@CName` linking against the exports in + * `nativeMain/.../EmbedAPI.native.kt`, which declare no `JNIEnv*`/`jclass` prologue -- so the + * arguments arrived shifted by two registers. That does not fail cleanly: it corrupts a pointer + * and kills the process (ROADMAP §2, `PyImport_AddModule` at fault address 0xc24110e8). + * + * The Kotlin declarations were also `JNIPointer?`, i.e. a boxed `java.lang.Long`, which cannot be + * registered against a C function taking `jlong` at all. They are raw `Long` now, so every + * descriptor below is primitive-only. + * + * Ordinary JNI throughout, per §3's default. Nothing here is on a measured hot path, and a large + * share of it can execute arbitrary Python (a `__del__` reached by dropping the last reference, a + * `__eq__` during a dict merge, a descriptor during a generic attribute access), which a + * GC-blocking convention must not do. + */ +extern void Py_InitializeEx(jint initsigs); +extern jint Py_IsFinalizing(void); +extern jint Py_FinalizeEx(void); +extern jlong PyEval_EvalCode(jlong co, jlong globals, jlong locals); +extern void PyErr_PrintEx(jint set_sys_last_vars); +extern void PyErr_WriteUnraisable(jlong obj); +extern void PyErr_DisplayException(jlong exc); +extern void PyErr_SetObject(jlong type, jlong value); +extern void PyErr_SetNone(jlong type); +extern jint PyErr_BadArgument(void); +extern jlong PyErr_NoMemory(void); +extern jlong PyErr_SetFromErrno(jlong type); +extern jlong PyErr_SetFromErrnoWithFilenameObject(jlong type, jlong filenameObject); +extern jlong PyErr_SetFromErrnoWithFilenameObjects(jlong type, jlong filenameObject, jlong filenameObject2); +extern jlong PyErr_SetImportError(jlong msg, jlong name, jlong path); +extern jlong PyErr_SetImportErrorSubclass(jlong exception, jlong msg, jlong name, jlong path); +extern void PyErr_BadInternalCall(void); +extern jint PyErr_ExceptionMatches(jlong exc); +extern jint PyErr_GivenExceptionMatches(jlong given, jlong exc); +extern void PyErr_Restore(jlong type, jlong value, jlong traceback); +extern jlong PyErr_GetHandledException(void); +extern void PyErr_SetHandledException(jlong exc); +extern void PyErr_SetExcInfo(jlong type, jlong value, jlong traceback); +extern jint PyErr_CheckSignals(void); +extern void PyErr_SetInterrupt(void); +extern jint PyErr_SetInterruptEx(jint signum); +extern jint PyException_SetTraceback(jlong ex, jlong tb); +extern void PyException_SetContext(jlong ex, jlong ctx); +extern void PyException_SetCause(jlong ex, jlong cause); +extern jlong PyException_GetArgs(jlong ex); +extern void PyException_SetArgs(jlong ex, jlong args); +extern jlong PyUnicodeEncodeError_GetEncoding(jlong exc); +extern jlong PyUnicodeTranslateError_GetObject(jlong exc); +extern jlong PyUnicodeTranslateError_GetReason(jlong exc); +extern void Py_LeaveRecursiveCall(void); +extern jint Py_ReprEnter(jlong o); +extern void Py_ReprLeave(jlong o); +extern jlong PyOS_FSPath(jlong path); +extern jlong PySys_GetXOptions(void); +extern void Py_Exit(jint status); +extern jlong PyImport_ImportModuleLevelObject(jlong name, jlong globals, jlong locals, jlong fromlist, jint level); +extern jlong PyImport_ReloadModule(jlong m); +extern jlong PyImport_AddModuleObject(jlong name); +extern jlong PyImport_ExecCodeModuleObject(jlong name, jlong co, jlong pathname, jlong cpathname); +extern jlong PyImport_GetModuleDict(void); +extern jlong PyImport_GetModule(jlong name); +extern jlong PyImport_GetImporter(jlong path); +extern jint PyImport_ImportFrozenModuleObject(jlong name); +extern jlong PyEval_GetLocals(void); +extern jlong PyEval_GetGlobals(void); +extern jlong PyEval_GetFrameBuiltins(void); +extern jlong PyEval_GetFrameLocals(void); +extern jlong PyEval_GetFrameGlobals(void); +extern jint PyObject_HasAttrWithError(jlong o, jlong attr_name); +extern jint PyObject_HasAttr(jlong o, jlong attr_name); +extern jlong PyObject_GetAttr(jlong o, jlong attr_name); +extern jlong PyObject_GenericGetAttr(jlong o, jlong name); +extern jint PyObject_SetAttr(jlong o, jlong attr_name, jlong v); +extern jint PyObject_GenericSetAttr(jlong o, jlong name, jlong value); +extern jint PyObject_DelAttr(jlong o, jlong attr_name); +extern jlong PyObject_Format(jlong obj, jlong format_spec); +extern jlong PyObject_ASCII(jlong o); +extern jlong PyObject_Bytes(jlong o); +extern jint PyObject_IsSubclass(jlong derived, jlong cls); +extern jint PyObject_Not(jlong o); +extern jlong PyObject_Size(jlong o); +extern jlong PyObject_Length(jlong o); +extern jlong PyObject_Dir(jlong o); +extern jlong PyObject_GetAIter(jlong o); +extern jlong PyVectorcall_Call(jlong callable, jlong tuple, jlong dict); +extern jint PyNumber_Check(jlong o); +extern jlong PyNumber_MatrixMultiply(jlong o1, jlong o2); +extern jlong PyNumber_FloorDivide(jlong o1, jlong o2); +extern jlong PyNumber_TrueDivide(jlong o1, jlong o2); +extern jlong PyNumber_Remainder(jlong o1, jlong o2); +extern jlong PyNumber_Divmod(jlong o1, jlong o2); +extern jlong PyNumber_Power(jlong o1, jlong o2, jlong o3); +extern jlong PyNumber_Negative(jlong o); +extern jlong PyNumber_Positive(jlong o); +extern jlong PyNumber_Absolute(jlong o); +extern jlong PyNumber_Invert(jlong o); +extern jlong PyNumber_Lshift(jlong o1, jlong o2); +extern jlong PyNumber_Rshift(jlong o1, jlong o2); +extern jlong PyNumber_Xor(jlong o1, jlong o2); +extern jlong PyNumber_InPlaceAdd(jlong o1, jlong o2); +extern jlong PyNumber_InPlaceSubtract(jlong o1, jlong o2); +extern jlong PyNumber_InPlaceMultiply(jlong o1, jlong o2); +extern jlong PyNumber_InPlaceMatrixMultiply(jlong o1, jlong o2); +extern jlong PyNumber_InPlaceFloorDivide(jlong o1, jlong o2); +extern jlong PyNumber_InPlaceTrueDivide(jlong o1, jlong o2); +extern jlong PyNumber_InPlaceRemainder(jlong o1, jlong o2); +extern jlong PyNumber_InPlacePower(jlong o1, jlong o2, jlong o3); +extern jlong PyNumber_InPlaceLshift(jlong o1, jlong o2); +extern jlong PyNumber_InPlaceRshift(jlong o1, jlong o2); +extern jlong PyNumber_InPlaceAnd(jlong o1, jlong o2); +extern jlong PyNumber_InPlaceXor(jlong o1, jlong o2); +extern jlong PyNumber_InPlaceOr(jlong o1, jlong o2); +extern jlong PyNumber_Long(jlong o); +extern jlong PyNumber_Float(jlong o); +extern jlong PyNumber_Index(jlong o); +extern jlong PyNumber_ToBase(jlong n, jint base); +extern jint PyIndex_Check(jlong o); +extern jint PySequence_Check(jlong o); +extern jlong PySequence_Concat(jlong o1, jlong o2); +extern jlong PySequence_InPlaceConcat(jlong o1, jlong o2); +extern jint PyMapping_Check(jlong o); +extern jint PyMapping_HasKeyWithError(jlong o, jlong key); +extern jint PyMapping_HasKey(jlong o, jlong key); +extern jlong PyMapping_Keys(jlong o); +extern jlong PyMapping_Values(jlong o); +extern jlong PyMapping_Items(jlong o); +extern jint PyIter_Check(jlong o); +extern jint PyAIter_Check(jlong o); +extern jlong PyLong_FromDouble(double v); +extern double PyLong_AsDouble(jlong pylong); +extern jlong PyLong_GetInfo(void); +extern jlong PyFloat_FromString(jlong str); +extern jlong PyFloat_GetInfo(void); +extern double PyFloat_GetMax(void); +extern double PyFloat_GetMin(void); +extern jlong PyBytes_FromObject(jlong o); +extern jlong PyByteArray_FromObject(jlong o); +extern jlong PyByteArray_Concat(jlong a, jlong b); +extern jint PyUnicode_IsIdentifier(jlong unicode); +extern jlong PyUnicode_FromObject(jlong obj); +extern jlong PyUnicode_EncodeFSDefault(jlong unicode); +extern jlong PyUnicode_AsUTF8String(jlong unicode); +extern jlong PyUnicode_AsUTF32String(jlong unicode); +extern jlong PyUnicode_AsUTF16String(jlong unicode); +extern jlong PyUnicode_AsUnicodeEscapeString(jlong unicode); +extern jlong PyUnicode_AsRawUnicodeEscapeString(jlong unicode); +extern jlong PyUnicode_AsLatin1String(jlong unicode); +extern jlong PyUnicode_AsASCIIString(jlong unicode); +extern jlong PyUnicode_AsCharmapString(jlong unicode, jlong mapping); +extern jlong PyUnicode_Splitlines(jlong unicode, jint keepends); +extern jlong PyUnicode_Join(jlong separator, jlong seq); +extern jint PyUnicode_Compare(jlong left, jlong right); +extern jlong PyUnicode_RichCompare(jlong left, jlong right, jint op); +extern jlong PyUnicode_Format(jlong format, jlong args); +extern jlong PyDictProxy_New(jlong mapping); +extern jlong PyDict_Copy(jlong p); +extern jlong PyDict_GetItemWithError(jlong p, jlong key); +extern jlong PyDict_Keys(jlong p); +extern jint PyDict_Merge(jlong a, jlong b, jint override); +extern jint PyDict_Update(jlong a, jlong b); +extern jint PyDict_MergeFromSeq2(jlong a, jlong seq2, jint override); +extern jlong PySeqIter_New(jlong seq); +extern jlong PyCallIter_New(jlong callable, jlong sentinel); +extern jlong PyWeakref_NewRef(jlong ob, jlong callback); +extern jlong PyWeakref_NewProxy(jlong ob, jlong callback); +/* int PyWeakref_GetRef(PyObject *ref, PyObject **pobj) -- replaces PyWeakref_GetObject, + which 3.15 removed. PyObject* travels as jlong here like every other pointer in this + file; the out-parameter takes void** so the pointer is never punned through an integer + pointer. Both android ABIs this builds for (arm64-v8a, x86_64) are 64-bit. */ +extern jint PyWeakref_GetRef(jlong ref, void **pobj); +extern void PyObject_ClearWeakRefs(jlong o); +extern jint PyType_Ready(jlong type); +extern jlong PyType_GetFullyQualifiedName(jlong type); +extern jlong PyType_GetModuleName(jlong type); +extern jlong PyType_GetModule(jlong type); + + +jlong critical_echo0(jlong x) { return x; } +jlong critical_echo1(jlong x) { return x; } +jlong critical_echo2(jlong x) { return x; } + +/* + * Same one-line body, three calling conventions, so a benchmark can subtract + * everything except the transition itself. + * + * @CriticalNative gets no JNIEnv or jclass at all; @FastNative and ordinary JNI + * both receive them, and differ only in whether ART performs the GC + * thread-state transition around the call. + */ +jlong normal_echo(JNIEnv *env, jclass clazz, jlong x) { return x; } +jlong fast_echo(JNIEnv *env, jclass clazz, jlong x) { return x; } + +/* + * The echoes above measure a transition around a body that does nothing. Real C API calls do + * work, and if that work dominates then the convention barely matters in practice. These wrap + * an actual CPython function -- PyList_Size, an O(1) struct read -- under the two conventions + * that need JNIEnv, so it can be compared against the @CriticalNative registration of + * PyList_Size itself. + */ +jlong pylistsize_fast(JNIEnv *env, jclass clazz, jlong l) { return PyList_Size(l); } +jlong pylistsize_normal(JNIEnv *env, jclass clazz, jlong l) { return PyList_Size(l); } + +/* + * @FastNative counterparts of the migrated functions. + * + * Measurement (see docs/design/downcall-design.md) shows @CriticalNative is effectively free up to + * API 33 and collapses at 34, while @FastNative is the reverse. Both are registered so the + * Kotlin side can pick per API level. @FastNative keeps JNIEnv and jclass, so these need the + * leading parameters that the critical registrations must not have. + */ +static void f_Py_Initialize(JNIEnv *e, jclass c) { Py_Initialize(); } +static jint f_Py_IsInitialized(JNIEnv *e, jclass c) { return Py_IsInitialized(); } +static void f_Py_Finalize(JNIEnv *e, jclass c) { Py_Finalize(); } +static void f_PyErr_Clear(JNIEnv *e, jclass c) { PyErr_Clear(); } +static jlong f_PyLong_FromLongLong(JNIEnv *e, jclass c, jlong v) { return PyLong_FromLongLong(v); } +static jlong f_PyList_Size(JNIEnv *e, jclass c, jlong l) { return PyList_Size(l); } +static jint f_PyRun_SimpleString(JNIEnv *e, jclass c, jlong s) { return PyRun_SimpleString((const char*)s); } +static jlong f_Py_GetVersion(JNIEnv *e, jclass c) { return (jlong)Py_GetVersion(); } +static jlong f_PyImport_ImportModule(JNIEnv *e, jclass c, jlong n) { return PyImport_ImportModule((const char*)n); } +static jlong f_PyImport_ImportN(JNIEnv *e, jclass c, jlong n) { return PyImport_Import(n); } +static jlong f_PyObject_GetAttrString(JNIEnv *e, jclass c, jlong o, jlong n) { return PyObject_GetAttrString(o, (const char*)n); } +static jlong f_PyErr_Occurred(JNIEnv *e, jclass c) { return PyErr_Occurred(); } + +/* + * PyList_GetItem had no @FastNative twin, which left it @CriticalNative on every API level -- the + * one place in the table that ignores the device axis. That is backwards above API 33: the net + * cost of @CriticalNative there is 24.35 ns (34) and 44.05 ns (36 hardware) against @FastNative's + * 1.97 and 3.55. It is also the per-element call of bulk list iteration, so the penalty multiplies + * by N -- the measured 50065.89 ns for a 1000-element list on API 36 is ~50 ns per element against + * a @CriticalNative net cost of 44.05. + */ +static jlong f_PyList_GetItemRaw(JNIEnv *e, jclass c, jlong list, jlong i) { return PyList_GetItem(list, i); } + +static void f_Py_DecRefN(JNIEnv *e, jclass c, jlong o) { Py_DecRef((jlong)o); } +static void f_Py_IncRefN(JNIEnv *e, jclass c, jlong o) { Py_IncRef((jlong)o); } +static jlong f_Py_NewRefN(JNIEnv *e, jclass c, jlong o) { return Py_NewRef((jlong)o); } +static jlong f_Py_XNewRefN(JNIEnv *e, jclass c, jlong o) { return Py_XNewRef((jlong)o); } +static jint f_PyGILState_EnsureN(JNIEnv *e, jclass c) { return PyGILState_Ensure(); } +static void f_PyGILState_ReleaseN(JNIEnv *e, jclass c, jint s) { PyGILState_Release(s); } +static jlong f_PyGILState_GetThisThreadStateN(JNIEnv *e, jclass c) { return PyGILState_GetThisThreadState(); } +static jlong f_PyEval_SaveThreadN(JNIEnv *e, jclass c) { return PyEval_SaveThread(); } +static void f_PyEval_RestoreThreadN(JNIEnv *e, jclass c, jlong t) { PyEval_RestoreThread(t); } +static jint f_Py_MakePendingCallsN(JNIEnv *e, jclass c) { return Py_MakePendingCalls(); } +static jlong f_PyGC_CollectN(JNIEnv *e, jclass c) { return PyGC_Collect(); } +static void f_PyEval_InitThreadsN(JNIEnv *e, jclass c) { PyEval_InitThreads(); } +static jlong f_PyThreadState_GetDictN(JNIEnv *e, jclass c) { return PyThreadState_GetDict(); } +static jlong f_PyTuple_NewN(JNIEnv *e, jclass c, jlong len) { return PyTuple_New(len); } +static jint f_PyTuple_SetItemN(JNIEnv *e, jclass c, jlong p, jlong pos, jlong o) { return PyTuple_SetItem(p, pos, o); } +static jlong f_PyDict_NewN(JNIEnv *e, jclass c) { return PyDict_New(); } +static jint f_PyDict_SetItemStringN(JNIEnv *e, jclass c, jlong p, jlong k, jlong v) { return PyDict_SetItemString(p, (const char*)k, v); } +static jint f_PyDict_SetItemN(JNIEnv *e, jclass c, jlong p, jlong k, jlong v) { return PyDict_SetItem(p, k, v); } +static jlong f_PyDict_GetItemStringN(JNIEnv *e, jclass c, jlong p, jlong k) { return PyDict_GetItemString(p, (const char*)k); } + + +static jlong f_PyImport_AddModuleRefN(JNIEnv *e, jclass c, jlong n) { return PyImport_AddModuleRef((const char*)n); } + +/* + * Reached from PythonTestFixture.mainGlobals, and before these were registered the second + * object-model test on Android died with SIGSEGV at fault address 0xc24110e8 -- a truncated + * pointer, the signature of the name-linked @CName fallback described in ROADMAP §2. + * + * PyImport_AddModule returns a BORROWED reference where PyImport_AddModuleRef returns a new one. + * The two are not interchangeable at the call site and the Kotlin actual keeps that distinction. + */ +static jlong f_PyImport_AddModuleN(JNIEnv *e, jclass c, jlong n) { return PyImport_AddModule((const char*)n); } +/* + * Borrowed reference into the interpreter's sys module dict; the caller must not release it. + * + * Left on the name-linked @CName fallback it did not fail loudly -- it returned a null pointer, + * which surfaced as an NPE in GilParkingTest rather than as a crash, so it read like a missing + * "modules" entry instead of missing wiring. Ordinary JNI, not @FastNative: sys.__dict__ lookups + * go through PyDict_GetItemWithError, which can run a __eq__ or __hash__. + */ +static jlong f_PySys_GetObjectN(JNIEnv *e, jclass c, jlong n) { return PySys_GetObject((const char*)n); } +static void f_PyErr_SetStringN(JNIEnv *e, jclass c, jlong t, jlong m) { PyErr_SetString(t, (const char*)m); } +static jint f_PyObject_SetAttrStringN(JNIEnv *e, jclass c, jlong o, jlong n, jlong v) { return PyObject_SetAttrString(o, (const char*)n, v); } +static jint f_PyObject_DelAttrStringN(JNIEnv *e, jclass c, jlong o, jlong n) { return PyObject_DelAttrString(o, (const char*)n); } +static jlong f_PyRun_StringN(JNIEnv *e, jclass c, jlong str, jint start, jlong globals, jlong locals) { return PyRun_String((const char*)str, start, globals, locals); } +static jlong f_PyObject_StrN(JNIEnv *e, jclass c, jlong o) { return PyObject_Str(o); } +static jint f_PyObject_IsTrueN(JNIEnv *e, jclass c, jlong o) { return PyObject_IsTrue(o); } +static jlong f_PyObject_CallNoArgsN(JNIEnv *e, jclass c, jlong callable) { return PyObject_CallNoArgs(callable); } +static jlong f_PyObject_CallObjectN(JNIEnv *e, jclass c, jlong callable, jlong args) { return PyObject_CallObject(callable, args); } +static jlong f_PyUnicode_AsUTF8(JNIEnv *e, jclass c, jlong unicode) { return (jlong)PyUnicode_AsUTF8(unicode); } +static jlong f_PyUnicode_FromString(JNIEnv *e, jclass c, jlong str) { return PyUnicode_FromString((const char*)str); } +static void f_PyErr_PrintN(JNIEnv *e, jclass c) { PyErr_Print(); } +static jlong f_PyErr_GetRaisedExceptionN(JNIEnv *e, jclass c) { return PyErr_GetRaisedException(); } +static void f_PyErr_SetRaisedExceptionN(JNIEnv *e, jclass c, jlong exc) { PyErr_SetRaisedException(exc); } +static jlong f_PyException_GetTracebackN(JNIEnv *e, jclass c, jlong ex) { return PyException_GetTraceback(ex); } +static jlong f_PyException_GetContextN(JNIEnv *e, jclass c, jlong ex) { return PyException_GetContext(ex); } +static jlong f_PyException_GetCauseN(JNIEnv *e, jclass c, jlong ex) { return PyException_GetCause(ex); } +static jlong f_PyObject_TypeN(JNIEnv *e, jclass c, jlong o) { return PyObject_Type(o); } +static jint f_PyObject_IsInstanceN(JNIEnv *e, jclass c, jlong inst, jlong cls) { return PyObject_IsInstance(inst, cls); } +static jlong f_PyObject_ReprN(JNIEnv *e, jclass c, jlong o) { return PyObject_Repr(o); } +static jlong f_PyObject_RichCompareN(JNIEnv *e, jclass c, jlong o1, jlong o2, jint opid) { return PyObject_RichCompare(o1, o2, opid); } +static jint f_PyObject_RichCompareBoolN(JNIEnv *e, jclass c, jlong o1, jlong o2, jint opid) { return PyObject_RichCompareBool(o1, o2, opid); } +static jlong f_PyObject_GetIterN(JNIEnv *e, jclass c, jlong o) { return PyObject_GetIter(o); } +static jlong f_PyObject_GetItemN(JNIEnv *e, jclass c, jlong o, jlong key) { return PyObject_GetItem(o, key); } +static jint f_PyObject_SetItemN(JNIEnv *e, jclass c, jlong o, jlong key, jlong v) { return PyObject_SetItem(o, key, v); } +static jint f_PyObject_DelItemN(JNIEnv *e, jclass c, jlong o, jlong key) { return PyObject_DelItem(o, key); } +static jlong f_PyObject_CallN(JNIEnv *e, jclass c, jlong callable, jlong args, jlong kwargs) { return PyObject_Call(callable, args, kwargs); } +static jlong f_PyType_GetNameN(JNIEnv *e, jclass c, jlong type) { return (jlong)PyType_GetName(type); } +static jint f_PyType_IsSubtypeN(JNIEnv *e, jclass c, jlong a, jlong b) { return PyType_IsSubtype(a, b); } +static jlong f_PyModule_GetDictN(JNIEnv *e, jclass c, jlong module) { return PyModule_GetDict(module); } +static jlong f_PyModule_GetFilenameObjectN(JNIEnv *e, jclass c, jlong module) { return PyModule_GetFilenameObject(module); } +static jlong f_PyModule_GetNameN(JNIEnv *e, jclass c, jlong module) { return (jlong)PyModule_GetName(module); } +static void f_PyDict_ClearN(JNIEnv *e, jclass c, jlong p) { PyDict_Clear(p); } +static jint f_PyDict_ContainsN(JNIEnv *e, jclass c, jlong p, jlong key) { return PyDict_Contains(p, key); } +static jint f_PyDict_DelItemN(JNIEnv *e, jclass c, jlong p, jlong key) { return PyDict_DelItem(p, key); } +static jlong f_PyDict_GetItemN(JNIEnv *e, jclass c, jlong p, jlong key) { return PyDict_GetItem(p, key); } +static jlong f_PyDict_ItemsN(JNIEnv *e, jclass c, jlong p) { return PyDict_Items(p); } +static jlong f_PyDict_SizeN(JNIEnv *e, jclass c, jlong p) { return PyDict_Size(p); } +static jlong f_PyDict_ValuesN(JNIEnv *e, jclass c, jlong p) { return PyDict_Values(p); } +static jint f_PyList_AppendN(JNIEnv *e, jclass c, jlong list, jlong item) { return PyList_Append(list, item); } +static jlong f_PyList_AsTupleN(JNIEnv *e, jclass c, jlong list) { return PyList_AsTuple(list); } +static jint f_PyList_InsertN(JNIEnv *e, jclass c, jlong list, jlong index, jlong item) { return PyList_Insert(list, index, item); } +static jint f_PyList_ReverseN(JNIEnv *e, jclass c, jlong list) { return PyList_Reverse(list); } +static jint f_PyList_SortN(JNIEnv *e, jclass c, jlong list) { return PyList_Sort(list); } +static jlong f_PyList_NewN(JNIEnv *e, jclass c, jlong len) { return PyList_New(len); } +static jint f_PyList_SetItemN(JNIEnv *e, jclass c, jlong list, jlong index, jlong item) { return PyList_SetItem(list, index, item); } +static jlong f_PyTuple_GetItemN(JNIEnv *e, jclass c, jlong p, jlong pos) { return PyTuple_GetItem(p, pos); } +static jlong f_PyTuple_GetSliceN(JNIEnv *e, jclass c, jlong p, jlong low, jlong high) { return PyTuple_GetSlice(p, low, high); } +static jlong f_PyTuple_SizeN(JNIEnv *e, jclass c, jlong p) { return PyTuple_Size(p); } +static jint f_PySet_AddN(JNIEnv *e, jclass c, jlong set, jlong key) { return PySet_Add(set, key); } +static jint f_PySet_ClearN(JNIEnv *e, jclass c, jlong set) { return PySet_Clear(set); } +static jint f_PySet_ContainsN(JNIEnv *e, jclass c, jlong anyset, jlong key) { return PySet_Contains(anyset, key); } +static jint f_PySet_DiscardN(JNIEnv *e, jclass c, jlong set, jlong key) { return PySet_Discard(set, key); } +static jlong f_PySet_NewN(JNIEnv *e, jclass c, jlong iterable) { return PySet_New(iterable); } +static jlong f_PySet_PopN(JNIEnv *e, jclass c, jlong set) { return PySet_Pop(set); } +static jlong f_PySet_SizeN(JNIEnv *e, jclass c, jlong anyset) { return PySet_Size(anyset); } +static jlong f_PyFrozenSet_NewN(JNIEnv *e, jclass c, jlong iterable) { return PyFrozenSet_New(iterable); } +static jint f_PySequence_ContainsN(JNIEnv *e, jclass c, jlong seq, jlong ob) { return PySequence_Contains(seq, ob); } +static jlong f_PySequence_ListN(JNIEnv *e, jclass c, jlong o) { return PySequence_List(o); } +static jlong f_PySequence_TupleN(JNIEnv *e, jclass c, jlong o) { return PySequence_Tuple(o); } +static jlong f_PyNumber_AddN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_Add(o1, o2); } +static jlong f_PyNumber_AndN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_And(o1, o2); } +static jlong f_PyNumber_MultiplyN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_Multiply(o1, o2); } +static jlong f_PyNumber_OrN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_Or(o1, o2); } +static jlong f_PyNumber_SubtractN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_Subtract(o1, o2); } +static jdouble f_PyFloat_AsDoubleN(JNIEnv *e, jclass c, jlong f) { return PyFloat_AsDouble(f); } +static jlong f_PyFloat_FromDoubleN(JNIEnv *e, jclass c, jdouble v) { return PyFloat_FromDouble(v); } +static jint f_PyLong_AsIntN(JNIEnv *e, jclass c, jlong l) { return PyLong_AsInt(l); } +static jlong f_PyLong_AsLongLongN(JNIEnv *e, jclass c, jlong l) { return PyLong_AsLongLong(l); } +static jlong f_PyBool_FromLongN(JNIEnv *e, jclass c, jlong v) { return PyBool_FromLong(v); } +static jlong f_PyUnicode_ConcatN(JNIEnv *e, jclass c, jlong left, jlong right) { return PyUnicode_Concat(left, right); } +static jint f_PyUnicode_ContainsN(JNIEnv *e, jclass c, jlong container, jlong element) { return PyUnicode_Contains(container, element); } +static jint f_PyCallable_CheckN(JNIEnv *e, jclass c, jlong o) { return PyCallable_Check(o); } +static jlong f_PyEval_GetBuiltinsN(JNIEnv *e, jclass c) { return PyEval_GetBuiltins(); } +static jlong f_PyIter_NextN(JNIEnv *e, jclass c, jlong i) { return PyIter_Next(i); } +static jlong f_Py_GetBuildInfoN(JNIEnv *e, jclass c) { return (jlong)Py_GetBuildInfo(); } +static jlong f_Py_GetCompilerN(JNIEnv *e, jclass c) { return (jlong)Py_GetCompiler(); } +static jlong f_Py_GetCopyrightN(JNIEnv *e, jclass c) { return (jlong)Py_GetCopyright(); } +static jlong f_Py_GetPlatformN(JNIEnv *e, jclass c) { return (jlong)Py_GetPlatform(); } +static jint f_Py_RunMainN(JNIEnv *e, jclass c) { return Py_RunMain(); } + +/* ---- string-carrying surface, jlong C-string addresses in and out ---- */ +static jlong f_Py_CompileStringN(JNIEnv *e, jclass c, jlong str, jlong filename, jint start) { return Py_CompileString((const char*)str, (const char*)filename, start); } +static jlong f_PyErr_SetFromErrnoWithFilenameN(JNIEnv *e, jclass c, jlong type, jlong filename) { return PyErr_SetFromErrnoWithFilename(type, (const char*)filename); } +static void f_PyErr_SyntaxLocationExN(JNIEnv *e, jclass c, jlong filename, jint lineno, jint col) { PyErr_SyntaxLocationEx((const char*)filename, lineno, col); } +static void f_PyErr_SyntaxLocationN(JNIEnv *e, jclass c, jlong filename, jint lineno) { PyErr_SyntaxLocation((const char*)filename, lineno); } +static jint f_PyErr_WarnExplicitN(JNIEnv *e, jclass c, jlong category, jlong message, jlong filename, jint lineno, jlong module, jlong registry) { return PyErr_WarnExplicit(category, (const char*)message, (const char*)filename, lineno, (const char*)module, registry); } +static jlong f_PyErr_NewExceptionN(JNIEnv *e, jclass c, jlong name, jlong base, jlong dict) { return PyErr_NewException((const char*)name, base, dict); } +static jlong f_PyErr_NewExceptionWithDocN(JNIEnv *e, jclass c, jlong name, jlong doc, jlong base, jlong dict) { return PyErr_NewExceptionWithDoc((const char*)name, (const char*)doc, base, dict); } +static jint f_PyUnicodeTranslateError_SetReasonN(JNIEnv *e, jclass c, jlong exc, jlong reason) { return PyUnicodeTranslateError_SetReason(exc, (const char*)reason); } +static jint f_Py_EnterRecursiveCallN(JNIEnv *e, jclass c, jlong where) { return Py_EnterRecursiveCall((const char*)where); } +static jint f_PySys_SetObjectN(JNIEnv *e, jclass c, jlong name, jlong v) { return PySys_SetObject((const char*)name, v); } +static jint f_PySys_AuditTupleN(JNIEnv *e, jclass c, jlong event, jlong args) { return PySys_AuditTuple((const char*)event, args); } +static void f_Py_FatalErrorN(JNIEnv *e, jclass c, jlong message) { Py_FatalError((const char*)message); } +static jlong f_PyImport_ImportModuleLevelN(JNIEnv *e, jclass c, jlong name, jlong globals, jlong locals, jlong fromlist, jint level) { return PyImport_ImportModuleLevel((const char*)name, globals, locals, fromlist, level); } +static jlong f_PyImport_ExecCodeModuleN(JNIEnv *e, jclass c, jlong name, jlong co) { return PyImport_ExecCodeModule((const char*)name, co); } +static jlong f_PyImport_ExecCodeModuleExN(JNIEnv *e, jclass c, jlong name, jlong co, jlong pathname) { return PyImport_ExecCodeModuleEx((const char*)name, co, (const char*)pathname); } +static jlong f_PyImport_ExecCodeModuleWithPathnamesN(JNIEnv *e, jclass c, jlong name, jlong co, jlong pathname, jlong cpathname) { return PyImport_ExecCodeModuleWithPathnames((const char*)name, co, (const char*)pathname, (const char*)cpathname); } +static jlong f_PyImport_GetMagicTagN(JNIEnv *e, jclass c) { return (jlong)PyImport_GetMagicTag(); } +static jint f_PyImport_ImportFrozenModuleN(JNIEnv *e, jclass c, jlong name) { return PyImport_ImportFrozenModule((const char*)name); } +static jlong f_PyEval_GetFuncNameN(JNIEnv *e, jclass c, jlong func) { return (jlong)PyEval_GetFuncName(func); } +static jlong f_PyEval_GetFuncDescN(JNIEnv *e, jclass c, jlong func) { return (jlong)PyEval_GetFuncDesc(func); } +static jint f_PyObject_HasAttrStringWithErrorN(JNIEnv *e, jclass c, jlong o, jlong n) { return PyObject_HasAttrStringWithError(o, (const char*)n); } +static jint f_PyObject_HasAttrStringN(JNIEnv *e, jclass c, jlong o, jlong n) { return PyObject_HasAttrString(o, (const char*)n); } +static jlong f_PySequence_FastN(JNIEnv *e, jclass c, jlong o, jlong m) { return PySequence_Fast(o, (const char*)m); } +static jlong f_PyMapping_GetItemStringN(JNIEnv *e, jclass c, jlong o, jlong key) { return PyMapping_GetItemString(o, (const char*)key); } +static jint f_PyMapping_SetItemStringN(JNIEnv *e, jclass c, jlong o, jlong key, jlong v) { return PyMapping_SetItemString(o, (const char*)key, v); } +static jint f_PyMapping_HasKeyStringWithErrorN(JNIEnv *e, jclass c, jlong o, jlong key) { return PyMapping_HasKeyStringWithError(o, (const char*)key); } +static jint f_PyMapping_HasKeyStringN(JNIEnv *e, jclass c, jlong o, jlong key) { return PyMapping_HasKeyString(o, (const char*)key); } +static jlong f_PyBytes_FromStringN(JNIEnv *e, jclass c, jlong v) { return PyBytes_FromString((const char*)v); } +static jlong f_PyBytes_AsStringN(JNIEnv *e, jclass c, jlong o) { return (jlong)PyBytes_AsString(o); } +static jlong f_PyByteArray_AsStringN(JNIEnv *e, jclass c, jlong b) { return (jlong)PyByteArray_AsString(b); } +static jlong f_PyUnicode_FromEncodedObjectN(JNIEnv *e, jclass c, jlong obj, jlong encoding, jlong errors) { return PyUnicode_FromEncodedObject(obj, (const char*)encoding, (const char*)errors); } +static jlong f_PyUnicode_DecodeLocaleN(JNIEnv *e, jclass c, jlong str, jlong errors) { return PyUnicode_DecodeLocale((const char*)str, (const char*)errors); } +static jlong f_PyUnicode_EncodeLocaleN(JNIEnv *e, jclass c, jlong unicode, jlong errors) { return PyUnicode_EncodeLocale(unicode, (const char*)errors); } +static jlong f_PyUnicode_DecodeFSDefaultN(JNIEnv *e, jclass c, jlong str) { return PyUnicode_DecodeFSDefault((const char*)str); } +static jlong f_PyUnicode_AsEncodedStringN(JNIEnv *e, jclass c, jlong unicode, jlong encoding, jlong errors) { return PyUnicode_AsEncodedString(unicode, (const char*)encoding, (const char*)errors); } +static jlong f_PyUnicode_TranslateN(JNIEnv *e, jclass c, jlong unicode, jlong table, jlong errors) { return PyUnicode_Translate(unicode, table, (const char*)errors); } +static jint f_PyUnicode_EqualToUTF8N(JNIEnv *e, jclass c, jlong unicode, jlong string) { return PyUnicode_EqualToUTF8(unicode, (const char*)string); } +static jint f_PyUnicode_CompareWithASCIIStringN(JNIEnv *e, jclass c, jlong unicode, jlong string) { return PyUnicode_CompareWithASCIIString(unicode, (const char*)string); } +static jlong f_PyUnicode_InternFromStringN(JNIEnv *e, jclass c, jlong str) { return PyUnicode_InternFromString((const char*)str); } +static jint f_PyDict_DelItemStringN(JNIEnv *e, jclass c, jlong p, jlong key) { return PyDict_DelItemString(p, (const char*)key); } + + +/* ---- wrappers for the surface above ---- */ +static void f_Py_InitializeExN(JNIEnv *e, jclass c, jint initsigs) { Py_InitializeEx(initsigs); } +static jint f_Py_IsFinalizingN(JNIEnv *e, jclass c) { return Py_IsFinalizing(); } +static jint f_Py_FinalizeExN(JNIEnv *e, jclass c) { return Py_FinalizeEx(); } +static jlong f_PyEval_EvalCodeN(JNIEnv *e, jclass c, jlong co, jlong globals, jlong locals) { return PyEval_EvalCode(co, globals, locals); } +static void f_PyErr_PrintExN(JNIEnv *e, jclass c, jint set_sys_last_vars) { PyErr_PrintEx(set_sys_last_vars); } +static void f_PyErr_WriteUnraisableN(JNIEnv *e, jclass c, jlong obj) { PyErr_WriteUnraisable(obj); } +static void f_PyErr_DisplayExceptionN(JNIEnv *e, jclass c, jlong exc) { PyErr_DisplayException(exc); } +static void f_PyErr_SetObjectN(JNIEnv *e, jclass c, jlong type, jlong value) { PyErr_SetObject(type, value); } +static void f_PyErr_SetNoneN(JNIEnv *e, jclass c, jlong type) { PyErr_SetNone(type); } +static jint f_PyErr_BadArgumentN(JNIEnv *e, jclass c) { return PyErr_BadArgument(); } +static jlong f_PyErr_NoMemoryN(JNIEnv *e, jclass c) { return PyErr_NoMemory(); } +static jlong f_PyErr_SetFromErrnoN(JNIEnv *e, jclass c, jlong type) { return PyErr_SetFromErrno(type); } +static jlong f_PyErr_SetFromErrnoWithFilenameObjectN(JNIEnv *e, jclass c, jlong type, jlong filenameObject) { return PyErr_SetFromErrnoWithFilenameObject(type, filenameObject); } +static jlong f_PyErr_SetFromErrnoWithFilenameObjectsN(JNIEnv *e, jclass c, jlong type, jlong filenameObject, jlong filenameObject2) { return PyErr_SetFromErrnoWithFilenameObjects(type, filenameObject, filenameObject2); } +static jlong f_PyErr_SetImportErrorN(JNIEnv *e, jclass c, jlong msg, jlong name, jlong path) { return PyErr_SetImportError(msg, name, path); } +static jlong f_PyErr_SetImportErrorSubclassN(JNIEnv *e, jclass c, jlong exception, jlong msg, jlong name, jlong path) { return PyErr_SetImportErrorSubclass(exception, msg, name, path); } +static void f_PyErr_BadInternalCallN(JNIEnv *e, jclass c) { PyErr_BadInternalCall(); } +static jint f_PyErr_ExceptionMatchesN(JNIEnv *e, jclass c, jlong exc) { return PyErr_ExceptionMatches(exc); } +static jint f_PyErr_GivenExceptionMatchesN(JNIEnv *e, jclass c, jlong given, jlong exc) { return PyErr_GivenExceptionMatches(given, exc); } +static void f_PyErr_RestoreN(JNIEnv *e, jclass c, jlong type, jlong value, jlong traceback) { PyErr_Restore(type, value, traceback); } +static jlong f_PyErr_GetHandledExceptionN(JNIEnv *e, jclass c) { return PyErr_GetHandledException(); } +static void f_PyErr_SetHandledExceptionN(JNIEnv *e, jclass c, jlong exc) { PyErr_SetHandledException(exc); } +static void f_PyErr_SetExcInfoN(JNIEnv *e, jclass c, jlong type, jlong value, jlong traceback) { PyErr_SetExcInfo(type, value, traceback); } +static jint f_PyErr_CheckSignalsN(JNIEnv *e, jclass c) { return PyErr_CheckSignals(); } +static void f_PyErr_SetInterruptN(JNIEnv *e, jclass c) { PyErr_SetInterrupt(); } +static jint f_PyErr_SetInterruptExN(JNIEnv *e, jclass c, jint signum) { return PyErr_SetInterruptEx(signum); } +static jint f_PyException_SetTracebackN(JNIEnv *e, jclass c, jlong ex, jlong tb) { return PyException_SetTraceback(ex, tb); } +static void f_PyException_SetContextN(JNIEnv *e, jclass c, jlong ex, jlong ctx) { PyException_SetContext(ex, ctx); } +static void f_PyException_SetCauseN(JNIEnv *e, jclass c, jlong ex, jlong cause) { PyException_SetCause(ex, cause); } +static jlong f_PyException_GetArgsN(JNIEnv *e, jclass c, jlong ex) { return PyException_GetArgs(ex); } +static void f_PyException_SetArgsN(JNIEnv *e, jclass c, jlong ex, jlong args) { PyException_SetArgs(ex, args); } +static jlong f_PyUnicodeEncodeError_GetEncodingN(JNIEnv *e, jclass c, jlong exc) { return PyUnicodeEncodeError_GetEncoding(exc); } +static jlong f_PyUnicodeTranslateError_GetObjectN(JNIEnv *e, jclass c, jlong exc) { return PyUnicodeTranslateError_GetObject(exc); } +static jlong f_PyUnicodeTranslateError_GetReasonN(JNIEnv *e, jclass c, jlong exc) { return PyUnicodeTranslateError_GetReason(exc); } +static void f_Py_LeaveRecursiveCallN(JNIEnv *e, jclass c) { Py_LeaveRecursiveCall(); } +static jint f_Py_ReprEnterN(JNIEnv *e, jclass c, jlong o) { return Py_ReprEnter(o); } +static void f_Py_ReprLeaveN(JNIEnv *e, jclass c, jlong o) { Py_ReprLeave(o); } +static jlong f_PyOS_FSPathN(JNIEnv *e, jclass c, jlong path) { return PyOS_FSPath(path); } +static jlong f_PySys_GetXOptionsN(JNIEnv *e, jclass c) { return PySys_GetXOptions(); } +static void f_Py_ExitN(JNIEnv *e, jclass c, jint status) { Py_Exit(status); } +static jlong f_PyImport_ImportModuleLevelObjectN(JNIEnv *e, jclass c, jlong name, jlong globals, jlong locals, jlong fromlist, jint level) { return PyImport_ImportModuleLevelObject(name, globals, locals, fromlist, level); } +static jlong f_PyImport_ReloadModuleN(JNIEnv *e, jclass c, jlong m) { return PyImport_ReloadModule(m); } +static jlong f_PyImport_AddModuleObjectN(JNIEnv *e, jclass c, jlong name) { return PyImport_AddModuleObject(name); } +static jlong f_PyImport_ExecCodeModuleObjectN(JNIEnv *e, jclass c, jlong name, jlong co, jlong pathname, jlong cpathname) { return PyImport_ExecCodeModuleObject(name, co, pathname, cpathname); } +static jlong f_PyImport_GetModuleDictN(JNIEnv *e, jclass c) { return PyImport_GetModuleDict(); } +static jlong f_PyImport_GetModuleN(JNIEnv *e, jclass c, jlong name) { return PyImport_GetModule(name); } +static jlong f_PyImport_GetImporterN(JNIEnv *e, jclass c, jlong path) { return PyImport_GetImporter(path); } +static jint f_PyImport_ImportFrozenModuleObjectN(JNIEnv *e, jclass c, jlong name) { return PyImport_ImportFrozenModuleObject(name); } +static jlong f_PyEval_GetLocalsN(JNIEnv *e, jclass c) { return PyEval_GetLocals(); } +static jlong f_PyEval_GetGlobalsN(JNIEnv *e, jclass c) { return PyEval_GetGlobals(); } +static jlong f_PyEval_GetFrameBuiltinsN(JNIEnv *e, jclass c) { return PyEval_GetFrameBuiltins(); } +static jlong f_PyEval_GetFrameLocalsN(JNIEnv *e, jclass c) { return PyEval_GetFrameLocals(); } +static jlong f_PyEval_GetFrameGlobalsN(JNIEnv *e, jclass c) { return PyEval_GetFrameGlobals(); } +static jint f_PyObject_HasAttrWithErrorN(JNIEnv *e, jclass c, jlong o, jlong attr_name) { return PyObject_HasAttrWithError(o, attr_name); } +static jint f_PyObject_HasAttrN(JNIEnv *e, jclass c, jlong o, jlong attr_name) { return PyObject_HasAttr(o, attr_name); } +static jlong f_PyObject_GetAttrN(JNIEnv *e, jclass c, jlong o, jlong attr_name) { return PyObject_GetAttr(o, attr_name); } +static jlong f_PyObject_GenericGetAttrN(JNIEnv *e, jclass c, jlong o, jlong name) { return PyObject_GenericGetAttr(o, name); } +static jint f_PyObject_SetAttrN(JNIEnv *e, jclass c, jlong o, jlong attr_name, jlong v) { return PyObject_SetAttr(o, attr_name, v); } +static jint f_PyObject_GenericSetAttrN(JNIEnv *e, jclass c, jlong o, jlong name, jlong value) { return PyObject_GenericSetAttr(o, name, value); } +static jint f_PyObject_DelAttrN(JNIEnv *e, jclass c, jlong o, jlong attr_name) { return PyObject_DelAttr(o, attr_name); } +static jlong f_PyObject_FormatN(JNIEnv *e, jclass c, jlong obj, jlong format_spec) { return PyObject_Format(obj, format_spec); } +static jlong f_PyObject_ASCIIN(JNIEnv *e, jclass c, jlong o) { return PyObject_ASCII(o); } +static jlong f_PyObject_BytesN(JNIEnv *e, jclass c, jlong o) { return PyObject_Bytes(o); } +static jint f_PyObject_IsSubclassN(JNIEnv *e, jclass c, jlong derived, jlong cls) { return PyObject_IsSubclass(derived, cls); } +static jint f_PyObject_NotN(JNIEnv *e, jclass c, jlong o) { return PyObject_Not(o); } +static jlong f_PyObject_SizeN(JNIEnv *e, jclass c, jlong o) { return PyObject_Size(o); } +static jlong f_PyObject_LengthN(JNIEnv *e, jclass c, jlong o) { return PyObject_Length(o); } +static jlong f_PyObject_DirN(JNIEnv *e, jclass c, jlong o) { return PyObject_Dir(o); } +static jlong f_PyObject_GetAIterN(JNIEnv *e, jclass c, jlong o) { return PyObject_GetAIter(o); } +static jlong f_PyVectorcall_CallN(JNIEnv *e, jclass c, jlong callable, jlong tuple, jlong dict) { return PyVectorcall_Call(callable, tuple, dict); } +static jint f_PyNumber_CheckN(JNIEnv *e, jclass c, jlong o) { return PyNumber_Check(o); } +static jlong f_PyNumber_MatrixMultiplyN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_MatrixMultiply(o1, o2); } +static jlong f_PyNumber_FloorDivideN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_FloorDivide(o1, o2); } +static jlong f_PyNumber_TrueDivideN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_TrueDivide(o1, o2); } +static jlong f_PyNumber_RemainderN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_Remainder(o1, o2); } +static jlong f_PyNumber_DivmodN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_Divmod(o1, o2); } +static jlong f_PyNumber_PowerN(JNIEnv *e, jclass c, jlong o1, jlong o2, jlong o3) { return PyNumber_Power(o1, o2, o3); } +static jlong f_PyNumber_NegativeN(JNIEnv *e, jclass c, jlong o) { return PyNumber_Negative(o); } +static jlong f_PyNumber_PositiveN(JNIEnv *e, jclass c, jlong o) { return PyNumber_Positive(o); } +static jlong f_PyNumber_AbsoluteN(JNIEnv *e, jclass c, jlong o) { return PyNumber_Absolute(o); } +static jlong f_PyNumber_InvertN(JNIEnv *e, jclass c, jlong o) { return PyNumber_Invert(o); } +static jlong f_PyNumber_LshiftN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_Lshift(o1, o2); } +static jlong f_PyNumber_RshiftN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_Rshift(o1, o2); } +static jlong f_PyNumber_XorN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_Xor(o1, o2); } +static jlong f_PyNumber_InPlaceAddN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceAdd(o1, o2); } +static jlong f_PyNumber_InPlaceSubtractN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceSubtract(o1, o2); } +static jlong f_PyNumber_InPlaceMultiplyN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceMultiply(o1, o2); } +static jlong f_PyNumber_InPlaceMatrixMultiplyN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceMatrixMultiply(o1, o2); } +static jlong f_PyNumber_InPlaceFloorDivideN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceFloorDivide(o1, o2); } +static jlong f_PyNumber_InPlaceTrueDivideN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceTrueDivide(o1, o2); } +static jlong f_PyNumber_InPlaceRemainderN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceRemainder(o1, o2); } +static jlong f_PyNumber_InPlacePowerN(JNIEnv *e, jclass c, jlong o1, jlong o2, jlong o3) { return PyNumber_InPlacePower(o1, o2, o3); } +static jlong f_PyNumber_InPlaceLshiftN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceLshift(o1, o2); } +static jlong f_PyNumber_InPlaceRshiftN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceRshift(o1, o2); } +static jlong f_PyNumber_InPlaceAndN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceAnd(o1, o2); } +static jlong f_PyNumber_InPlaceXorN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceXor(o1, o2); } +static jlong f_PyNumber_InPlaceOrN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PyNumber_InPlaceOr(o1, o2); } +static jlong f_PyNumber_LongN(JNIEnv *e, jclass c, jlong o) { return PyNumber_Long(o); } +static jlong f_PyNumber_FloatN(JNIEnv *e, jclass c, jlong o) { return PyNumber_Float(o); } +static jlong f_PyNumber_IndexN(JNIEnv *e, jclass c, jlong o) { return PyNumber_Index(o); } +static jlong f_PyNumber_ToBaseN(JNIEnv *e, jclass c, jlong n, jint base) { return PyNumber_ToBase(n, base); } +static jint f_PyIndex_CheckN(JNIEnv *e, jclass c, jlong o) { return PyIndex_Check(o); } +static jint f_PySequence_CheckN(JNIEnv *e, jclass c, jlong o) { return PySequence_Check(o); } +static jlong f_PySequence_ConcatN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PySequence_Concat(o1, o2); } +static jlong f_PySequence_InPlaceConcatN(JNIEnv *e, jclass c, jlong o1, jlong o2) { return PySequence_InPlaceConcat(o1, o2); } +static jint f_PyMapping_CheckN(JNIEnv *e, jclass c, jlong o) { return PyMapping_Check(o); } +static jint f_PyMapping_HasKeyWithErrorN(JNIEnv *e, jclass c, jlong o, jlong key) { return PyMapping_HasKeyWithError(o, key); } +static jint f_PyMapping_HasKeyN(JNIEnv *e, jclass c, jlong o, jlong key) { return PyMapping_HasKey(o, key); } +static jlong f_PyMapping_KeysN(JNIEnv *e, jclass c, jlong o) { return PyMapping_Keys(o); } +static jlong f_PyMapping_ValuesN(JNIEnv *e, jclass c, jlong o) { return PyMapping_Values(o); } +static jlong f_PyMapping_ItemsN(JNIEnv *e, jclass c, jlong o) { return PyMapping_Items(o); } +static jint f_PyIter_CheckN(JNIEnv *e, jclass c, jlong o) { return PyIter_Check(o); } +static jint f_PyAIter_CheckN(JNIEnv *e, jclass c, jlong o) { return PyAIter_Check(o); } +static jlong f_PyLong_FromDoubleN(JNIEnv *e, jclass c, double v) { return PyLong_FromDouble(v); } +static jdouble f_PyLong_AsDoubleN(JNIEnv *e, jclass c, jlong pylong) { return PyLong_AsDouble(pylong); } +static jlong f_PyLong_GetInfoN(JNIEnv *e, jclass c) { return PyLong_GetInfo(); } +static jlong f_PyFloat_FromStringN(JNIEnv *e, jclass c, jlong str) { return PyFloat_FromString(str); } +static jlong f_PyFloat_GetInfoN(JNIEnv *e, jclass c) { return PyFloat_GetInfo(); } +static jdouble f_PyFloat_GetMaxN(JNIEnv *e, jclass c) { return PyFloat_GetMax(); } +static jdouble f_PyFloat_GetMinN(JNIEnv *e, jclass c) { return PyFloat_GetMin(); } +static jlong f_PyBytes_FromObjectN(JNIEnv *e, jclass c, jlong o) { return PyBytes_FromObject(o); } +static jlong f_PyByteArray_FromObjectN(JNIEnv *e, jclass c, jlong o) { return PyByteArray_FromObject(o); } +static jlong f_PyByteArray_ConcatN(JNIEnv *e, jclass c, jlong a, jlong b) { return PyByteArray_Concat(a, b); } +static jint f_PyUnicode_IsIdentifierN(JNIEnv *e, jclass c, jlong unicode) { return PyUnicode_IsIdentifier(unicode); } +static jlong f_PyUnicode_FromObjectN(JNIEnv *e, jclass c, jlong obj) { return PyUnicode_FromObject(obj); } +static jlong f_PyUnicode_EncodeFSDefaultN(JNIEnv *e, jclass c, jlong unicode) { return PyUnicode_EncodeFSDefault(unicode); } +static jlong f_PyUnicode_AsUTF8StringN(JNIEnv *e, jclass c, jlong unicode) { return PyUnicode_AsUTF8String(unicode); } +static jlong f_PyUnicode_AsUTF32StringN(JNIEnv *e, jclass c, jlong unicode) { return PyUnicode_AsUTF32String(unicode); } +static jlong f_PyUnicode_AsUTF16StringN(JNIEnv *e, jclass c, jlong unicode) { return PyUnicode_AsUTF16String(unicode); } +static jlong f_PyUnicode_AsUnicodeEscapeStringN(JNIEnv *e, jclass c, jlong unicode) { return PyUnicode_AsUnicodeEscapeString(unicode); } +static jlong f_PyUnicode_AsRawUnicodeEscapeStringN(JNIEnv *e, jclass c, jlong unicode) { return PyUnicode_AsRawUnicodeEscapeString(unicode); } +static jlong f_PyUnicode_AsLatin1StringN(JNIEnv *e, jclass c, jlong unicode) { return PyUnicode_AsLatin1String(unicode); } +static jlong f_PyUnicode_AsASCIIStringN(JNIEnv *e, jclass c, jlong unicode) { return PyUnicode_AsASCIIString(unicode); } +static jlong f_PyUnicode_AsCharmapStringN(JNIEnv *e, jclass c, jlong unicode, jlong mapping) { return PyUnicode_AsCharmapString(unicode, mapping); } +static jlong f_PyUnicode_SplitlinesN(JNIEnv *e, jclass c, jlong unicode, jint keepends) { return PyUnicode_Splitlines(unicode, keepends); } +static jlong f_PyUnicode_JoinN(JNIEnv *e, jclass c, jlong separator, jlong seq) { return PyUnicode_Join(separator, seq); } +static jint f_PyUnicode_CompareN(JNIEnv *e, jclass c, jlong left, jlong right) { return PyUnicode_Compare(left, right); } +static jlong f_PyUnicode_RichCompareN(JNIEnv *e, jclass c, jlong left, jlong right, jint op) { return PyUnicode_RichCompare(left, right, op); } +static jlong f_PyUnicode_FormatN(JNIEnv *e, jclass c, jlong format, jlong args) { return PyUnicode_Format(format, args); } +static jlong f_PyDictProxy_NewN(JNIEnv *e, jclass c, jlong mapping) { return PyDictProxy_New(mapping); } +static jlong f_PyDict_CopyN(JNIEnv *e, jclass c, jlong p) { return PyDict_Copy(p); } +static jlong f_PyDict_GetItemWithErrorN(JNIEnv *e, jclass c, jlong p, jlong key) { return PyDict_GetItemWithError(p, key); } +static jlong f_PyDict_KeysN(JNIEnv *e, jclass c, jlong p) { return PyDict_Keys(p); } +static jint f_PyDict_MergeN(JNIEnv *e, jclass c, jlong a, jlong b, jint override) { return PyDict_Merge(a, b, override); } +static jint f_PyDict_UpdateN(JNIEnv *e, jclass c, jlong a, jlong b) { return PyDict_Update(a, b); } +static jint f_PyDict_MergeFromSeq2N(JNIEnv *e, jclass c, jlong a, jlong seq2, jint override) { return PyDict_MergeFromSeq2(a, seq2, override); } +static jlong f_PySeqIter_NewN(JNIEnv *e, jclass c, jlong seq) { return PySeqIter_New(seq); } +static jlong f_PyCallIter_NewN(JNIEnv *e, jclass c, jlong callable, jlong sentinel) { return PyCallIter_New(callable, sentinel); } +static jlong f_PyWeakref_NewRefN(JNIEnv *e, jclass c, jlong ob, jlong callback) { return PyWeakref_NewRef(ob, callback); } +static jlong f_PyWeakref_NewProxyN(JNIEnv *e, jclass c, jlong ob, jlong callback) { return PyWeakref_NewProxy(ob, callback); } +/* The out-parameter's slot is an ordinary C local, so the boundary still carries only + primitives. Status 1 means the slot was written; 0 (dead referent) and -1 (error) both + collapse to 0, which the caller separates with PyErr_Occurred. */ +static jlong f_PyWeakref_GetRefN(JNIEnv *e, jclass c, jlong ref) { + void *obj = NULL; + if (PyWeakref_GetRef(ref, &obj) != 1) return 0; + return (jlong)obj; +} +static void f_PyObject_ClearWeakRefsN(JNIEnv *e, jclass c, jlong o) { PyObject_ClearWeakRefs(o); } +static jint f_PyType_ReadyN(JNIEnv *e, jclass c, jlong type) { return PyType_Ready(type); } +static jlong f_PyType_GetFullyQualifiedNameN(JNIEnv *e, jclass c, jlong type) { return PyType_GetFullyQualifiedName(type); } +static jlong f_PyType_GetModuleNameN(JNIEnv *e, jclass c, jlong type) { return PyType_GetModuleName(type); } +static jlong f_PyType_GetModuleN(JNIEnv *e, jclass c, jlong type) { return PyType_GetModule(type); } + + +/* + * =========================================================================== + * Shape vocabulary. + * =========================================================================== + * + * The 14 ABI shapes every CPython Stable ABI function collapses into (see + * docs/design/downcall-design.md and jvmMain/.../ShapeDowncalls.kt). The target function's address + * arrives as the leading `fn` argument and is called indirectly. + * + * These used to reach the `@CName` trampolines in `nativeMain/.../EmbedAPI.native.kt` by name, + * which is the broken path -- and worse than the ordinary case, because a shape function's first + * argument is a function pointer, so a shifted argument register is a wild call rather than a bad + * PyObject*. Doing the indirect call here removes both the fallback and the extra hop through + * Kotlin/Native. + * + * Ordinary JNI: `fn` can be any CPython entry point, including ones that run Python. + */ +static void f_downcall_V(JNIEnv *e, jclass c, jlong fn) { ((void (*)(void))(intptr_t)fn)(); } +static jlong f_downcall_I(JNIEnv *e, jclass c, jlong fn) { return ((jlong (*)(void))(intptr_t)fn)(); } +static jdouble f_downcall_F(JNIEnv *e, jclass c, jlong fn) { return ((jdouble (*)(void))(intptr_t)fn)(); } +static void f_downcallI_V(JNIEnv *e, jclass c, jlong fn, jlong a0) { ((void (*)(jlong))(intptr_t)fn)(a0); } +static jlong f_downcallI_I(JNIEnv *e, jclass c, jlong fn, jlong a0) { return ((jlong (*)(jlong))(intptr_t)fn)(a0); } +static jdouble f_downcallI_F(JNIEnv *e, jclass c, jlong fn, jlong a0) { return ((jdouble (*)(jlong))(intptr_t)fn)(a0); } +static jlong f_downcallF_I(JNIEnv *e, jclass c, jlong fn, jdouble a0) { return ((jlong (*)(jdouble))(intptr_t)fn)(a0); } +static void f_downcallII_V(JNIEnv *e, jclass c, jlong fn, jlong a0, jlong a1) { ((void (*)(jlong, jlong))(intptr_t)fn)(a0, a1); } +static jlong f_downcallII_I(JNIEnv *e, jclass c, jlong fn, jlong a0, jlong a1) { return ((jlong (*)(jlong, jlong))(intptr_t)fn)(a0, a1); } +static void f_downcallIII_V(JNIEnv *e, jclass c, jlong fn, jlong a0, jlong a1, jlong a2) { ((void (*)(jlong, jlong, jlong))(intptr_t)fn)(a0, a1, a2); } +static jlong f_downcallIII_I(JNIEnv *e, jclass c, jlong fn, jlong a0, jlong a1, jlong a2) { return ((jlong (*)(jlong, jlong, jlong))(intptr_t)fn)(a0, a1, a2); } +static jlong f_downcallIIII_I(JNIEnv *e, jclass c, jlong fn, jlong a0, jlong a1, jlong a2, jlong a3) { return ((jlong (*)(jlong, jlong, jlong, jlong))(intptr_t)fn)(a0, a1, a2, a3); } +static jlong f_downcallIIIII_I(JNIEnv *e, jclass c, jlong fn, jlong a0, jlong a1, jlong a2, jlong a3, jlong a4) { return ((jlong (*)(jlong, jlong, jlong, jlong, jlong))(intptr_t)fn)(a0, a1, a2, a3, a4); } +static jlong f_downcallIIIIII_I(JNIEnv *e, jclass c, jlong fn, jlong a0, jlong a1, jlong a2, jlong a3, jlong a4, jlong a5) { return ((jlong (*)(jlong, jlong, jlong, jlong, jlong, jlong))(intptr_t)fn)(a0, a1, a2, a3, a4, a5); } + +/* + * Symbol lookup for the shapes above. + * + * `dlopen(NULL, RTLD_NOW)` returns a handle onto the process's global symbol table without + * loading anything -- libpython is already there, linked at build time. This mirrors what + * `ffiSymbolRaw` did in `nativeMain`, minus the jstring: the name arrives as an already-encoded + * C string address, because `ffiSymbol` on the JVM side caches by name and so encodes each + * symbol once. + */ +static void *g_dlGlobal = NULL; +static jlong f_ffiSymbolRawN(JNIEnv *e, jclass c, jlong name) { + if (name == 0) return 0; + if (g_dlGlobal == NULL) g_dlGlobal = dlopen(NULL, RTLD_NOW); + if (g_dlGlobal == NULL) return 0; + return (jlong)(intptr_t)dlsym(g_dlGlobal, (const char *)(intptr_t)name); +} + + +/* + * Composition probes. + * + * Everything else here crosses the JNI boundary once per C API call. These do the whole + * binder-level operation on the native side and cross once, which is the design the Android + * path was originally built around (artMain assembling, androidMain calling the assembly). + * They exist to measure what that structure is worth before committing to it. + * + * Ordinary JNI on purpose: they need JNIEnv to touch the jstring and the jlong[], and they + * run Python code, so a GC-blocking convention would be wrong regardless of speed. + */ +static jlong asm_getattr(JNIEnv *e, jclass c, jlong obj, jstring name) { + const char *n = (*e)->GetStringUTFChars(e, name, NULL); + if (n == NULL) return 0; + jlong r = PyObject_GetAttrString(obj, n); + (*e)->ReleaseStringUTFChars(e, name, n); + if (r == 0) PyErr_Clear(); + return r; +} + +static jint asm_list_to_array(JNIEnv *e, jclass c, jlong list, jlongArray out) { + jsize cap = (*e)->GetArrayLength(e, out); + jlong len = PyList_Size(list); + jsize n = (len < (jlong)cap) ? (jsize)len : cap; + jlong *buf = (*e)->GetLongArrayElements(e, out, NULL); + if (buf == NULL) return -1; + for (jsize i = 0; i < n; i++) buf[i] = PyList_GetItem(list, (jlong)i); + (*e)->ReleaseLongArrayElements(e, out, buf, 0); + return n; +} + +/* + * Python3.exec composed into one crossing. + * + * The Kotlin version crosses roughly eleven times: three C strings each needing an alloc and a + * free around the call that uses them, plus AddModuleRef, GetAttrString, RunString and the + * DecRefs. This is the same sequence with the boundary crossed once, so the difference between + * them is everything the boundary costs -- crossings and string marshalling both. + * + * Unlike the echo probes this runs real Python, so the parse/compile/execute work is in both + * numbers and the ratio shows what fraction of a realistic binder call the boundary actually is. + */ +/* + * No NUM_METHODS constant lives here on purpose. There used to be one, it drifted out of step + * with the table, and RegisterNatives silently bound a prefix of it. The count is taken with + * sizeof at the call site instead, which cannot drift. + */ +#define PMP_FILE_INPUT 257 + +static jint asm_exec(JNIEnv *e, jclass c, jstring code) { + const char *src = (*e)->GetStringUTFChars(e, code, NULL); + if (src == NULL) return -1; + jint rc = -1; + jlong mod = PyImport_AddModuleRef("__main__"); + if (mod != 0) { + jlong globals = PyObject_GetAttrString(mod, "__dict__"); + if (globals != 0) { + jlong result = PyRun_String(src, PMP_FILE_INPUT, globals, globals); + if (result != 0) { Py_DecRef(result); rc = 0; } else { PyErr_Clear(); } + Py_DecRef(globals); + } + Py_DecRef(mod); + } + (*e)->ReleaseStringUTFChars(e, code, src); + return rc; +} + +/* + * Address of a DirectByteBuffer's off-heap storage. + * + * Called once per scratch buffer, not per string. The point is that everything after it -- + * encoding the string and copying the bytes in -- happens inside the JVM with no crossing at + * all, which is what Panama does on desktop and what makes desktop's marshalling ~12x cheaper + * than ffiAllocUtf8's JNI round trip. + */ +static jlong ffi_direct_addr(JNIEnv *e, jclass c, jobject buf) { + return (jlong)(*e)->GetDirectBufferAddress(e, buf); +} + +#include + +static JavaVM *g_vm = NULL; +static jclass g_upcallClass = NULL; +static jmethodID g_upcallPrimitive = NULL; +static jmethodID g_upcallString = NULL; +static jmethodID g_upcallTrack = NULL; + +static jlong test_upcall_primitive(JNIEnv *env, jclass clazz, jlong x) { + return (*env)->CallStaticLongMethod(env, g_upcallClass, g_upcallPrimitive, x); +} + +static jlong test_upcall_string(JNIEnv *env, jclass clazz, jstring s) { + const char *cstr = (*env)->GetStringUTFChars(env, s, NULL); + if (!cstr) return 0; + jstring jstr = (*env)->NewStringUTF(env, cstr); + jlong res = (*env)->CallStaticLongMethod(env, g_upcallClass, g_upcallString, jstr); + (*env)->DeleteLocalRef(env, jstr); + (*env)->ReleaseStringUTFChars(env, s, cstr); + return res; +} + +struct UnattachedData { jlong x; jlong res; }; + +static void* unattached_thread_func(void* arg) { + struct UnattachedData* d = (struct UnattachedData*)arg; + JNIEnv *env = NULL; + if ((*g_vm)->AttachCurrentThread(g_vm, &env, NULL) == JNI_OK) { + d->res = (*env)->CallStaticLongMethod(env, g_upcallClass, g_upcallPrimitive, d->x); + (*g_vm)->DetachCurrentThread(g_vm); + } + return NULL; +} + +static jlong test_upcall_unattached(JNIEnv *env, jclass clazz, jlong x) { + struct UnattachedData d = {x, -1}; + pthread_t t; + pthread_create(&t, NULL, unattached_thread_func, &d); + pthread_join(t, NULL); + return d.res; +} + +/* + * The experiment `pmp_attach`'s design rests on: a pthread that attaches as a daemon, calls into + * the JVM, and exits without detaching. + * + * The claim being tested is the one this file used to assert as fact -- that ART aborts the + * process for this. Reading `Thread::ThreadExitCallback` says it does not (see the block above + * `pmp_attach`), but a reading is not an observation, and the whole per-thread attach depends on + * which of the two is true. So it is run. + * + * The `java.lang.Thread` id of the attachment is handed back, because "did not abort" is only + * half the answer: the other half is what the process is left holding, and the caller can ask the + * thread list whether that peer is still in it. + * + * Deliberately does not register `g_detachKey` -- this is the *un*fixed path, kept as the + * evidence for the fixed one. + */ +struct LeakedAttachData { jlong id; }; + +static void* leaked_attach_thread_func(void* arg) { + struct LeakedAttachData* d = (struct LeakedAttachData*)arg; + JNIEnv *env = NULL; + if ((*g_vm)->AttachCurrentThreadAsDaemon(g_vm, &env, NULL) != JNI_OK) return NULL; + + if (g_upcallClass != NULL && g_upcallTrack != NULL) { + d->id = (*env)->CallStaticLongMethod(env, g_upcallClass, g_upcallTrack); + } + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + /* No DetachCurrentThread, and that is the point. */ + return NULL; +} + +static jlong test_attach_without_detach(JNIEnv *env, jclass clazz, jlong unused) { + struct LeakedAttachData d = {-1}; + pthread_t t; + if (g_vm == NULL) return -1; + if (pthread_create(&t, NULL, leaked_attach_thread_func, &d) != 0) return -1; + /* Returns only once the thread's TSD destructors have run, so ART's exit callback is past. */ + pthread_join(t, NULL); + return d.id; +} + +static void* empty_thread_func(void* arg) { return NULL; } + +static jlong test_thread_create_floor(JNIEnv *env, jclass clazz, jlong x) { + pthread_t t; + pthread_create(&t, NULL, empty_thread_func, NULL); + pthread_join(t, NULL); + return x; +} + +/* + * =========================================================================== + * Cycle-collecting proxy type. + * =========================================================================== + * + * Desktop builds tp_traverse/tp_clear as Panama upcall stubs; Kotlin/Native builds them with + * staticCFunction. Android has neither -- androidMain is Kotlin/JVM without Panama -- so the two + * slots are plain C functions here, and they reach Kotlin the same way everything else on this + * path does: through JNI, against a class registered in JNI_OnLoad. + * + * That inversion decides the division of labour. `visit` is a C function pointer that the JVM + * side has no way to call, so C keeps the visiting: Kotlin is asked only "which raw PyObject + * pointers does the object behind this handle hold?" and answers with a jlong[]. The loop over + * that array, and every call to `visit`, happens on this side. + * + * CPython holds the GIL when it calls both slots. Nothing here takes it and nothing here may + * release it. + * + * Layout note: the type is declared with a NEGATIVE basicsize, so the 8-byte handle lives in + * relative type data reached through PyObject_GetTypeData. Nothing here reads inside PyObject, + * which is what keeps the abi3 assumption the rest of the FFI layer makes. + */ + +typedef int (*pmp_visitproc)(void *, void *); + +/* Mirrors CPython's PyType_Slot / PyType_Spec. Declared rather than included: this .def has no + * Python.h on its include path (only the `python` cinterop does), and both structs are part of + * the stable ABI, so their layout is fixed. */ +typedef struct { int slot; void *pfunc; } pmp_type_slot; +typedef struct { + const char *name; + int basicsize; + int itemsize; + unsigned int flags; + pmp_type_slot *slots; +} pmp_type_spec; + +extern jlong PyType_FromSpec(void *spec); +extern void *PyObject_GetTypeData(jlong obj, jlong type); + +/* + * The two the dealloc slot needs. PyObject_Type and Py_DecRef are already declared above. + * + * PyType_GetSlot is the abi3 route to a slot function of a type whose struct is opaque under the + * limited API; tp_dealloc needs it for Py_tp_free (74), because a hand-written dealloc has to + * free the object itself and only the type knows which allocator to use. + */ +extern void *PyType_GetSlot(jlong type, int slot); +extern void PyObject_GC_UnTrack(void *op); + +/* CPython's freefunc: void (*)(void *). */ +typedef void (*pmp_freefunc)(void *); + +static jlong g_proxyType = 0; +static pmp_type_slot g_proxySlots[5]; +static pmp_type_spec g_proxySpec; + +static jclass g_proxyCallbacks = NULL; +static jmethodID g_proxyTraverse = NULL; +static jmethodID g_proxyClear = NULL; + +/* + * A JNIEnv for a thread CPython may or may not have entered from the JVM. + * + * On most paths the collector runs on the thread that called into Python, and that thread came + * *from* ART -- so GetEnv succeeds and nothing is attached. The exception is a thread CPython + * created itself (threading.Thread is a bare pthread), where a collection, or a refcount reaching + * zero, runs on a thread ART has never seen. That is the case AttachCurrentThreadAsDaemon covers. + * + * That branch is exercised rather than assumed. CycleCollectionTest's + * testCycleCollectedOnAThreadCPythonCreated and testDeallocOnAThreadCPythonCreated do their work + * inside a threading.Thread, and they assert that the thread the callback arrived on is not the + * instrumentation thread -- so a pass cannot come from GetEnv having succeeded after all. Green + * on pmp_api26 and pmp_api36. + * + * ### The attachment is kept for the life of the thread, not the life of the call + * + * This used to detach on every exit, on the stated grounds that "ART aborts the process when a + * thread it knows about exits without detaching". That is measurably expensive and, on Android, + * it is not what ART does. `Thread::ThreadExitCallback` (runtime/thread.cc, unchanged in shape + * from android-8.0.0_r1 to main) reads: + * + * if (self->tls32_.thread_exit_check_count == 0) { + * LOG(WARNING) << "Native thread exiting without having called DetachCurrentThread (maybe " + * "it's going to use a pthread_key_create destructor?): " << *self; + * CHECK(is_started_); + * #ifdef __BIONIC__ + * __get_tls()[TLS_SLOT_ART_THREAD_SELF] = self; + * #else + * CHECK_PTHREAD_CALL(pthread_setspecific, (Thread::pthread_key_self_, self), "reattach self"); + * #endif + * self->tls32_.thread_exit_check_count = 1; + * } else { + * LOG(FATAL) << "Native thread exited without calling DetachCurrentThread: " << *self; + * } + * + * The LOG(FATAL) is reached only on a *second* invocation of the callback, and the only thing + * that arms a second invocation is the `pthread_setspecific` in the `#else` -- which Android does + * not take. Bionic clears a key's value before running its destructor and never re-reads it, so + * on Android the callback fires exactly once and the abort is unreachable. What is left is a + * warning, and a leak: the `java.lang.Thread` peer stays a GC root in the thread list with a + * recorded stack the pthread has already unmapped. + * + * So the detach is still mandatory -- but it belongs at thread death, not at call exit, which is + * the case the warning text itself points at. `pmp_thread_exit_detach` below is that destructor. + * Threads that reach here from ART (GetEnv succeeds) are untouched: nothing was attached, so + * nothing is registered and nothing is detached. + * + * `*attached` therefore now means "this call owns the attachment and must give it back", and it + * is set only on the fallback path where no destructor could be registered. Callers keep their + * `if (attached) DetachCurrentThread` exactly as before. + */ + +/* + * Set on a thread this file attached, so that thread's exit can detach it. The value is a + * sentinel; the attachment itself lives in ART's own TLS. + */ +static pthread_key_t g_detachKey; +static int g_detachKeyReady = 0; + +#define PMP_DETACH_PENDING ((void *)1) +#define PMP_DETACH_ARMED ((void *)2) + +/* + * Detaches at thread death. Registered on g_detachKey, so bionic calls it while the pthread is + * unwinding its thread-specific data. + * + * The first invocation re-arms instead of detaching, which is what makes this independent of the + * order bionic happens to visit keys in. Bionic iterates keys by index, ascending, for up to + * PTHREAD_DESTRUCTOR_ITERATIONS rounds, clearing each value before calling its destructor; ART's + * own key is created during runtime startup and ours in JNI_OnLoad, so ours is almost certainly + * the higher index and ART's callback would run first -- but "almost certainly" is not a + * guarantee, and getting it wrong is not benign. Detaching first would free the ART `Thread` + * while ART's key still holds a pointer to it, and ART's callback would then run `LOG(WARNING) + * << *self` over freed memory. Deferring to the second round removes the question: ART's callback + * can only run in a round where its value is still set, i.e. the first, and it does not re-arm + * itself on Android, so by the time this detaches it has already been and gone. + * + * Detaching from here is safe for the same reason that ordering is: on Android `Thread::Current()` + * comes from TLS_SLOT_ART_THREAD_SELF, a dedicated bionic slot that the pthread-key teardown does + * not touch, and which ART's callback re-populates on its way past. + * + * A thread that was detached by other means before exiting still lands here with the sentinel + * set; ART's DetachCurrentThread returns JNI_ERR for a thread it does not know, which is why that + * case needs no separate guard. + */ +static void pmp_thread_exit_detach(void *value) { + if (value == PMP_DETACH_PENDING && + g_detachKeyReady && + pthread_setspecific(g_detachKey, PMP_DETACH_ARMED) == 0) { + return; + } + if (g_vm != NULL) (*g_vm)->DetachCurrentThread(g_vm); +} + +static JNIEnv *pmp_attach(int *attached) { + JNIEnv *env = NULL; + *attached = 0; + if (g_vm == NULL) return NULL; + jint r = (*g_vm)->GetEnv(g_vm, (void **)&env, JNI_VERSION_1_6); + if (r == JNI_OK) return env; + if (r != JNI_EDETACHED) return NULL; + if ((*g_vm)->AttachCurrentThreadAsDaemon(g_vm, &env, NULL) != JNI_OK) return NULL; + /* Kept until the thread dies, unless there is no destructor to release it with. */ + if (g_detachKeyReady && pthread_setspecific(g_detachKey, PMP_DETACH_PENDING) == 0) return env; + *attached = 1; + return env; +} + +/* The 8 bytes of relative type data on a proxy instance, or NULL if this is not one. */ +static jlong *pmp_handle_slot(jlong obj) { + if (g_proxyType == 0 || obj == 0) return NULL; + return (jlong *)PyObject_GetTypeData(obj, g_proxyType); +} + +/* + * int tp_traverse(PyObject *self, visitproc visit, void *arg) + * + * The early returns are silent by design -- an object whose class is not registered with + * ClassLookup simply has nothing to report. That silence is worth knowing about: it reads + * exactly like "the object was never GC-tracked". If a cycle is not collected, check the + * registration before suspecting the slots. + * + * A pending JVM exception is cleared rather than propagated. There is no error channel back + * into a running collection except the return value, and a non-zero return aborts the traversal + * instead of reporting anything. + */ +static int pmp_proxy_traverse(void *self, pmp_visitproc visit, void *arg) { + if (visit == NULL) return 0; + jlong *slot = pmp_handle_slot((jlong)self); + if (slot == NULL) return 0; + jlong handle = *slot; + if (handle == 0) return 0; + if (g_proxyCallbacks == NULL || g_proxyTraverse == NULL) return 0; + + int attached = 0; + JNIEnv *env = pmp_attach(&attached); + if (env == NULL) return 0; + + int rc = 0; + jlongArray targets = + (jlongArray)(*env)->CallStaticObjectMethod(env, g_proxyCallbacks, g_proxyTraverse, handle); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + targets = NULL; + } + if (targets != NULL) { + jsize n = (*env)->GetArrayLength(env, targets); + if (n > 0) { + jlong *buf = (*env)->GetLongArrayElements(env, targets, NULL); + if (buf != NULL) { + for (jsize i = 0; i < n; i++) { + if (buf[i] == 0) continue; + int r = visit((void *)buf[i], arg); + if (r != 0) { rc = r; break; } + } + (*env)->ReleaseLongArrayElements(env, targets, buf, JNI_ABORT); + } + } + (*env)->DeleteLocalRef(env, targets); + } + if (attached) (*g_vm)->DetachCurrentThread(g_vm); + return rc; +} + +/* + * Empties the object's handle slot and returns what was in it, or 0. + * + * Zeroing before the caller releases is what lets tp_clear and tp_dealloc run one after the + * other without double-releasing. An object that dies inside a cycle takes both paths: tp_clear + * breaks the loop, the refcount then falls to zero, and tp_dealloc follows. The second call + * finds 0 here and does nothing. + */ +static jlong pmp_take_handle(void *self) { + jlong *slot = pmp_handle_slot((jlong)self); + if (slot == NULL) return 0; + jlong handle = *slot; + if (handle == 0) return 0; + *slot = 0; + return handle; +} + +/* + * Drops the Kotlin-side root for `handle`, and nothing else. + * + * Calling PyObject.close() on the Kotlin object's fields from here would decrement references + * the collector is in the middle of accounting for, which double-frees. The collector reclaims + * the Python side on its own. + */ +static void pmp_release_handle(jlong handle) { + if (handle == 0) return; + if (g_proxyCallbacks == NULL || g_proxyClear == NULL) return; + + int attached = 0; + JNIEnv *env = pmp_attach(&attached); + if (env == NULL) return; + + (*env)->CallStaticVoidMethod(env, g_proxyCallbacks, g_proxyClear, handle); + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + if (attached) (*g_vm)->DetachCurrentThread(g_vm); +} + +/* + * int tp_clear(PyObject *self) + * + * Releases the HandleTable entry and nothing else. That entry is the Kotlin-side half of the + * cycle and dropping it is what breaks the loop. + */ +static int pmp_proxy_clear(void *self) { + pmp_release_handle(pmp_take_handle(self)); + return 0; +} + +/* + * void tp_dealloc(PyObject *self) -- the slot tp_clear is not a substitute for. + * + * tp_clear runs only when the cyclic collector decides to break a loop. A proxy whose refcount + * simply reaches zero never goes near it, and that is the ordinary case: cycles are the + * exception. Without this slot every such proxy left its HandleTable entry rooted for the life of + * the interpreter, holding a Kotlin object nothing could reach again. + * + * Unlike tp_clear, this one owns the object's memory. Installing it replaces CPython's + * subtype_dealloc outright, so everything that function does for a GC'd heap type happens here: + * + * - untrack first. The type carries Py_TPFLAGS_HAVE_GC, so the instance is on the collector's + * list; freeing it while still linked leaves the collector walking released memory. + * - free through the type's own tp_free, reached with PyType_GetSlot. The type must come from + * the instance rather than from g_proxyType: the type is declared Py_TPFLAGS_BASETYPE, so + * Python code may subclass it and inherit this function. + * - release the instance's reference to its type. Since 3.8 an instance of a heap type holds + * one and subtype_dealloc gives it back. Forgetting leaks the type once per instance; doing it + * twice frees the type while it is still in use. + * + * Py_TYPE is a macro over a struct this file deliberately does not include, so the type comes + * from PyObject_Type -- a real stable-ABI function that returns a NEW reference. Hence the two + * decrements: one for that temporary, one for the instance's own. + * + * CPython holds the GIL here, as for the other two slots. Nothing here takes or releases it. + */ +static void pmp_proxy_dealloc(void *self) { + if (self == NULL) return; + + /* Taken before anything is torn down; this reference keeps the type alive across the free. */ + jlong type = PyObject_Type((jlong)self); + + PyObject_GC_UnTrack(self); + pmp_release_handle(pmp_take_handle(self)); + + if (type != 0) { + pmp_freefunc tp_free = (pmp_freefunc)PyType_GetSlot(type, 74 /* Py_tp_free */); + if (tp_free != NULL) { + tp_free(self); + Py_DecRef(type); /* the reference the instance held on its heap type */ + } + Py_DecRef(type); /* the reference PyObject_Type just handed us */ + } +} + +/* + * Equivalent to PyMemberDef from structmember.h. + * 40 bytes on 64-bit platforms (arm64-v8a, x86_64). + */ +typedef struct { + const char *name; + int type; + intptr_t offset; + int flags; + const char *doc; +} pmp_member_def; + +/* + * Slot 3: Py_tp_members (72) -- the handle slot exposed as _pm_handle to Python. + * Py_T_LONG = 2, Py_RELATIVE_OFFSET = 8. + */ +static pmp_member_def g_proxyMembers[2] = { + {"_pm_handle", 2 /* Py_T_LONG */, 0 /* relative offset */, 8 /* Py_RELATIVE_OFFSET */, NULL}, + {NULL, 0, 0, 0, NULL} +}; + +/* + * Creates the proxy type once and returns its address. + * + * The spec, its slot array and the name are statics rather than heap allocations, because + * PyType_FromSpec stores spec->name directly as tp_name instead of copying it -- a freed name + * would leave the type pointing at released memory. The type lives for the process anyway. + * + * Ordinary JNI: it runs type creation, which can execute arbitrary Python. + */ +static jlong proxy_create_type(JNIEnv *e, jclass c) { + if (g_proxyType != 0) return g_proxyType; + + /* Slot ids from CPython's typeslots.h; they are ABI, not header-version-dependent. */ + g_proxySlots[0].slot = 71; /* Py_tp_traverse */ + g_proxySlots[0].pfunc = (void *)&pmp_proxy_traverse; + g_proxySlots[1].slot = 51; /* Py_tp_clear */ + g_proxySlots[1].pfunc = (void *)&pmp_proxy_clear; + g_proxySlots[2].slot = 52; /* Py_tp_dealloc */ + g_proxySlots[2].pfunc = (void *)&pmp_proxy_dealloc; + g_proxySlots[3].slot = 72; /* Py_tp_members */ + g_proxySlots[3].pfunc = (void *)g_proxyMembers; + g_proxySlots[4].slot = 0; /* sentinel */ + g_proxySlots[4].pfunc = NULL; + + g_proxySpec.name = "KotlinProxy"; + g_proxySpec.basicsize = -8; /* 8 bytes of relative type data: the handle */ + g_proxySpec.itemsize = 0; + /* Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC */ + g_proxySpec.flags = (1u << 10) | (1u << 14); + g_proxySpec.slots = g_proxySlots; + + g_proxyType = PyType_FromSpec(&g_proxySpec); + return g_proxyType; +} + +/* + * Handle accessors, composed on this side. + * + * androidMain could not do this itself: writing 8 bytes at a raw address from Kotlin/JVM needs + * sun.misc.Unsafe, which is a restricted non-SDK interface on Android. Crossing once with both + * the object and the value is also cheaper than exposing PyObject_GetTypeData and poking at the + * result, which is what artMain/README.md means by composed functions. + */ +static jlong proxy_get_handle(JNIEnv *e, jclass c, jlong obj) { + jlong *slot = pmp_handle_slot(obj); + return slot == NULL ? 0 : *slot; +} + +static void proxy_set_handle(JNIEnv *e, jclass c, jlong obj, jlong handle) { + jlong *slot = pmp_handle_slot(obj); + if (slot != NULL) *slot = handle; +} + +/* + * ob_refcnt of the object at `obj`, read straight out of the header. + * + * Py_REFCNT is a macro over a struct this file does not include, and sys.getrefcount counts its + * own argument's temporary reference -- neither can answer whether tp_dealloc released the + * instance's reference to its heap type exactly once, which is what the cycle-collection tests + * check. ob_refcnt is the first field of PyObject and the stable ABI fixes that; it is 64-bit on + * both ABIs this project ships (arm64-v8a, x86_64), so jlong is the right width. + * + * A test probe. Composed here for the same reason proxy_set_handle is: reading 8 bytes at a raw + * address from Kotlin/JVM needs sun.misc.Unsafe, a restricted non-SDK interface on Android. + * + * Ordinary JNI. A load of one word cannot run Python, but it is only ever called from a test, so + * there is nothing to gain from a GC-blocking convention here. + */ +static jlong ob_refcnt(JNIEnv *e, jclass c, jlong obj) { + if (obj == 0) return 0; + return *(jlong *)(intptr_t)obj; +} + +/* + * =========================================================================== + * Upcalls: Python -> Kotlin/JVM. + * =========================================================================== + * + * The last of the three JVM/native inversions in this file, and the same one as the proxy type's: + * desktop hands Python a Panama upcall stub, Kotlin/Native hands it a `staticCFunction`, and + * Android can produce neither -- `androidMain` is Kotlin/JVM on ART. A `PyMethodDef`'s `ml_meth` + * has to be a C function pointer, so the entry points are here and *they* call Kotlin, through + * `python/native/ffi/UpcallCallbacks` registered in JNI_OnLoad. + * + * Only the address-publishing step lives here. Every argument and every result is marshalled by + * `commonMain`'s UpcallTrampoline, which is shared with the other two platforms; what crosses this + * boundary is `(jlong callableHandle, jlong argsTuple) -> jlong`, i.e. primitives only, which is + * what androidMain/README.md requires of anything on this path. + * + * ### Conventions this side must not break + * + * - `args` is **borrowed** and is passed straight through. Nothing here touches its refcount; + * the trampoline wraps its items with `borrowed = true`. + * - The result is a **new** reference, produced by the trampoline and returned unchanged. + * - `self` on a bound `pm_invoke` is the callable handle as a PyLong, borrowed like any + * PyCFunction's self. A bad one becomes -1 (CallableHandle.NONE), which the trampoline rejects + * with the error indicator set -- not a crash, and not a call to whatever entry 0 happens to be. + * - Failure leaves as 0 with Python's error indicator set. A pending *JVM* exception is cleared + * before returning, because ART aborts if one is left set across a return into native code. + * + * ### The GIL + * + * CPython holds the GIL when it calls a PyCFunction, so nothing here takes it. The trampoline on + * the Kotlin side still takes its own PyGILState_Ensure/Release pair unconditionally -- that is + * commonMain/README.md's rule for any entry point reached from C, and PyGILState_Ensure is + * reentrant, so the second one costs a counter bump. + * + * ### Cost + * + * One JNI upcall per call, plus an attach the first time Python calls from a thread ART has never + * seen -- every `threading.Thread` is a bare pthread, so that is not a corner case. + * + * The attach used to be paid on every call, because this file asserted that ART aborts a process + * whose thread exits without detaching. It does not (see the block above pmp_attach, and + * UpcallThreadAttachTest, which runs the case). Measured by UpcallOverheadTest on pmp_api26 / + * pmp_api36, two runs each, per upcall from a Python worker in steady state: + * + * was (attach per call) now (attach per thread) + * API 26 59712-63293 ns 1209-1329 ns + * API 36 19820-31219 ns 2301-3086 ns + * + * against 1280-1327 ns / 2982-5086 ns for the same upcall on a thread ART already knows -- i.e. + * the difference now straddles zero and the attach has stopped being visible in the steady state + * at all. What is left is the JNI upcall itself, and the first call on each worker, which still + * pays the whole attach: 56-142 us. + */ + +/* From methodobject.h. Flag values are ABI, not header-version-dependent. */ +#define PMP_METH_VARARGS 0x0001 +#define PMP_METH_O 0x0008 + +/* + * PyCFunction, with the same jlong-for-PyObject* convention the rest of this file uses. Both ABIs + * this project ships (arm64-v8a, x86_64) pass a pointer and an int64_t identically. + */ +typedef jlong (*pmp_pycfunction)(jlong, jlong); + +/* Mirrors CPython's PyMethodDef; stable ABI, so its layout is fixed. */ +typedef struct { + const char *ml_name; + pmp_pycfunction ml_meth; + int ml_flags; + const char *ml_doc; +} pmp_method_def; + +extern jlong PyCMethod_New(void *ml, jlong self, jlong module, jlong cls); +extern void *PyExc_RuntimeError; + +static jclass g_upcallBridge = NULL; +static jmethodID g_upcallInvokeM = NULL; +static jmethodID g_upcallResolveM = NULL; +static jmethodID g_upcallReleaseM = NULL; +static jmethodID g_upcallCancelM = NULL; + +static jlong pmp_upcall_invoke_meth(jlong self, jlong args); +static jlong pmp_upcall_invoke_free_meth(jlong self, jlong args); +static jlong pmp_upcall_resolve_meth(jlong self, jlong name); +static jlong pmp_upcall_bind_meth(jlong self, jlong handle); +static jlong pmp_upcall_release_meth(jlong self, jlong handle); +static jlong pmp_upcall_cancel_meth(jlong self, jlong handle); + +/* + * Static rather than heap-allocated: a PyCFunction object stores the PyMethodDef * it was built + * from instead of copying it, so anything freed here would leave every callable built from it + * pointing at released memory. Six structs for the life of the process. + */ +static pmp_method_def g_pmInvokeDef = + {"pm_invoke", &pmp_upcall_invoke_meth, PMP_METH_VARARGS, NULL}; +static pmp_method_def g_pmInvokeFreeDef = + {"_pm_invoke", &pmp_upcall_invoke_free_meth, PMP_METH_VARARGS, NULL}; +static pmp_method_def g_pmResolveDef = + {"_pm_resolve", &pmp_upcall_resolve_meth, PMP_METH_O, NULL}; +static pmp_method_def g_pmBindDef = + {"_pm_bind", &pmp_upcall_bind_meth, PMP_METH_O, NULL}; +static pmp_method_def g_pmReleaseDef = + {"_pm_release", &pmp_upcall_release_meth, PMP_METH_O, NULL}; +static pmp_method_def g_pmCancelDef = + {"_pm_cancel", &pmp_upcall_cancel_meth, PMP_METH_O, NULL}; + +/* + * Returns 0 -- the only failure signal this boundary has -- with an error indicator set. + * + * An error Python already has is left exactly as it is. Overwriting it would replace whatever the + * exposed Kotlin function raised with a generic message from the plumbing, and the trampoline sets + * one on every failure it handles itself. + */ +static jlong pmp_upcall_fail(const char *msg) { + if (PyErr_Occurred() == 0) { + PyErr_SetString((jlong)(intptr_t)PyExc_RuntimeError, msg); + } + return 0; +} + +/* The callable handle inside a bound pm_invoke's `self`, or -1 (CallableHandle.NONE). */ +static jlong pmp_upcall_handle_of(jlong self) { + jlong handle; + if (self == 0) return -1; + handle = PyLong_AsLongLong(self); + if (PyErr_Occurred() != 0) { + PyErr_Clear(); + return -1; + } + return handle; +} + +/* + * PyObject *pm_invoke(PyObject *self, PyObject *args) -- the one C shape argument passing needs. + * + * `self` carries the callable handle; when the generated proxy type lands it carries the receiver + * instead and this signature does not change, which is the whole reason the shape was chosen. + */ +static jlong pmp_upcall_invoke_meth(jlong self, jlong args) { + JNIEnv *env; + int attached = 0; + jlong result; + + if (g_upcallBridge == NULL || g_upcallInvokeM == NULL) { + return pmp_upcall_fail("the Kotlin upcall bridge was not registered at load time"); + } + env = pmp_attach(&attached); + if (env == NULL) return pmp_upcall_fail("no JNIEnv is available on this thread"); + + result = (*env)->CallStaticLongMethod( + env, g_upcallBridge, g_upcallInvokeM, pmp_upcall_handle_of(self), args); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + result = 0; + } + if (attached) (*g_vm)->DetachCurrentThread(g_vm); + + if (result == 0) return pmp_upcall_fail("the Kotlin upcall failed"); + return result; +} + +/* + * PyObject *_pm_invoke(PyObject *self, PyObject *args) -- METH_VARARGS, **unbound**: `args` is + * `(handle, args_tuple)`. + * + * The shape PythonProxySource's generated module calls, and the same one desktop's + * `ctypes.CFUNCTYPE(py_object, c_long, py_object)` shim and nativeMain's `pmInvokeFreeMethod` + * present. It grants nothing pmp_upcall_bind_meth did not already grant -- `_pm_bind` takes a raw + * handle straight from Python and hands back a callable over it, so `_pm_bind(h)(*a)` and + * `_pm_invoke(h, a)` are the same capability with the same failure mode. + * + * Both tuple items are borrowed and nothing here releases either; the result is a new reference, + * which is UpcallTrampoline's contract and the same one pmp_upcall_invoke_meth relies on. + */ +static jlong pmp_upcall_invoke_free_meth(jlong self, jlong args) { + JNIEnv *env; + int attached = 0; + jlong handleItem; + jlong callArgs; + jlong raw; + jlong result; + + if (g_upcallBridge == NULL || g_upcallInvokeM == NULL) { + return pmp_upcall_fail("the Kotlin upcall bridge was not registered at load time"); + } + /* METH_VARARGS guarantees a tuple, so this can only be the wrong arity. */ + if (args == 0 || PyTuple_Size(args) != 2) { + return pmp_upcall_fail("_pm_invoke(handle, args_tuple) takes exactly two arguments"); + } + handleItem = PyTuple_GetItem(args, 0); + /* CallableHandle.NONE for a missing item; a non-integer leaves its own TypeError pending. */ + raw = (handleItem == 0) ? -1 : PyLong_AsLongLong(handleItem); + if (PyErr_Occurred() != 0) return 0; + /* Cannot fail for index 1 of a size-2 tuple. */ + callArgs = PyTuple_GetItem(args, 1); + + env = pmp_attach(&attached); + if (env == NULL) return pmp_upcall_fail("no JNIEnv is available on this thread"); + + result = (*env)->CallStaticLongMethod(env, g_upcallBridge, g_upcallInvokeM, raw, callArgs); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + result = 0; + } + if (attached) (*g_vm)->DetachCurrentThread(g_vm); + + if (result == 0) return pmp_upcall_fail("the Kotlin upcall failed"); + return result; +} + +/* + * METH_O: `str` **or** `bytes` -> handle, or -1. Called once per name; every call after that + * carries the integer. + * + * Both spellings are accepted because both are forced on real callers, exactly as nativeMain's + * pmResolveMethod records. A PyMethodDef host writes `_pm_resolve('pkg.fn')`, which is what `str` + * is for -- androidInstrumentedTest's bindUpcallOrNull does. But the one piece of Python that has + * to work against every host, PythonProxySource's `_pm_lookup`, sends `bytes`: desktop reaches its + * resolver through `ctypes.CFUNCTYPE(c_long, c_char_p)` and `c_char_p` refuses a `str` outright. + * + * This is the second half of the ART gap, and it was not the half that was written down. With only + * `_pm_invoke` added, PythonProxyInstallTest got past the generated module's entry-point guard and + * failed one line later on both emulators with `TypeError: bad argument type for built-in + * operation` -- PyUnicode_AsUTF8 refusing the `bytes` `_pm_lookup` had just encoded. + * + * Costs one failed PyUnicode_AsUTF8 and a PyErr_Clear on the `bytes` path, at install time only. + */ +static jlong pmp_upcall_resolve_meth(jlong self, jlong name) { + JNIEnv *env; + int attached = 0; + const char *text; + jstring jname; + jlong handle = -1; + + if (g_upcallBridge == NULL || g_upcallResolveM == NULL) { + return pmp_upcall_fail("the Kotlin upcall bridge was not registered at load time"); + } + text = (name == 0) ? NULL : PyUnicode_AsUTF8(name); + if (text == NULL && name != 0) { + /* The failed str read left a TypeError pending; it is not the answer yet. */ + PyErr_Clear(); + text = PyBytes_AsString(name); + } + /* + * Neither spelling matched, so PyBytes_AsString's TypeError is left pending -- a better answer + * than -1, which would read as "no such name". pmp_upcall_fail keeps whatever is already set. + */ + if (text == NULL) return pmp_upcall_fail("_pm_resolve expects a str or bytes"); + + env = pmp_attach(&attached); + if (env == NULL) return pmp_upcall_fail("no JNIEnv is available on this thread"); + + jname = (*env)->NewStringUTF(env, text); + if (jname != NULL) { + handle = (*env)->CallStaticLongMethod(env, g_upcallBridge, g_upcallResolveM, jname); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + handle = -1; + } + (*env)->DeleteLocalRef(env, jname); + } else if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + } + if (attached) (*g_vm)->DetachCurrentThread(g_vm); + + return PyLong_FromLongLong(handle); +} + +/* + * METH_O: handle -> a callable that invokes it. + * + * The handle is deliberately not validated. A stale one is rejected by the trampoline at call time + * with a proper Python exception, and checking twice would only move the same failure earlier while + * adding a second place that has to agree with the table's epoch rule. + * + * PyCMethod_New takes its own reference to `self`, so the PyLong built here is given back + * immediately -- keeping it would leak one integer per bound method. + */ +static jlong pmp_upcall_bind_meth(jlong self, jlong handle) { + jlong raw; + jlong boundSelf; + jlong fn; + + raw = (handle == 0) ? -1 : PyLong_AsLongLong(handle); + if (PyErr_Occurred() != 0) return 0; + + boundSelf = PyLong_FromLongLong(raw); + if (boundSelf == 0) return 0; + fn = PyCMethod_New(&g_pmInvokeDef, boundSelf, 0, 0); + Py_DecRef(boundSelf); + return fn; +} + +/* METH_O: object handle -> 1 if it released a live entry. What a proxy's tp_dealloc calls. */ +static jlong pmp_upcall_release_meth(jlong self, jlong handle) { + JNIEnv *env; + int attached = 0; + jlong raw; + jint released = 0; + + if (g_upcallBridge == NULL || g_upcallReleaseM == NULL) { + return pmp_upcall_fail("the Kotlin upcall bridge was not registered at load time"); + } + raw = (handle == 0) ? 0 : PyLong_AsLongLong(handle); + if (PyErr_Occurred() != 0) return 0; + + env = pmp_attach(&attached); + if (env == NULL) return pmp_upcall_fail("no JNIEnv is available on this thread"); + + released = (*env)->CallStaticIntMethod(env, g_upcallBridge, g_upcallReleaseM, raw); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + released = 0; + } + if (attached) (*g_vm)->DetachCurrentThread(g_vm); + + return PyLong_FromLongLong((jlong)released); +} + +/* + * METH_O: pending-call handle -> 1 if it cancelled a live call. + * + * What a cancelled asyncio.Future's done callback calls, so a suspended Kotlin coroutine hears + * about the cancellation at its next ensureActive() rather than only when it finishes. Same shape + * and same total-function rules as pmp_upcall_release_meth above. + */ +static jlong pmp_upcall_cancel_meth(jlong self, jlong handle) { + JNIEnv *env; + int attached = 0; + jlong raw; + jint cancelled = 0; + + if (g_upcallBridge == NULL || g_upcallCancelM == NULL) { + return pmp_upcall_fail("the Kotlin upcall bridge was not registered at load time"); + } + raw = (handle == 0) ? 0 : PyLong_AsLongLong(handle); + if (PyErr_Occurred() != 0) return 0; + + env = pmp_attach(&attached); + if (env == NULL) return pmp_upcall_fail("no JNIEnv is available on this thread"); + + cancelled = (*env)->CallStaticIntMethod(env, g_upcallBridge, g_upcallCancelM, raw); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + cancelled = 0; + } + if (attached) (*g_vm)->DetachCurrentThread(g_vm); + + return PyLong_FromLongLong((jlong)cancelled); +} + +/* PyDict_SetItemString takes a reference of its own, so the one built here is given back. */ +static int pmp_install_method(jlong ns, pmp_method_def *def) { + int ok; + jlong fn = PyCMethod_New(def, 0, 0, 0); + if (fn == 0) return 0; + ok = (PyDict_SetItemString(ns, def->ml_name, fn) == 0); + Py_DecRef(fn); + return ok; +} + +/* + * Installs `_pm_resolve`, `_pm_invoke`, `_pm_bind`, `_pm_release` and `_pm_cancel` into the Python + * dict at `ns`. 1 on success. + * + * That set is the whole bootstrap: everything after it is Python calling Python objects. + * + * `_pm_invoke` used to be withheld here, on the argument that the handle should never reach Python + * as a separate argument -- but `_pm_bind` hands Python the same handle and gives back a callable + * over it, so the two are one capability, and the only consumer the bootstrap has (the module + * PythonProxySource generates) calls `_pm_invoke(handle, args)` and refuses to install without it. + * nativeMain's UpcallEntry.publish reversed the same decision for the same reason. + * + * Ordinary JNI. It builds function objects and stores them in a dict, so it allocates GC-tracked + * containers and can reach a Python-level hook (androidMain/README.md, step 2) -- and it runs once + * per interpreter, so there is nothing to gain from a promotion anyway. The caller holds the GIL. + */ +static jint upcall_publish(JNIEnv *e, jclass c, jlong ns) { + if (ns == 0) return 0; + if (g_upcallBridge == NULL) return 0; + if (!pmp_install_method(ns, &g_pmResolveDef)) return 0; + if (!pmp_install_method(ns, &g_pmInvokeFreeDef)) return 0; + if (!pmp_install_method(ns, &g_pmBindDef)) return 0; + if (!pmp_install_method(ns, &g_pmReleaseDef)) return 0; + if (!pmp_install_method(ns, &g_pmCancelDef)) return 0; + return 1; +} + +int CustomJNI_OnLoad(JavaVM *vm, void *reserved) { + JNIEnv *env; + if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK) { + return JNI_ERR; + } + jclass clazz = (*env)->FindClass(env, "python/native/ffi/bindings"); + if (clazz == NULL) return JNI_ERR; + + g_vm = vm; + + /* + * Registered before anything can attach, so no thread can ever be attached without a + * destructor to release it. A failure here is not fatal: pmp_attach falls back to detaching on + * the way out of each call, which is what this file did unconditionally before. + */ + g_detachKeyReady = (pthread_key_create(&g_detachKey, pmp_thread_exit_detach) == 0); + + jclass upcallClazz = (*env)->FindClass(env, "python/native/ffi/UpcallTarget"); + if (upcallClazz != NULL) { + g_upcallClass = (jclass)(*env)->NewGlobalRef(env, upcallClazz); + g_upcallPrimitive = (*env)->GetStaticMethodID(env, g_upcallClass, "upcallPrimitive", "(J)J"); + g_upcallString = (*env)->GetStaticMethodID(env, g_upcallClass, "upcallString", "(Ljava/lang/String;)J"); + g_upcallTrack = (*env)->GetStaticMethodID(env, g_upcallClass, "trackCurrentThread", "()J"); + } + + /* + * The Kotlin half of tp_traverse/tp_clear. A global ref, because the slots are called from + * whichever thread the collector runs on, long after this frame is gone. + * + * Every failure here is cleared rather than propagated: FindClass and GetStaticMethodID both + * leave a pending exception on failure, and RegisterNatives below must not run with one set. + * A missing class leaves the slots as no-ops, which surfaces as an uncollected cycle rather + * than as a crash -- and, unlike an abort during library load, leaves the rest of the + * bindings usable. + */ + jclass proxyClazz = (*env)->FindClass(env, "python/multiplatform/ffi/ProxyCallbacks"); + if (proxyClazz != NULL) { + g_proxyCallbacks = (jclass)(*env)->NewGlobalRef(env, proxyClazz); + g_proxyTraverse = (*env)->GetStaticMethodID(env, g_proxyCallbacks, "traverse", "(J)[J"); + g_proxyClear = (*env)->GetStaticMethodID(env, g_proxyCallbacks, "clear", "(J)V"); + (*env)->DeleteLocalRef(env, proxyClazz); + } + + /* + * The Kotlin half of the upcall entry points. A global ref for the same reason: they are + * called from whichever thread Python happens to be running on, long after this frame is gone. + * + * Failures are cleared rather than propagated, as above. A missing class leaves upcall_publish + * returning 0 -- "the bootstrap could not be installed" -- rather than aborting the load and + * taking the rest of the bindings with it. + */ + jclass upcallBridgeClazz = (*env)->FindClass(env, "python/native/ffi/UpcallCallbacks"); + if (upcallBridgeClazz != NULL) { + g_upcallBridge = (jclass)(*env)->NewGlobalRef(env, upcallBridgeClazz); + g_upcallInvokeM = (*env)->GetStaticMethodID(env, g_upcallBridge, "invoke", "(JJ)J"); + g_upcallResolveM = + (*env)->GetStaticMethodID(env, g_upcallBridge, "resolve", "(Ljava/lang/String;)J"); + g_upcallReleaseM = (*env)->GetStaticMethodID(env, g_upcallBridge, "release", "(J)I"); + g_upcallCancelM = (*env)->GetStaticMethodID(env, g_upcallBridge, "cancel", "(J)I"); + (*env)->DeleteLocalRef(env, upcallBridgeClazz); + } + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + + JNINativeMethod methods[] = { + {"Py_Initialize", "()V", (void *)&Py_Initialize}, + {"Py_IsInitialized", "()I", (void *)&Py_IsInitialized}, + {"Py_Finalize", "()V", (void *)&Py_Finalize}, + {"PyErr_Clear", "()V", (void *)&PyErr_Clear}, + {"PyLong_FromLongLong", "(J)J", (void *)&PyLong_FromLongLong}, + {"PyList_Size", "(J)J", (void *)&PyList_Size}, + {"PyRun_SimpleString", "(J)I", (void *)&PyRun_SimpleString}, + {"Py_GetVersion", "()J", (void *)&Py_GetVersion}, + {"PyImport_ImportModule", "(J)J", (void *)&PyImport_ImportModule}, + {"PyObject_GetAttrString", "(JJ)J", (void *)&PyObject_GetAttrString}, + {"PyErr_Occurred", "()J", (void *)&PyErr_Occurred}, + {"echo0", "(J)J", (void *)&critical_echo0}, + {"echo1", "(J)J", (void *)&critical_echo1}, + {"echo2", "(J)J", (void *)&critical_echo2}, + {"echoNormal", "(J)J", (void *)&normal_echo}, + {"echoFast", "(J)J", (void *)&fast_echo}, + {"PyList_SizeFast", "(J)J", (void *)&pylistsize_fast}, + {"PyList_SizeNormal", "(J)J", (void *)&pylistsize_normal}, + + {"Py_InitializeF", "()V", (void *)&f_Py_Initialize}, + {"Py_IsInitializedF", "()I", (void *)&f_Py_IsInitialized}, + {"Py_FinalizeF", "()V", (void *)&f_Py_Finalize}, + {"PyErr_ClearF", "()V", (void *)&f_PyErr_Clear}, + {"PyLong_FromLongLongF", "(J)J", (void *)&f_PyLong_FromLongLong}, + {"PyList_SizeF", "(J)J", (void *)&f_PyList_Size}, + {"PyRun_SimpleStringF", "(J)I", (void *)&f_PyRun_SimpleString}, + {"Py_GetVersionF", "()J", (void *)&f_Py_GetVersion}, + {"PyImport_ImportModuleF", "(J)J", (void *)&f_PyImport_ImportModule}, + {"PyObject_GetAttrStringF", "(JJ)J", (void *)&f_PyObject_GetAttrString}, + {"PyErr_OccurredF", "()J", (void *)&f_PyErr_Occurred}, + {"PyList_GetItemRaw", "(JJ)J", (void *)&PyList_GetItem}, + {"PyList_GetItemRawF", "(JJ)J", (void *)&f_PyList_GetItemRaw}, + {"ffiDirectBufferAddress", "(Ljava/nio/ByteBuffer;)J", (void *)&ffi_direct_addr}, + {"asmExec", "(Ljava/lang/String;)I", (void *)&asm_exec}, + {"asmGetAttr", "(JLjava/lang/String;)J", (void *)&asm_getattr}, + {"asmListToArray", "(J[J)I", (void *)&asm_list_to_array}, + + /* + * Ordinary-JNI registrations, reusing the same wrappers as the @FastNative set -- + * the C signature is identical, only the Kotlin-side annotation differs. These back + * the functions that must never run under a GC-blocking convention. + */ + {"Py_InitializeN", "()V", (void *)&f_Py_Initialize}, + {"Py_FinalizeN", "()V", (void *)&f_Py_Finalize}, + {"PyErr_ClearN", "()V", (void *)&f_PyErr_Clear}, + {"PyRun_SimpleStringN", "(J)I", (void *)&f_PyRun_SimpleString}, + {"PyImport_ImportModuleN", "(J)J", (void *)&f_PyImport_ImportModule}, + {"PyImport_ImportN", "(J)J", (void *)&f_PyImport_ImportN}, + {"PyObject_GetAttrStringN", "(JJ)J", (void *)&f_PyObject_GetAttrString}, + + {"Py_DecRefN", "(J)V", (void *)&f_Py_DecRefN}, + {"Py_IncRefN", "(J)V", (void *)&f_Py_IncRefN}, + {"Py_NewRefN", "(J)J", (void *)&f_Py_NewRefN}, + {"Py_XNewRefN", "(J)J", (void *)&f_Py_XNewRefN}, + {"PyGILState_EnsureN", "()I", (void *)&f_PyGILState_EnsureN}, + {"PyGILState_ReleaseN", "(I)V", (void *)&f_PyGILState_ReleaseN}, + {"PyGILState_GetThisThreadStateN", "()J", (void *)&f_PyGILState_GetThisThreadStateN}, + {"PyEval_SaveThreadN", "()J", (void *)&f_PyEval_SaveThreadN}, + {"PyEval_RestoreThreadN", "(J)V", (void *)&f_PyEval_RestoreThreadN}, + {"Py_MakePendingCallsN", "()I", (void *)&f_Py_MakePendingCallsN}, + {"PyGC_CollectN", "()J", (void *)&f_PyGC_CollectN}, + {"PyEval_InitThreadsN", "()V", (void *)&f_PyEval_InitThreadsN}, + {"PyThreadState_GetDictN", "()J", (void *)&f_PyThreadState_GetDictN}, + {"PyTuple_NewN", "(J)J", (void *)&f_PyTuple_NewN}, + {"PyTuple_SetItemN", "(JJJ)I", (void *)&f_PyTuple_SetItemN}, + {"PyDict_NewN", "()J", (void *)&f_PyDict_NewN}, + {"PyDict_SetItemStringN", "(JJJ)I", (void *)&f_PyDict_SetItemStringN}, + {"PyDict_SetItemN", "(JJJ)I", (void *)&f_PyDict_SetItemN}, + {"PyDict_GetItemStringN", "(JJ)J", (void *)&f_PyDict_GetItemStringN}, + + + {"PyUnicode_AsUTF8", "(J)J", (void *)&PyUnicode_AsUTF8}, + {"PyUnicode_AsUTF8F", "(J)J", (void *)&f_PyUnicode_AsUTF8}, + {"PyUnicode_FromString", "(J)J", (void *)&PyUnicode_FromString}, + {"PyUnicode_FromStringF", "(J)J", (void *)&f_PyUnicode_FromString}, + + {"PyImport_AddModuleRefN", "(J)J", (void *)&f_PyImport_AddModuleRefN}, + {"PyImport_AddModuleN", "(J)J", (void *)&f_PyImport_AddModuleN}, + {"PySys_GetObjectN", "(J)J", (void *)&f_PySys_GetObjectN}, + {"PyErr_SetStringN", "(JJ)V", (void *)&f_PyErr_SetStringN}, + {"PyObject_SetAttrStringN", "(JJJ)I", (void *)&f_PyObject_SetAttrStringN}, + {"PyObject_DelAttrStringN", "(JJ)I", (void *)&f_PyObject_DelAttrStringN}, + {"PyRun_StringN", "(JIJJ)J", (void *)&f_PyRun_StringN}, + {"PyObject_StrN", "(J)J", (void *)&f_PyObject_StrN}, + {"PyObject_IsTrueN", "(J)I", (void *)&f_PyObject_IsTrueN}, + {"PyObject_CallNoArgsN", "(J)J", (void *)&f_PyObject_CallNoArgsN}, + {"PyObject_CallObjectN", "(JJ)J", (void *)&f_PyObject_CallObjectN}, + {"PyErr_PrintN", "()V", (void *)&f_PyErr_PrintN}, + {"PyErr_GetRaisedExceptionN", "()J", (void *)&f_PyErr_GetRaisedExceptionN}, + {"PyErr_SetRaisedExceptionN", "(J)V", (void *)&f_PyErr_SetRaisedExceptionN}, + {"PyException_GetTracebackN", "(J)J", (void *)&f_PyException_GetTracebackN}, + {"PyException_GetContextN", "(J)J", (void *)&f_PyException_GetContextN}, + {"PyException_GetCauseN", "(J)J", (void *)&f_PyException_GetCauseN}, + {"PyObject_TypeN", "(J)J", (void *)&f_PyObject_TypeN}, + {"PyObject_IsInstanceN", "(JJ)I", (void *)&f_PyObject_IsInstanceN}, + {"PyObject_ReprN", "(J)J", (void *)&f_PyObject_ReprN}, + {"PyObject_RichCompareN", "(JJI)J", (void *)&f_PyObject_RichCompareN}, + {"PyObject_RichCompareBoolN", "(JJI)I", (void *)&f_PyObject_RichCompareBoolN}, + {"PyObject_GetIterN", "(J)J", (void *)&f_PyObject_GetIterN}, + {"PyObject_GetItemN", "(JJ)J", (void *)&f_PyObject_GetItemN}, + {"PyObject_SetItemN", "(JJJ)I", (void *)&f_PyObject_SetItemN}, + {"PyObject_DelItemN", "(JJ)I", (void *)&f_PyObject_DelItemN}, + {"PyObject_CallN", "(JJJ)J", (void *)&f_PyObject_CallN}, + {"PyType_GetNameN", "(J)J", (void *)&f_PyType_GetNameN}, + {"PyType_IsSubtypeN", "(JJ)I", (void *)&f_PyType_IsSubtypeN}, + {"PyModule_GetDictN", "(J)J", (void *)&f_PyModule_GetDictN}, + {"PyModule_GetFilenameObjectN", "(J)J", (void *)&f_PyModule_GetFilenameObjectN}, + {"PyModule_GetNameN", "(J)J", (void *)&f_PyModule_GetNameN}, + {"PyDict_ClearN", "(J)V", (void *)&f_PyDict_ClearN}, + {"PyDict_ContainsN", "(JJ)I", (void *)&f_PyDict_ContainsN}, + {"PyDict_DelItemN", "(JJ)I", (void *)&f_PyDict_DelItemN}, + {"PyDict_GetItemN", "(JJ)J", (void *)&f_PyDict_GetItemN}, + {"PyDict_ItemsN", "(J)J", (void *)&f_PyDict_ItemsN}, + {"PyDict_SizeN", "(J)J", (void *)&f_PyDict_SizeN}, + {"PyDict_ValuesN", "(J)J", (void *)&f_PyDict_ValuesN}, + {"PyList_AppendN", "(JJ)I", (void *)&f_PyList_AppendN}, + {"PyList_AsTupleN", "(J)J", (void *)&f_PyList_AsTupleN}, + {"PyList_InsertN", "(JJJ)I", (void *)&f_PyList_InsertN}, + {"PyList_ReverseN", "(J)I", (void *)&f_PyList_ReverseN}, + {"PyList_SortN", "(J)I", (void *)&f_PyList_SortN}, + {"PyList_NewN", "(J)J", (void *)&f_PyList_NewN}, + {"PyList_SetItemN", "(JJJ)I", (void *)&f_PyList_SetItemN}, + {"PyTuple_GetItemN", "(JJ)J", (void *)&f_PyTuple_GetItemN}, + {"PyTuple_GetSliceN", "(JJJ)J", (void *)&f_PyTuple_GetSliceN}, + {"PyTuple_SizeN", "(J)J", (void *)&f_PyTuple_SizeN}, + {"PySet_AddN", "(JJ)I", (void *)&f_PySet_AddN}, + {"PySet_ClearN", "(J)I", (void *)&f_PySet_ClearN}, + {"PySet_ContainsN", "(JJ)I", (void *)&f_PySet_ContainsN}, + {"PySet_DiscardN", "(JJ)I", (void *)&f_PySet_DiscardN}, + {"PySet_NewN", "(J)J", (void *)&f_PySet_NewN}, + {"PySet_PopN", "(J)J", (void *)&f_PySet_PopN}, + {"PySet_SizeN", "(J)J", (void *)&f_PySet_SizeN}, + {"PyFrozenSet_NewN", "(J)J", (void *)&f_PyFrozenSet_NewN}, + {"PySequence_ContainsN", "(JJ)I", (void *)&f_PySequence_ContainsN}, + {"PySequence_ListN", "(J)J", (void *)&f_PySequence_ListN}, + {"PySequence_TupleN", "(J)J", (void *)&f_PySequence_TupleN}, + {"PyNumber_AddN", "(JJ)J", (void *)&f_PyNumber_AddN}, + {"PyNumber_AndN", "(JJ)J", (void *)&f_PyNumber_AndN}, + {"PyNumber_MultiplyN", "(JJ)J", (void *)&f_PyNumber_MultiplyN}, + {"PyNumber_OrN", "(JJ)J", (void *)&f_PyNumber_OrN}, + {"PyNumber_SubtractN", "(JJ)J", (void *)&f_PyNumber_SubtractN}, + {"PyFloat_AsDoubleN", "(J)D", (void *)&f_PyFloat_AsDoubleN}, + {"PyFloat_FromDoubleN", "(D)J", (void *)&f_PyFloat_FromDoubleN}, + {"PyLong_AsIntN", "(J)I", (void *)&f_PyLong_AsIntN}, + {"PyLong_AsLongLongN", "(J)J", (void *)&f_PyLong_AsLongLongN}, + {"PyBool_FromLongN", "(J)J", (void *)&f_PyBool_FromLongN}, + {"PyUnicode_ConcatN", "(JJ)J", (void *)&f_PyUnicode_ConcatN}, + {"PyUnicode_ContainsN", "(JJ)I", (void *)&f_PyUnicode_ContainsN}, + {"PyCallable_CheckN", "(J)I", (void *)&f_PyCallable_CheckN}, + {"PyEval_GetBuiltinsN", "()J", (void *)&f_PyEval_GetBuiltinsN}, + {"PyIter_NextN", "(J)J", (void *)&f_PyIter_NextN}, + {"Py_GetBuildInfoN", "()J", (void *)&f_Py_GetBuildInfoN}, + {"Py_GetCompilerN", "()J", (void *)&f_Py_GetCompilerN}, + {"Py_GetCopyrightN", "()J", (void *)&f_Py_GetCopyrightN}, + {"Py_GetPlatformN", "()J", (void *)&f_Py_GetPlatformN}, + {"Py_RunMainN", "()I", (void *)&f_Py_RunMainN}, + + /* + * The rest of the string-carrying surface. Every C string crosses as a jlong address the + * Kotlin side already encoded, so none of these takes a jstring. + */ + {"Py_CompileStringN", "(JJI)J", (void *)&f_Py_CompileStringN}, + {"PyErr_SetFromErrnoWithFilenameN", "(JJ)J", (void *)&f_PyErr_SetFromErrnoWithFilenameN}, + {"PyErr_SyntaxLocationExN", "(JII)V", (void *)&f_PyErr_SyntaxLocationExN}, + {"PyErr_SyntaxLocationN", "(JI)V", (void *)&f_PyErr_SyntaxLocationN}, + {"PyErr_WarnExplicitN", "(JJJIJJ)I", (void *)&f_PyErr_WarnExplicitN}, + {"PyErr_NewExceptionN", "(JJJ)J", (void *)&f_PyErr_NewExceptionN}, + {"PyErr_NewExceptionWithDocN", "(JJJJ)J", (void *)&f_PyErr_NewExceptionWithDocN}, + {"PyUnicodeTranslateError_SetReasonN", "(JJ)I", (void *)&f_PyUnicodeTranslateError_SetReasonN}, + {"Py_EnterRecursiveCallN", "(J)I", (void *)&f_Py_EnterRecursiveCallN}, + {"PySys_SetObjectN", "(JJ)I", (void *)&f_PySys_SetObjectN}, + {"PySys_AuditTupleN", "(JJ)I", (void *)&f_PySys_AuditTupleN}, + {"Py_FatalErrorN", "(J)V", (void *)&f_Py_FatalErrorN}, + {"PyImport_ImportModuleLevelN", "(JJJJI)J", (void *)&f_PyImport_ImportModuleLevelN}, + {"PyImport_ExecCodeModuleN", "(JJ)J", (void *)&f_PyImport_ExecCodeModuleN}, + {"PyImport_ExecCodeModuleExN", "(JJJ)J", (void *)&f_PyImport_ExecCodeModuleExN}, + {"PyImport_ExecCodeModuleWithPathnamesN", "(JJJJ)J", (void *)&f_PyImport_ExecCodeModuleWithPathnamesN}, + {"PyImport_GetMagicTagN", "()J", (void *)&f_PyImport_GetMagicTagN}, + {"PyImport_ImportFrozenModuleN", "(J)I", (void *)&f_PyImport_ImportFrozenModuleN}, + {"PyEval_GetFuncNameN", "(J)J", (void *)&f_PyEval_GetFuncNameN}, + {"PyEval_GetFuncDescN", "(J)J", (void *)&f_PyEval_GetFuncDescN}, + {"PyObject_HasAttrStringWithErrorN", "(JJ)I", (void *)&f_PyObject_HasAttrStringWithErrorN}, + {"PyObject_HasAttrStringN", "(JJ)I", (void *)&f_PyObject_HasAttrStringN}, + {"PySequence_FastN", "(JJ)J", (void *)&f_PySequence_FastN}, + {"PyMapping_GetItemStringN", "(JJ)J", (void *)&f_PyMapping_GetItemStringN}, + {"PyMapping_SetItemStringN", "(JJJ)I", (void *)&f_PyMapping_SetItemStringN}, + {"PyMapping_HasKeyStringWithErrorN", "(JJ)I", (void *)&f_PyMapping_HasKeyStringWithErrorN}, + {"PyMapping_HasKeyStringN", "(JJ)I", (void *)&f_PyMapping_HasKeyStringN}, + {"PyBytes_FromStringN", "(J)J", (void *)&f_PyBytes_FromStringN}, + {"PyBytes_AsStringN", "(J)J", (void *)&f_PyBytes_AsStringN}, + {"PyByteArray_AsStringN", "(J)J", (void *)&f_PyByteArray_AsStringN}, + {"PyUnicode_FromEncodedObjectN", "(JJJ)J", (void *)&f_PyUnicode_FromEncodedObjectN}, + {"PyUnicode_DecodeLocaleN", "(JJ)J", (void *)&f_PyUnicode_DecodeLocaleN}, + {"PyUnicode_EncodeLocaleN", "(JJ)J", (void *)&f_PyUnicode_EncodeLocaleN}, + {"PyUnicode_DecodeFSDefaultN", "(J)J", (void *)&f_PyUnicode_DecodeFSDefaultN}, + {"PyUnicode_AsEncodedStringN", "(JJJ)J", (void *)&f_PyUnicode_AsEncodedStringN}, + {"PyUnicode_TranslateN", "(JJJ)J", (void *)&f_PyUnicode_TranslateN}, + {"PyUnicode_EqualToUTF8N", "(JJ)I", (void *)&f_PyUnicode_EqualToUTF8N}, + {"PyUnicode_CompareWithASCIIStringN", "(JJ)I", (void *)&f_PyUnicode_CompareWithASCIIStringN}, + {"PyUnicode_InternFromStringN", "(J)J", (void *)&f_PyUnicode_InternFromStringN}, + {"PyDict_DelItemStringN", "(JJ)I", (void *)&f_PyDict_DelItemStringN}, + + /* + * The remainder of the CPython surface. Pointers cross as `jlong`, never as a boxed + * `java.lang.Long`, and every one of these was on the name-linked fallback before. + */ + {"Py_InitializeExN", "(I)V", (void *)&f_Py_InitializeExN}, + {"Py_IsFinalizingN", "()I", (void *)&f_Py_IsFinalizingN}, + {"Py_FinalizeExN", "()I", (void *)&f_Py_FinalizeExN}, + {"PyEval_EvalCodeN", "(JJJ)J", (void *)&f_PyEval_EvalCodeN}, + {"PyErr_PrintExN", "(I)V", (void *)&f_PyErr_PrintExN}, + {"PyErr_WriteUnraisableN", "(J)V", (void *)&f_PyErr_WriteUnraisableN}, + {"PyErr_DisplayExceptionN", "(J)V", (void *)&f_PyErr_DisplayExceptionN}, + {"PyErr_SetObjectN", "(JJ)V", (void *)&f_PyErr_SetObjectN}, + {"PyErr_SetNoneN", "(J)V", (void *)&f_PyErr_SetNoneN}, + {"PyErr_BadArgumentN", "()I", (void *)&f_PyErr_BadArgumentN}, + {"PyErr_NoMemoryN", "()J", (void *)&f_PyErr_NoMemoryN}, + {"PyErr_SetFromErrnoN", "(J)J", (void *)&f_PyErr_SetFromErrnoN}, + {"PyErr_SetFromErrnoWithFilenameObjectN", "(JJ)J", (void *)&f_PyErr_SetFromErrnoWithFilenameObjectN}, + {"PyErr_SetFromErrnoWithFilenameObjectsN", "(JJJ)J", (void *)&f_PyErr_SetFromErrnoWithFilenameObjectsN}, + {"PyErr_SetImportErrorN", "(JJJ)J", (void *)&f_PyErr_SetImportErrorN}, + {"PyErr_SetImportErrorSubclassN", "(JJJJ)J", (void *)&f_PyErr_SetImportErrorSubclassN}, + {"PyErr_BadInternalCallN", "()V", (void *)&f_PyErr_BadInternalCallN}, + {"PyErr_ExceptionMatchesN", "(J)I", (void *)&f_PyErr_ExceptionMatchesN}, + {"PyErr_GivenExceptionMatchesN", "(JJ)I", (void *)&f_PyErr_GivenExceptionMatchesN}, + {"PyErr_RestoreN", "(JJJ)V", (void *)&f_PyErr_RestoreN}, + {"PyErr_GetHandledExceptionN", "()J", (void *)&f_PyErr_GetHandledExceptionN}, + {"PyErr_SetHandledExceptionN", "(J)V", (void *)&f_PyErr_SetHandledExceptionN}, + {"PyErr_SetExcInfoN", "(JJJ)V", (void *)&f_PyErr_SetExcInfoN}, + {"PyErr_CheckSignalsN", "()I", (void *)&f_PyErr_CheckSignalsN}, + {"PyErr_SetInterruptN", "()V", (void *)&f_PyErr_SetInterruptN}, + {"PyErr_SetInterruptExN", "(I)I", (void *)&f_PyErr_SetInterruptExN}, + {"PyException_SetTracebackN", "(JJ)I", (void *)&f_PyException_SetTracebackN}, + {"PyException_SetContextN", "(JJ)V", (void *)&f_PyException_SetContextN}, + {"PyException_SetCauseN", "(JJ)V", (void *)&f_PyException_SetCauseN}, + {"PyException_GetArgsN", "(J)J", (void *)&f_PyException_GetArgsN}, + {"PyException_SetArgsN", "(JJ)V", (void *)&f_PyException_SetArgsN}, + {"PyUnicodeEncodeError_GetEncodingN", "(J)J", (void *)&f_PyUnicodeEncodeError_GetEncodingN}, + {"PyUnicodeTranslateError_GetObjectN", "(J)J", (void *)&f_PyUnicodeTranslateError_GetObjectN}, + {"PyUnicodeTranslateError_GetReasonN", "(J)J", (void *)&f_PyUnicodeTranslateError_GetReasonN}, + {"Py_LeaveRecursiveCallN", "()V", (void *)&f_Py_LeaveRecursiveCallN}, + {"Py_ReprEnterN", "(J)I", (void *)&f_Py_ReprEnterN}, + {"Py_ReprLeaveN", "(J)V", (void *)&f_Py_ReprLeaveN}, + {"PyOS_FSPathN", "(J)J", (void *)&f_PyOS_FSPathN}, + {"PySys_GetXOptionsN", "()J", (void *)&f_PySys_GetXOptionsN}, + {"Py_ExitN", "(I)V", (void *)&f_Py_ExitN}, + {"PyImport_ImportModuleLevelObjectN", "(JJJJI)J", (void *)&f_PyImport_ImportModuleLevelObjectN}, + {"PyImport_ReloadModuleN", "(J)J", (void *)&f_PyImport_ReloadModuleN}, + {"PyImport_AddModuleObjectN", "(J)J", (void *)&f_PyImport_AddModuleObjectN}, + {"PyImport_ExecCodeModuleObjectN", "(JJJJ)J", (void *)&f_PyImport_ExecCodeModuleObjectN}, + {"PyImport_GetModuleDictN", "()J", (void *)&f_PyImport_GetModuleDictN}, + {"PyImport_GetModuleN", "(J)J", (void *)&f_PyImport_GetModuleN}, + {"PyImport_GetImporterN", "(J)J", (void *)&f_PyImport_GetImporterN}, + {"PyImport_ImportFrozenModuleObjectN", "(J)I", (void *)&f_PyImport_ImportFrozenModuleObjectN}, + {"PyEval_GetLocalsN", "()J", (void *)&f_PyEval_GetLocalsN}, + {"PyEval_GetGlobalsN", "()J", (void *)&f_PyEval_GetGlobalsN}, + {"PyEval_GetFrameBuiltinsN", "()J", (void *)&f_PyEval_GetFrameBuiltinsN}, + {"PyEval_GetFrameLocalsN", "()J", (void *)&f_PyEval_GetFrameLocalsN}, + {"PyEval_GetFrameGlobalsN", "()J", (void *)&f_PyEval_GetFrameGlobalsN}, + {"PyObject_HasAttrWithErrorN", "(JJ)I", (void *)&f_PyObject_HasAttrWithErrorN}, + {"PyObject_HasAttrN", "(JJ)I", (void *)&f_PyObject_HasAttrN}, + {"PyObject_GetAttrN", "(JJ)J", (void *)&f_PyObject_GetAttrN}, + {"PyObject_GenericGetAttrN", "(JJ)J", (void *)&f_PyObject_GenericGetAttrN}, + {"PyObject_SetAttrN", "(JJJ)I", (void *)&f_PyObject_SetAttrN}, + {"PyObject_GenericSetAttrN", "(JJJ)I", (void *)&f_PyObject_GenericSetAttrN}, + {"PyObject_DelAttrN", "(JJ)I", (void *)&f_PyObject_DelAttrN}, + {"PyObject_FormatN", "(JJ)J", (void *)&f_PyObject_FormatN}, + {"PyObject_ASCIIN", "(J)J", (void *)&f_PyObject_ASCIIN}, + {"PyObject_BytesN", "(J)J", (void *)&f_PyObject_BytesN}, + {"PyObject_IsSubclassN", "(JJ)I", (void *)&f_PyObject_IsSubclassN}, + {"PyObject_NotN", "(J)I", (void *)&f_PyObject_NotN}, + {"PyObject_SizeN", "(J)J", (void *)&f_PyObject_SizeN}, + {"PyObject_LengthN", "(J)J", (void *)&f_PyObject_LengthN}, + {"PyObject_DirN", "(J)J", (void *)&f_PyObject_DirN}, + {"PyObject_GetAIterN", "(J)J", (void *)&f_PyObject_GetAIterN}, + {"PyVectorcall_CallN", "(JJJ)J", (void *)&f_PyVectorcall_CallN}, + {"PyNumber_CheckN", "(J)I", (void *)&f_PyNumber_CheckN}, + {"PyNumber_MatrixMultiplyN", "(JJ)J", (void *)&f_PyNumber_MatrixMultiplyN}, + {"PyNumber_FloorDivideN", "(JJ)J", (void *)&f_PyNumber_FloorDivideN}, + {"PyNumber_TrueDivideN", "(JJ)J", (void *)&f_PyNumber_TrueDivideN}, + {"PyNumber_RemainderN", "(JJ)J", (void *)&f_PyNumber_RemainderN}, + {"PyNumber_DivmodN", "(JJ)J", (void *)&f_PyNumber_DivmodN}, + {"PyNumber_PowerN", "(JJJ)J", (void *)&f_PyNumber_PowerN}, + {"PyNumber_NegativeN", "(J)J", (void *)&f_PyNumber_NegativeN}, + {"PyNumber_PositiveN", "(J)J", (void *)&f_PyNumber_PositiveN}, + {"PyNumber_AbsoluteN", "(J)J", (void *)&f_PyNumber_AbsoluteN}, + {"PyNumber_InvertN", "(J)J", (void *)&f_PyNumber_InvertN}, + {"PyNumber_LshiftN", "(JJ)J", (void *)&f_PyNumber_LshiftN}, + {"PyNumber_RshiftN", "(JJ)J", (void *)&f_PyNumber_RshiftN}, + {"PyNumber_XorN", "(JJ)J", (void *)&f_PyNumber_XorN}, + {"PyNumber_InPlaceAddN", "(JJ)J", (void *)&f_PyNumber_InPlaceAddN}, + {"PyNumber_InPlaceSubtractN", "(JJ)J", (void *)&f_PyNumber_InPlaceSubtractN}, + {"PyNumber_InPlaceMultiplyN", "(JJ)J", (void *)&f_PyNumber_InPlaceMultiplyN}, + {"PyNumber_InPlaceMatrixMultiplyN", "(JJ)J", (void *)&f_PyNumber_InPlaceMatrixMultiplyN}, + {"PyNumber_InPlaceFloorDivideN", "(JJ)J", (void *)&f_PyNumber_InPlaceFloorDivideN}, + {"PyNumber_InPlaceTrueDivideN", "(JJ)J", (void *)&f_PyNumber_InPlaceTrueDivideN}, + {"PyNumber_InPlaceRemainderN", "(JJ)J", (void *)&f_PyNumber_InPlaceRemainderN}, + {"PyNumber_InPlacePowerN", "(JJJ)J", (void *)&f_PyNumber_InPlacePowerN}, + {"PyNumber_InPlaceLshiftN", "(JJ)J", (void *)&f_PyNumber_InPlaceLshiftN}, + {"PyNumber_InPlaceRshiftN", "(JJ)J", (void *)&f_PyNumber_InPlaceRshiftN}, + {"PyNumber_InPlaceAndN", "(JJ)J", (void *)&f_PyNumber_InPlaceAndN}, + {"PyNumber_InPlaceXorN", "(JJ)J", (void *)&f_PyNumber_InPlaceXorN}, + {"PyNumber_InPlaceOrN", "(JJ)J", (void *)&f_PyNumber_InPlaceOrN}, + {"PyNumber_LongN", "(J)J", (void *)&f_PyNumber_LongN}, + {"PyNumber_FloatN", "(J)J", (void *)&f_PyNumber_FloatN}, + {"PyNumber_IndexN", "(J)J", (void *)&f_PyNumber_IndexN}, + {"PyNumber_ToBaseN", "(JI)J", (void *)&f_PyNumber_ToBaseN}, + {"PyIndex_CheckN", "(J)I", (void *)&f_PyIndex_CheckN}, + {"PySequence_CheckN", "(J)I", (void *)&f_PySequence_CheckN}, + {"PySequence_ConcatN", "(JJ)J", (void *)&f_PySequence_ConcatN}, + {"PySequence_InPlaceConcatN", "(JJ)J", (void *)&f_PySequence_InPlaceConcatN}, + {"PyMapping_CheckN", "(J)I", (void *)&f_PyMapping_CheckN}, + {"PyMapping_HasKeyWithErrorN", "(JJ)I", (void *)&f_PyMapping_HasKeyWithErrorN}, + {"PyMapping_HasKeyN", "(JJ)I", (void *)&f_PyMapping_HasKeyN}, + {"PyMapping_KeysN", "(J)J", (void *)&f_PyMapping_KeysN}, + {"PyMapping_ValuesN", "(J)J", (void *)&f_PyMapping_ValuesN}, + {"PyMapping_ItemsN", "(J)J", (void *)&f_PyMapping_ItemsN}, + {"PyIter_CheckN", "(J)I", (void *)&f_PyIter_CheckN}, + {"PyAIter_CheckN", "(J)I", (void *)&f_PyAIter_CheckN}, + {"PyLong_FromDoubleN", "(D)J", (void *)&f_PyLong_FromDoubleN}, + {"PyLong_AsDoubleN", "(J)D", (void *)&f_PyLong_AsDoubleN}, + {"PyLong_GetInfoN", "()J", (void *)&f_PyLong_GetInfoN}, + {"PyFloat_FromStringN", "(J)J", (void *)&f_PyFloat_FromStringN}, + {"PyFloat_GetInfoN", "()J", (void *)&f_PyFloat_GetInfoN}, + {"PyFloat_GetMaxN", "()D", (void *)&f_PyFloat_GetMaxN}, + {"PyFloat_GetMinN", "()D", (void *)&f_PyFloat_GetMinN}, + {"PyBytes_FromObjectN", "(J)J", (void *)&f_PyBytes_FromObjectN}, + {"PyByteArray_FromObjectN", "(J)J", (void *)&f_PyByteArray_FromObjectN}, + {"PyByteArray_ConcatN", "(JJ)J", (void *)&f_PyByteArray_ConcatN}, + {"PyUnicode_IsIdentifierN", "(J)I", (void *)&f_PyUnicode_IsIdentifierN}, + {"PyUnicode_FromObjectN", "(J)J", (void *)&f_PyUnicode_FromObjectN}, + {"PyUnicode_EncodeFSDefaultN", "(J)J", (void *)&f_PyUnicode_EncodeFSDefaultN}, + {"PyUnicode_AsUTF8StringN", "(J)J", (void *)&f_PyUnicode_AsUTF8StringN}, + {"PyUnicode_AsUTF32StringN", "(J)J", (void *)&f_PyUnicode_AsUTF32StringN}, + {"PyUnicode_AsUTF16StringN", "(J)J", (void *)&f_PyUnicode_AsUTF16StringN}, + {"PyUnicode_AsUnicodeEscapeStringN", "(J)J", (void *)&f_PyUnicode_AsUnicodeEscapeStringN}, + {"PyUnicode_AsRawUnicodeEscapeStringN", "(J)J", (void *)&f_PyUnicode_AsRawUnicodeEscapeStringN}, + {"PyUnicode_AsLatin1StringN", "(J)J", (void *)&f_PyUnicode_AsLatin1StringN}, + {"PyUnicode_AsASCIIStringN", "(J)J", (void *)&f_PyUnicode_AsASCIIStringN}, + {"PyUnicode_AsCharmapStringN", "(JJ)J", (void *)&f_PyUnicode_AsCharmapStringN}, + {"PyUnicode_SplitlinesN", "(JI)J", (void *)&f_PyUnicode_SplitlinesN}, + {"PyUnicode_JoinN", "(JJ)J", (void *)&f_PyUnicode_JoinN}, + {"PyUnicode_CompareN", "(JJ)I", (void *)&f_PyUnicode_CompareN}, + {"PyUnicode_RichCompareN", "(JJI)J", (void *)&f_PyUnicode_RichCompareN}, + {"PyUnicode_FormatN", "(JJ)J", (void *)&f_PyUnicode_FormatN}, + {"PyDictProxy_NewN", "(J)J", (void *)&f_PyDictProxy_NewN}, + {"PyDict_CopyN", "(J)J", (void *)&f_PyDict_CopyN}, + {"PyDict_GetItemWithErrorN", "(JJ)J", (void *)&f_PyDict_GetItemWithErrorN}, + {"PyDict_KeysN", "(J)J", (void *)&f_PyDict_KeysN}, + {"PyDict_MergeN", "(JJI)I", (void *)&f_PyDict_MergeN}, + {"PyDict_UpdateN", "(JJ)I", (void *)&f_PyDict_UpdateN}, + {"PyDict_MergeFromSeq2N", "(JJI)I", (void *)&f_PyDict_MergeFromSeq2N}, + {"PySeqIter_NewN", "(J)J", (void *)&f_PySeqIter_NewN}, + {"PyCallIter_NewN", "(JJ)J", (void *)&f_PyCallIter_NewN}, + {"PyWeakref_NewRefN", "(JJ)J", (void *)&f_PyWeakref_NewRefN}, + {"PyWeakref_NewProxyN", "(JJ)J", (void *)&f_PyWeakref_NewProxyN}, + {"PyWeakref_GetRefN", "(J)J", (void *)&f_PyWeakref_GetRefN}, + {"PyObject_ClearWeakRefsN", "(J)V", (void *)&f_PyObject_ClearWeakRefsN}, + {"PyType_ReadyN", "(J)I", (void *)&f_PyType_ReadyN}, + {"PyType_GetFullyQualifiedNameN", "(J)J", (void *)&f_PyType_GetFullyQualifiedNameN}, + {"PyType_GetModuleNameN", "(J)J", (void *)&f_PyType_GetModuleNameN}, + {"PyType_GetModuleN", "(J)J", (void *)&f_PyType_GetModuleN}, + + /* Shape vocabulary + symbol lookup. See the block above f_downcall_V. */ + {"downcall_V", "(J)V", (void *)&f_downcall_V}, + {"downcall_I", "(J)J", (void *)&f_downcall_I}, + {"downcall_F", "(J)D", (void *)&f_downcall_F}, + {"downcallI_V", "(JJ)V", (void *)&f_downcallI_V}, + {"downcallI_I", "(JJ)J", (void *)&f_downcallI_I}, + {"downcallI_F", "(JJ)D", (void *)&f_downcallI_F}, + {"downcallF_I", "(JD)J", (void *)&f_downcallF_I}, + {"downcallII_V", "(JJJ)V", (void *)&f_downcallII_V}, + {"downcallII_I", "(JJJ)J", (void *)&f_downcallII_I}, + {"downcallIII_V", "(JJJJ)V", (void *)&f_downcallIII_V}, + {"downcallIII_I", "(JJJJ)J", (void *)&f_downcallIII_I}, + {"downcallIIII_I", "(JJJJJ)J", (void *)&f_downcallIIII_I}, + {"downcallIIIII_I", "(JJJJJJ)J", (void *)&f_downcallIIIII_I}, + {"downcallIIIIII_I", "(JJJJJJJ)J", (void *)&f_downcallIIIIII_I}, + {"ffiSymbolRawN", "(J)J", (void *)&f_ffiSymbolRawN}, + + + /* Cycle-collecting proxy type. See the block above pmp_proxy_traverse. */ + {"proxyCreateType", "()J", (void *)&proxy_create_type}, + {"proxyGetHandle", "(J)J", (void *)&proxy_get_handle}, + {"proxySetHandle", "(JJ)V", (void *)&proxy_set_handle}, + {"obRefCnt", "(J)J", (void *)&ob_refcnt}, + + /* Upcalls: Python -> Kotlin/JVM. See the block above pmp_upcall_invoke_meth. */ + {"upcallPublish", "(J)I", (void *)&upcall_publish}, + + {"testUpcallPrimitive", "(J)J", (void *)&test_upcall_primitive}, + {"testUpcallString", "(Ljava/lang/String;)J", (void *)&test_upcall_string}, + {"testUpcallUnattached", "(J)J", (void *)&test_upcall_unattached}, + {"testThreadCreateFloor", "(J)J", (void *)&test_thread_create_floor}, + {"testAttachWithoutDetach", "(J)J", (void *)&test_attach_without_detach} + }; + (*env)->RegisterNatives(env, clazz, methods, sizeof(methods) / sizeof(methods[0])); + return JNI_VERSION_1_6; +} diff --git a/python-multiplatform/src/artMain/kotlin/python/multiplatform/Platform.art.kt b/python-multiplatform/src/artMain/kotlin/python/multiplatform/Platform.art.kt index 7adc833b2..2b6b45a42 100644 --- a/python-multiplatform/src/artMain/kotlin/python/multiplatform/Platform.art.kt +++ b/python-multiplatform/src/artMain/kotlin/python/multiplatform/Platform.art.kt @@ -1,13 +1,54 @@ package python.multiplatform -import platform.android.* +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.alloc +import kotlinx.cinterop.cstr +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.toKString +import platform.posix.__system_property_get +import platform.posix.android_get_device_api_level +import platform.posix.uname +import platform.posix.utsname +/** + * The androidNative view of the running device. + * + * Every field used to be a placeholder (`"Kotlin/Native"`, `versionCode = 0`), which stopped being + * harmless once the androidNative test binary started running on real devices: the measurement + * header printed `Android Kotlin/Native (SDK 0, Kotlin/Native)` on an API 26 emulator and on an + * API 36 one alike, so no measurement could be attributed to a device. + * + * The values are read the same way the other platforms read theirs, and are meant to line up + * field-for-field with [AndroidPlatform] (`androidMain`), which is the same operating system seen + * through the ART VM: + * + * | field | `androidMain` (ART) | here (androidNative) | + * |---------------|--------------------------------------|-------------------------------------------| + * | `arch` | `System.getProperty("os.arch")` | `uname(2)` → `utsname.machine` | + * | `version` | `Build.VERSION.RELEASE` | `ro.build.version.release` system property | + * | `versionCode` | `Build.VERSION.SDK_INT` | `android_get_device_api_level(3)` | + * | `versionText` | `"SDK $versionCode"` | `"SDK $versionCode"` | + * + * Both `arch` sources report the kernel's machine name, so an arm64 device reads `aarch64` on + * either side rather than `arm64-v8a`/`arm64`; `Platform.isArm` and `Platform.is64Bit` are written + * against exactly that spelling. `version` is the user-visible OS release ("8.0.0", "16") on every + * platform in this tree -- iOS uses `UIDevice.systemVersion` and desktop uses `os.version` -- so + * the release property is the matching read, not the API level. + * + * `platformVersion` stays null: on ART it names the language runtime + * (`java.specification.version`), and here there is no runtime under the code. iOS reports null for + * the same reason. + */ +@OptIn(ExperimentalForeignApi::class) object AndroidNativePlatform : Platform { override val os = OSType.Android - override val arch: String = "Kotlin/Native" // TODO: Get the actual architecture - override val version: String = "Kotlin/Native" // TODO: Get the actual version - override val versionCode = 0 // TODO: Get the actual version code + override val arch: String = machineName() + override val version: String = deviceApiRelease() + override val versionCode: Int = deviceApiLevel() override val versionText = "SDK $versionCode" override val platformType = PlatformType.Native override val platformVersion: String? = null @@ -17,4 +58,51 @@ object AndroidNativePlatform : Platform { } } +/** + * `android_get_device_api_level()` is `platform.posix`, not `platform.android` -- Kotlin/Native's + * posix def pulls in `` for the android_* targets. + * + * Below API 29 bionic ships it as a `static __inline` that reads `ro.build.version.sdk` (see + * `bits/get_device_api_level_inlines.h`), and above it as a real symbol. Which of the two a build + * gets is decided by `__ANDROID_API__`, and Kotlin/Native's target triple + * (`aarch64-unknown-linux-android`, no API suffix in `konan.properties`) leaves it at the NDK + * minimum -- so the inline is what is compiled in, and the binary keeps loading on API 26 devices + * where the extern symbol does not exist. + * + * It documents -1 on failure. That is folded into the property fallback rather than surfaced, + * because a negative "SDK -1" in a report is no more useful than the "SDK 0" this replaced. + */ +@OptIn(ExperimentalForeignApi::class) +private fun deviceApiLevel(): Int { + val reported = android_get_device_api_level() + if (reported > 0) return reported + return systemProperty("ro.build.version.sdk").toIntOrNull() ?: 0 +} + +@OptIn(ExperimentalForeignApi::class) +private fun deviceApiRelease(): String = + systemProperty("ro.build.version.release").ifEmpty { "unknown" } + +@OptIn(ExperimentalForeignApi::class) +private fun machineName(): String = memScoped { + val info = alloc() + if (uname(info.ptr) != 0) return "unknown" + info.machine.toKString().ifEmpty { "unknown" } +} + +/** + * `PROP_VALUE_MAX` (92) is a `#define` in ``, and cinterop does not carry + * plain object-like macros of this shape into the klib -- the constant is absent from + * `platform.posix`, so it is spelled out here. `__system_property_get` returns the length written + * and writes an empty string for an unset name. + */ +@OptIn(ExperimentalForeignApi::class) +private fun systemProperty(name: String): String = memScoped { + val buffer = allocArray(PROP_VALUE_MAX) + val length = __system_property_get(name.cstr, buffer) + if (length <= 0) "" else buffer.toKString() +} + +private const val PROP_VALUE_MAX = 92 + actual val currentPlatform: Platform = AndroidNativePlatform diff --git a/python-multiplatform/src/artMain/kotlin/python/multiplatform/ThreadId.art.kt b/python-multiplatform/src/artMain/kotlin/python/multiplatform/ThreadId.art.kt new file mode 100644 index 000000000..0fe2dd2fd --- /dev/null +++ b/python-multiplatform/src/artMain/kotlin/python/multiplatform/ThreadId.art.kt @@ -0,0 +1,9 @@ +package python.multiplatform + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.convert + +/** Bionic's `pthread_t` is a plain unsigned integral typedef, not a pointer. */ +@OptIn(ExperimentalForeignApi::class) +internal actual fun currentThreadId(): Long = + platform.posix.pthread_self().convert() diff --git a/python-multiplatform/src/artMain/kotlin/python/multiplatform/env/PackagedPythonHome.art.kt b/python-multiplatform/src/artMain/kotlin/python/multiplatform/env/PackagedPythonHome.art.kt new file mode 100644 index 000000000..47e6d3484 --- /dev/null +++ b/python-multiplatform/src/artMain/kotlin/python/multiplatform/env/PackagedPythonHome.art.kt @@ -0,0 +1,12 @@ +package python.multiplatform.env + +/** + * No-op: androidNative is the Kotlin/Native half of an Android app, and `PythonBootstrap` + * (`androidMain`) has already unpacked the stdlib and set `PYTHONHOME` with `Os.setenv` by the time + * anything here runs. + * + * The `actual` lives here rather than in `nativeMain` because `nativeMain` is shared with iOS, where + * the answer is a real lookup in the app bundle (SPEC L-10, `iosMain`) -- the same split + * `discoverStagedPayloadRoots` already has. + */ +internal actual fun applyPackagedPythonHome() = Unit 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 new file mode 100644 index 000000000..4067deaa0 --- /dev/null +++ b/python-multiplatform/src/artMain/kotlin/python/multiplatform/env/PythonPayload.art.kt @@ -0,0 +1,17 @@ +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 + * 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 + * code here runs. + * + * The `actual` lives here rather than in `nativeMain` because `nativeMain` is shared with iOS, + * where the answer is a real lookup (see `iosMain`). An `actual` placed there would have made the + * iOS one impossible to write without reopening the declaration. + */ +internal actual fun discoverStagedPayloadRoots(): List = emptyList() 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 6e717919a..413bdd7bf 100644 --- a/python-multiplatform/src/artMain/kotlin/python/multiplatform/ffi/JniExport.kt +++ b/python-multiplatform/src/artMain/kotlin/python/multiplatform/ffi/JniExport.kt @@ -121,3 +121,15 @@ fun sayHello() { // return jniEnvVal.CallObjectMethodA!!.invoke(env, thiz, methodId, null) as jstring // } //} + +/** + * 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 — + * if the JNI calling convention does not match this export's signature, the value read back will + * not be the value passed in. + */ +@CName("Java_python_native_ffi_bindings_diagEcho") +@kotlin.OptIn(kotlin.experimental.ExperimentalNativeApi::class, kotlinx.cinterop.ExperimentalForeignApi::class) +fun diagEcho(env: Long, clazz: Long, x: Long): Long = x diff --git a/python-multiplatform/src/artMain/kotlin/python/native/ffi/JNIOnLoadExporter.kt b/python-multiplatform/src/artMain/kotlin/python/native/ffi/JNIOnLoadExporter.kt new file mode 100644 index 000000000..a2546e58c --- /dev/null +++ b/python-multiplatform/src/artMain/kotlin/python/native/ffi/JNIOnLoadExporter.kt @@ -0,0 +1,74 @@ +package python.native.ffi + +import kotlinx.cinterop.* +import python.native.ffi.jni.* +import platform.android.* + +import kotlin.experimental.ExperimentalNativeApi + +@OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) +@CName("JNI_OnLoad") +fun JNI_OnLoad(vm: CPointer?, reserved: COpaquePointer?): Int { + return CustomJNI_OnLoad(vm?.reinterpret(), reserved) +} + +@OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) +@CName("Java_python_native_ffi_bindings_ffiAllocUtf8") +fun Java_python_native_ffi_bindings_ffiAllocUtf8( + env: CPointer?, + clazz: jclass?, + str: jstring? +): jlong { + if (env == null || str == null) return 0L + val getChars = env.pointed.pointed!!.GetStringUTFChars ?: return 0L + val cStr = getChars(env, str, null) ?: return 0L + val kString = cStr.toKString() + val releaseChars = env.pointed.pointed!!.ReleaseStringUTFChars ?: return 0L + releaseChars(env, str, cStr) + return ffiAllocUtf8(kString) +} + +@OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) +@CName("Java_python_native_ffi_bindings_ffiFreeUtf8") +fun Java_python_native_ffi_bindings_ffiFreeUtf8( + env: CPointer?, + clazz: jclass?, + ptr: jlong +) { + ffiFreeUtf8(ptr) +} + +@OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) +@CName("Java_python_native_ffi_bindings_ffiReadUtf8") +fun Java_python_native_ffi_bindings_ffiReadUtf8( + env: CPointer?, + clazz: jclass?, + ptr: jlong +): jstring? { + if (env == null) return null + val kString = ffiReadUtf8(ptr) ?: return null + val newStringUTF = env.pointed.pointed!!.NewStringUTF ?: return null + val scope = Arena() + val cstr = kString.cstr.getPointer(scope) + val result = newStringUTF(env, cstr) + scope.clear() + return result +} + +/** + * A @CriticalNative echo reached by NAME-BASED linking instead of RegisterNatives. + * + * Deliberately absent from the JNINativeMethod table in jni_onload.def, so ART has to resolve + * it through the usual `Java__` symbol lookup. Everything else about it is + * identical to `critical_echo0`: same convention, same one-line body. + * + * This exists to test one hypothesis. Google advises binding @CriticalNative through + * RegisterNatives *before Android 12*, and measurement shows the convention collapsing on + * newer releases (net 1.83ns on API 26, 24ns on API 34, 49ns on API 36 hardware) while + * @FastNative stays ~2ns. If explicitly-registered critical natives are the ones losing the + * fast path on modern ART, this name-linked variant should be cheap where the registered one + * is not. + */ +@OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) +@CName("Java_python_native_ffi_bindings_echoCriticalNamed") +fun echoCriticalNamed(x: Long): Long = x diff --git a/python-multiplatform/src/artMain/kotlin/python/native/ffi/JniCheck.kt b/python-multiplatform/src/artMain/kotlin/python/native/ffi/JniCheck.kt new file mode 100644 index 000000000..3872986e3 --- /dev/null +++ b/python-multiplatform/src/artMain/kotlin/python/native/ffi/JniCheck.kt @@ -0,0 +1,6 @@ +package python.native.ffi +import kotlinx.cinterop.* +import platform.android.* +fun check() { + val x = JNINativeMethod.size +} diff --git a/python-multiplatform/src/artTest/kotlin/python/multiplatform/AndroidNativeDeviceIdentityTest.kt b/python-multiplatform/src/artTest/kotlin/python/multiplatform/AndroidNativeDeviceIdentityTest.kt new file mode 100644 index 000000000..f8c967f24 --- /dev/null +++ b/python-multiplatform/src/artTest/kotlin/python/multiplatform/AndroidNativeDeviceIdentityTest.kt @@ -0,0 +1,117 @@ +package python.multiplatform + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import platform.posix.getenv +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + + +/** + * That [AndroidNativePlatform] describes *this* device, checked against a reading taken from + * outside the process. + * + * ### Why the expectations arrive in the environment + * + * The regression this guards is a constant: `versionCode = 0`, `arch = "Kotlin/Native"`. A constant + * is indistinguishable from a real read as long as the only thing checking it is a range or a + * "not blank" test -- `versionCode = 26` hardcoded would pass every assertion in + * [PlatformIdentityTest] on both emulators. What separates the two is that a real read *differs + * between devices*, and a single test process only ever sees one device, so it cannot observe the + * difference on its own. + * + * Hardcoding the pair we happen to own (26 and 36) does not work either: the same binary is pushed + * to whatever device is attached, so the suite would start failing the moment it ran on a phone, or + * on an emulator someone recreated at a different level. The assertion would then be about our + * machine rather than about the code. + * + * So the expected values are read over adb by the Gradle task that pushes this binary -- `getprop + * ro.build.version.sdk`, `ro.build.version.release`, `ro.product.cpu.abi` -- and handed over in the + * environment. That makes the oracle external to the process *and* device-specific: on the API 26 + * emulator this test demands 26 and on the API 36 one it demands 36, so a value that does not move + * with the device cannot satisfy both, and no expectation is baked into the source. + * + * The variables are required, not optional. A test that quietly skips when its input is missing is + * how this class would stop guarding anything the next time the harness is rewired; the only + * supported way to run this binary is `:python-multiplatform:androidNativeTest`, which always + * sets them. + */ +@OptIn(ExperimentalForeignApi::class) +class AndroidNativeDeviceIdentityTest { + + private fun harnessValue(name: String): String { + val raw = getenv(name)?.toKString() + assertNotNull( + raw, + "$name is not set. The expected values come from `adb shell getprop` in the " + + "androidNative test task; run this binary through " + + "`:python-multiplatform:androidNativeArm64Test` (or ...X64Test) rather than by hand." + ) + assertEquals(true, raw.isNotBlank(), "$name is set but empty") + return raw.trim() + } + + @Test + fun apiLevelMatchesTheDevice() { + val expected = harnessValue("PMP_DEVICE_API_LEVEL").toInt() + assertEquals( + expected, currentPlatform.versionCode, + "android_get_device_api_level() reported ${currentPlatform.versionCode}, but this " + + "device's ro.build.version.sdk is $expected." + ) + } + + @Test + fun osReleaseMatchesTheDevice() { + val expected = harnessValue("PMP_DEVICE_RELEASE") + assertEquals( + expected, currentPlatform.version, + "Platform.version is '${currentPlatform.version}', but this device's " + + "ro.build.version.release is '$expected'." + ) + } + + /** + * `uname(2)` gives the kernel's machine name and `ro.product.cpu.abi` gives Android's ABI name; + * they are different spellings of the same fact, which is what makes one a usable check on the + * other. Only the two ABIs this project builds for are mapped -- an unmapped one is a failure + * rather than a skip, because it would mean the binary ran somewhere unplanned. + */ + @Test + fun archMatchesTheDeviceAbi() { + val abi = harnessValue("PMP_DEVICE_ABI") + val expected = when (abi) { + "arm64-v8a" -> "aarch64" + "x86_64" -> "x86_64" + else -> throw AssertionError( + "This device reports ro.product.cpu.abi='$abi'. Only arm64-v8a and x86_64 are " + + "built (see abiFilters), so there is no expected uname machine name for it." + ) + } + assertEquals( + expected, currentPlatform.arch, + "uname().machine is '${currentPlatform.arch}', but this device's ABI is '$abi'." + ) + } + + /** + * The failure that started this: the measurement header, which is the only place these fields + * are consumed, printed `Android Kotlin/Native (SDK 0, Kotlin/Native)` identically on both + * emulators. Asserting on the composed string is what ties the fields above to the thing that + * actually reads them. + */ + @Test + fun measurementHeaderIdentifiesTheDevice() { + val api = harnessValue("PMP_DEVICE_API_LEVEL") + val release = harnessValue("PMP_DEVICE_RELEASE") + val name = currentPlatform.name + assertEquals( + "Android $release (SDK $api, ${currentPlatform.arch}) / Native", name, + "The platform name that goes into every measurement header does not describe this device." + ) + // Printed so the two devices' recorded output can be compared directly, which is the + // observation this whole class exists to make possible. + println("PLATFORM-IDENTITY $name") + } +} diff --git a/python-multiplatform/src/commonMain/README.md b/python-multiplatform/src/commonMain/README.md new file mode 100644 index 000000000..ac0c24332 --- /dev/null +++ b/python-multiplatform/src/commonMain/README.md @@ -0,0 +1,138 @@ +# commonMain — rules + +The object model and the `expect` declarations every platform implements. + +## Layering — the object model must not reach past the FFI layer + + python.multiplatform.ffi PyObject, PyType, collections, conversion + | may call + v + python.native.ffi EmbedAPI: expect/actual over the C API + | may call (platform implementations only) + v + 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 +native targets, which use cinterop. + +A composed operation is a platform implementation difference, so it belongs in the FFI layer as +an `expect`/`actual`, **not** in the object model. Composition therefore does not require +reopening `PyObject` as `expect`/`actual`. See `docs/design/architecture.md`. + +## Every C API call needs the GIL + +Including reference counting. `withPython { }` and `withGIL { }` attach a thread state; a call +made without one is undefined behaviour, and the resulting segfault lands far from its cause. + +This is true on free-threaded builds too. Dropping the global lock removes contention, not the +requirement that a thread be attached before touching any object. + +## An entry point reached from C must not trust the nesting depth + +`withGIL` skips `PyGILState_Ensure` when this thread's depth is already non-zero. That counter +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`. + +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`. + +### 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 +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 +record. A completer thread with depth 0 takes a real `PyGILState_Ensure`; a completion delivered +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 +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 + +`_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. + +`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 +`Python3.autoDrainInterval` outermost `withGIL` scopes, which defaults to on for free-threaded +builds and off otherwise. **Never take a checkpoint from a cleaner**: the queue belongs to the +thread that *owns* the object, so it would drain nothing while running Python exactly where §1 +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_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 +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 +`docs/investigations/gc-scheduling-investigation.md` §1, §6 and §7. + +`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 +caller never asked to yield from. Measured reentrant-safe (a `__del__` that calls back into +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 +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 +same function `drainPendingReleases()` documents as the way to reclaim on behalf of *another* +thread. + +## Reference conventions, stated at every call site + +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 + +`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. + +### A converted value has a different lifetime from the object it came from + +`PyValue`/`PyProxy` cache the Kotlin projection of a Python object, and the two do not die +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 +`ConversionStrategy.RAW` hands back is an address, not a reference. + +`isIndependentOfPythonMemory` in `conversion/PyProxy.kt` is that rule as code, and every store to +`cachedNativeValue` on the conversion path goes through it. The full per-type table, the snapshot +semantics of a cached container, and the borrowed-reference bug this caught in `PyContext` are in +`docs/design/object-lifetime.md`, "Conversion caching, and where it stops". + +## `expect` declarations cannot be `inline` in an intermediate source set + +Combining `expect inline fun` with an intermediate source set's `expect`/`actual` crashes Kotlin +2.0.20 with `Internal error in file lowering`. 132 of the 147 `inline` markers here already +carry a compiler warning saying inlining gains nothing; drop the marker rather than working +around the crash. diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Platform.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Platform.kt index a0b9f8324..aef81eca6 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Platform.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Platform.kt @@ -39,7 +39,16 @@ interface Platform { } enum class OSType { - Windows, MacOS, Linux, Android, IOS; + Windows, MacOS, Linux, Android, IOS, + + /** + * `wasm32-emscripten`. Not an operating system, which is the point -- the host is whatever runs + * the wasm. CPython reports `emscripten` for `sys.platform` for the same reason. + * + * Both `when (currentPlatform.os)` sites in the tree (`manager.kt`, `Platform.desktop.kt`) have + * an `else` branch, so adding this entry does not change any other target's behaviour. + */ + Web; override fun toString(): String { if (this == IOS) return "iOS" @@ -48,7 +57,14 @@ enum class OSType { } enum class PlatformType { - JVM, Native + JVM, Native, + + /** + * Kotlin/Wasm. Neither [JVM] nor [Native]: objects live on the WasmGC heap and CPython is + * reached through `@WasmImport` against a shared linear memory, not through JNI, Panama or + * cinterop. [Platform.isJvm] and [Platform.isNative] are therefore both false here. + */ + Wasm } expect val currentPlatform: Platform diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Versions.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Versions.kt index 819288f5b..94bb845bf 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Versions.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/Versions.kt @@ -1,37 +1,83 @@ package python.multiplatform -/* - * Supported versions of Python +/** + * The embedded CPython version, as configured for the build. + * + * 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 + * `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. + * + * The version is now parsed rather than whitelisted, so the configured value flows through without + * a matching source edit. */ -enum class Versions(val versionString: String) { - PYTHON_3_13_0("3.13.0"); +class Versions private constructor(val versionString: String) { companion object { - val currentVersion = valueOf(BuildConfig.pythonVersion) + val currentVersion: Versions by lazy { parse(BuildConfig.pythonVersion) } - fun valueOf(versionString: String): Versions { - val found = entries.find { it.versionString == versionString } - if (found == null) { - throw IllegalArgumentException("Unsupported Python version: $versionString") + /** + * 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. + */ + fun parse(versionString: String): Versions { + val parts = versionString.split(".") + require(parts.size >= 2) { + "Unsupported Python version '$versionString': expected at least major.minor" } - return found + require(parts[0].toIntOrNull() != null && parts[1].toIntOrNull() != null) { + "Unsupported Python version '$versionString': major and minor must be numeric" + } + return Versions(versionString) } } - override fun toString(): String { - return versionString - } + override fun toString(): String = versionString + + override fun equals(other: Any?): Boolean = + other is Versions && other.versionString == versionString + override fun hashCode(): Int = versionString.hashCode() + + /** `major.minor`, as used in `libpython3.14.so` and the `python3.14/` stdlib directory. */ val compactVersionString: String - get() = versionString.split(".").subList(0, 2).joinToString(".") + get() = versionString.split(".").take(2).joinToString(".") + + /** + * CPython's ABI flags suffix: `t` on a free-threaded build (PEP 703), empty otherwise. + * + * This is a property of the *build*, not of the version -- 3.14 ships in both flavours out of + * the same source tree -- so it comes from [BuildConfig.pythonFreeThreaded] rather than from + * [versionString]. + */ + val abiFlags: String + get() = if (BuildConfig.pythonFreeThreaded) "t" else "" + + /** + * `major.minor` plus [abiFlags]: `3.14` on a default build, `3.14t` free-threaded. + * + * Everything CPython names after itself carries this rather than [compactVersionString]: the + * shared library (`libpython3.14t.dylib`), the stdlib directory (`lib/python3.14t/`), the + * interpreter (`bin/python3.14t`). Use it wherever a library name or a path is being built; + * use [compactVersionString] only where the release line itself is what is meant. + */ + val taggedVersionString: String + get() = compactVersionString + abiFlags - val majorVersion + val majorVersion: Int get() = versionString.split(".")[0].toInt() - val minorVersion + val minorVersion: Int get() = versionString.split(".")[1].toInt() - val getPatchVersion - get() = versionString.split(".")[2].toInt() + /** + * The patch component, or `null` when the configured version carries none or carries a + * pre-release suffix such as `0rc1` that is not a plain integer. + */ + val patchVersion: Int? + get() = versionString.split(".").getOrNull(2)?.takeWhile { it.isDigit() }?.toIntOrNull() } diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PackagedPythonHome.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PackagedPythonHome.kt new file mode 100644 index 000000000..e1653d239 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PackagedPythonHome.kt @@ -0,0 +1,18 @@ +package python.multiplatform.env + +/** + * Points CPython at the prefix a packaged application ships, when there is one -- SPEC L-9 + * (desktop) and L-11 (iOS). + * + * Called once by [python.multiplatform.ffi.Python3.initialize], after [PythonHomeCheck] and before + * `Py_Initialize()`, which is the only window in which a home can still be chosen. Desktop and iOS + * do something: a packaged desktop app is started by a launcher that cannot put `PYTHONHOME` in its + * environment (see `desktopMain/README.md`), and an installed iOS app is started with none while its + * stdlib sits in its own bundle (`iosMain/README.md`). Android sets `PYTHONHOME` with `Os.setenv` in + * `PythonBootstrap` (and androidNative runs inside that process), and wasm bakes its stdlib into the + * virtual filesystem, so each of those is a no-op. + * + * Must throw, rather than continue, when the packaged prefix is unusable: the next call is + * `Py_Initialize()`, which aborts the process instead of failing. + */ +internal expect fun applyPackagedPythonHome() diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonHomeCheck.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonHomeCheck.kt new file mode 100644 index 000000000..ae695dfd3 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonHomeCheck.kt @@ -0,0 +1,82 @@ +package python.multiplatform.env + +import python.multiplatform.Versions + +/** + * Pre-flight check for `PYTHONHOME`, run once by [python.multiplatform.ffi.Python3.initialize] + * before it ever reaches `Py_Initialize()`. + * + * Why this exists: CPython does not fail reliably when `PYTHONHOME` is unusable. Reproduced directly + * on desktop (see `PythonHomeCheckTest` and the measurement this file's history records): a + * nonexistent `PYTHONHOME` is already caught earlier, by `manager.loadLibPython`'s own library + * search, with a message naming the path. But a `PYTHONHOME` whose shared library is present and + * whose *stdlib* is missing or unreadable sails past that check, reaches `Py_Initialize()`, and + * that call's own contract is to treat the failure as fatal -- it calls `Py_FatalError()` and + * aborts the whole process, with no Kotlin exception and no interpreter left to report through. + * On iOS it can be worse than an abort: ROADMAP (2026-08-13, "PYTHONHOME on an external volume + * hangs the app instead of failing it") found a sandboxed simulator app parked forever inside + * `open$NOCANCEL` while importing `encodings` from a path under `/Volumes/` -- 0% CPU, a blank + * window, nothing in the console. The sandbox does not deny the open; it never completes it. + * + * This check cannot promise to catch that exact case -- it reads the same directory through the + * same kind of syscall CPython's own import machinery uses, so a `PYTHONHOME` whose access is + * *parked* rather than *denied* can park this check identically. It is written anyway because + * every other misconfiguration -- a wrong path, a stdlib that was never staged, permissions that + * return synchronously -- turns into a clear, catchable exception instead of a silent hang or an + * uncatchable abort. See `iosMain/README.md` for what to do about the case this cannot catch. + */ +internal object PythonHomeCheck { + + /** + * Throws with a message naming what is wrong when `PYTHONHOME` is set but unusable. Returns + * normally, doing nothing, when it is unset -- an embedder that relies on CPython's own + * default prefix search is not this check's business -- or when a marker file for the + * configured version is readable under it. + */ + fun verifyOrThrow() = verifyOrThrow(readEnvVar("PYTHONHOME")) + + /** + * Same as the no-arg overload, taking [home] directly instead of reading it from the + * environment. Exists so tests can exercise the exact exception a bad `PYTHONHOME` produces + * without mutating process-wide environment state -- see `PythonHomeCheckTest`. + */ + internal fun verifyOrThrow(home: String?) { + val nonBlankHome = home?.takeIf { it.isNotBlank() } ?: return + val problem = diagnose(nonBlankHome) ?: return + throw IllegalStateException( + "PYTHONHOME='$nonBlankHome' $problem Py_Initialize() cannot start without a CPython " + + "${Versions.currentVersion.compactVersionString} standard library under it " + + "(expected to find, e.g., 'lib/python${Versions.currentVersion.taggedVersionString}/os.py' " + + "or 'Lib/os.py')." + ) + } + + /** + * Null when [home] looks usable, otherwise a sentence fragment describing what is wrong with + * it (grammatically continuing "PYTHONHOME='...' ..."). + * + * Checks both stdlib layouts CPython's own build systems produce -- `lib/pythonX.Y/` on macOS + * and Linux, `Lib/` on Windows -- since desktop compiles one source set for all three and + * decides which applies at runtime, not at compile time (see `manager.resolveSidecarLibrary`, + * which probes the same two shapes for the shared library). + */ + internal fun diagnose(home: String): String? { + val tag = Versions.currentVersion.taggedVersionString + val markers = listOf("lib/python$tag/os.py", "Lib/os.py") + if (markers.any { pathIsAccessible("$home/$it") }) return null + return if (pathIsAccessible(home)) { + "exists but has no readable standard library under it." + } else { + "does not exist or is not readable." + } + } +} + +/** Reads process environment variable [name], or null when unset. Null on targets with no such concept (wasmJs). */ +internal expect fun readEnvVar(name: String): String? + +/** + * Whether a file or directory exists at [path] and can be read. A quick, synchronous filesystem + * probe -- see [PythonHomeCheck] for the syscall-parking case it cannot detect. + */ +internal expect fun pathIsAccessible(path: String): Boolean diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPath.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPath.kt new file mode 100644 index 000000000..fb88bc157 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPath.kt @@ -0,0 +1,130 @@ +package python.multiplatform.env + +import python.multiplatform.ffi.Python3 +import python.native.ffi.NativePointer +import python.native.ffi.PyErr_Clear +import python.native.ffi.PyList_Append +import python.native.ffi.PyList_GetItem +import python.native.ffi.PyList_Insert +import python.native.ffi.PyList_Size +import python.native.ffi.PySys_GetObject +import python.native.ffi.PyUnicode_AsUTF8 +import python.native.ffi.PyUnicode_FromString +import python.native.ffi.Py_DecRef + +/** + * `sys.path`, as a Kotlin list you can add to. + * + * ### Why this is not `Python3.exec("import sys; sys.path.insert(...)")` + * + * 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 + * backslash, which every Windows path does, either breaks the statement or changes what it does. + * Going through the list object directly has neither problem. + * + * ### `PYTHONHOME` is a different mechanism and this does not touch it + * + * `PYTHONHOME` names the *prefix* CPython finds its standard library under, is read by + * `getenv(3)` before `Py_Initialize()`, and is verified by [PythonHomeCheck]. This is the + * consumer's own code, added to the path CPython has already built. Neither substitutes for the + * other: an application with a payload and no `PYTHONHOME` cannot start at all, and one with a + * `PYTHONHOME` and no payload starts fine and cannot import the application. + * + * ### 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 + * 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. + * + * Every function here takes the GIL; none may be called before `Py_Initialize()`, because + * `sys.path` does not exist until then (see [PythonPayload] for where that lands in start-up). + */ +object PythonPath { + + /** + * `sys.path` as it is now. + * + * Non-string entries are skipped rather than rendered: `sys.path` legally holds anything the + * import machinery's path hooks understand, and a `zipimporter`-style object has no meaningful + * text form for the only two callers here ([contains] and diagnostics). + */ + fun entries(): List = Python3.withPython { + val path = sysPathHoldingGIL() ?: return@withPython emptyList() + readEntriesHoldingGIL(path) + } + + /** Whether [directory] is already on `sys.path`, compared verbatim. */ + fun contains(directory: String): Boolean = entries().contains(directory) + + /** + * Inserts [directory] at `sys.path[0]` unless it is already somewhere on the path. + * + * @return true when the entry was added, false when it was already there. Idempotent: calling + * it twice leaves one entry, which matters because start-up paths on some platforms can run + * more than once (an Android activity recreated, a host app that initialises defensively). + */ + fun prepend(directory: String): Boolean = insert(directory, 0L) + + /** As [prepend], but at the end of `sys.path`. */ + fun append(directory: String): Boolean = insert(directory, APPEND) + + private fun insert(directory: String, index: Long): Boolean = Python3.withPython { + val path = sysPathHoldingGIL() + ?: throw IllegalStateException( + "sys.path is not available. It is created by Py_Initialize(), so nothing can be " + + "added to it before the interpreter is up -- see PythonPayload for where " + + "start-up does this." + ) + if (readEntriesHoldingGIL(path).contains(directory)) return@withPython false + + // PyUnicode_FromString: new reference, owned here. + val entry = PyUnicode_FromString(directory) + ?: run { + PyErr_Clear() + throw IllegalStateException("could not convert '$directory' to a Python string for sys.path") + } + try { + // Neither PyList_Insert nor PyList_Append steals -- both take a reference of their + // own, unlike PyList_SetItem. So the reference taken above is still ours to release, + // and not releasing it would leak one string per call. + val status = if (index == APPEND) PyList_Append(path, entry) else PyList_Insert(path, index, entry) + if (status != 0) { + PyErr_Clear() + throw IllegalStateException("could not add '$directory' to sys.path") + } + } finally { + Py_DecRef(entry) + } + true + } + + /** `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 { + val size = PyList_Size(path) + if (size < 0L) { + // sys.path replaced by something that is not a list. Legal Python, useless here. + PyErr_Clear() + return emptyList() + } + val entries = ArrayList(size.toInt()) + for (index in 0L until size) { + // PyList_GetItem: borrowed reference, valid while the list holds it. + val item = PyList_GetItem(path, index) + if (item == null) { + PyErr_Clear() + continue + } + val text = PyUnicode_AsUTF8(item) + if (text == null) PyErr_Clear() else entries.add(text) + } + return entries + } + + /** Sentinel for [insert]: append rather than insert at an index. */ + private const val APPEND: Long = -1L +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPayload.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPayload.kt new file mode 100644 index 000000000..7018fb3d8 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/env/PythonPayload.kt @@ -0,0 +1,138 @@ +package python.multiplatform.env + +/** + * The consumer's own Python code, put on `sys.path` before the first import. + * + * ### What was missing, and what the contract is + * + * Every path in this library was for the *standard library*: `PYTHONHOME` and [PythonHomeCheck] on + * every platform, `PythonBootstrap.stageStdlib` on Android. Nothing read a consumer's own payload, + * and this repository's own source said so twice in the same sentence (`PythonProxySource` and + * `PythonxAdapter`: *"getting one generated file into each of those, and onto `sys.path` before the + * first import, is a per-platform packaging problem this repository has not solved for anything"*). + * `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: + * + * > **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 + * > desktop resource folder). + * + * [PAYLOAD_ROOT] is that name, and it is deliberately the *only* constant: the three destinations + * differ in where `python/` lands, not in what it is called. + * + * ### 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 — + * 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 — + * `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 + * 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 + * process is launched), so on the platform this had to work on first it is not available at all. + * + * ### It stays automatic + * + * [autoInstall] is on by default. An embedder who wants to place the payload themselves turns it + * off before calling `Python3.initialize()` and calls [install] with a path of their own; the + * discovery each platform does is [discoverStagedPayloadRoots]. + */ +object PythonPayload { + + /** + * `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. + */ + const val PAYLOAD_ROOT: String = "python" + + /** + * 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. + */ + var autoInstall: Boolean = true + + private val installedRoots = mutableListOf() + + /** The payload roots put on `sys.path` so far, in the order they were installed. */ + val installed: List get() = installedRoots.toList() + + /** + * Puts [directory] at `sys.path[0]` and records it. + * + * @return true when it was added, false when it was already on the path. + * @throws IllegalArgumentException when [directory] is not a readable path. Refusing is the + * point: a payload root that silently is not there turns into a `ModuleNotFoundError` for + * the consumer's own package, which reads as "my code was not packaged" rather than "the + * directory I was given does not exist". + */ + fun install(directory: String): Boolean { + require(pathIsAccessible(directory)) { + "Python payload root '$directory' does not exist or is not readable, so putting it on " + + "sys.path would only turn a packaging failure into a ModuleNotFoundError later." + } + val added = PythonPath.prepend(directory) + if (!installedRoots.contains(directory)) installedRoots.add(directory) + return added + } + + /** + * Discovers this platform's staged payload roots and installs each one. + * + * Installed in reverse so that the first root [discoverStagedPayloadRoots] returned ends up + * first on `sys.path`: each [install] prepends, so the last one prepended wins. + * + * @return the roots installed, in path order. + */ + fun installStagedRoots(): List { + val roots = discoverStagedPayloadRoots() + roots.asReversed().forEach { install(it) } + return roots + } + + /** + * 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 + * 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. + * + * 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` + * at whatever point the application first imports itself, with nothing left to say why. + */ + internal fun installStagedRootsOnStartup() { + if (!autoInstall || startupInstallDone) return + startupInstallDone = true + installStagedRoots() + } + + private var startupInstallDone: Boolean = false +} + +/** + * Where this platform's packaging step left the consumer's `python/` payload, as directories that + * 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 + * 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)`. + */ +internal expect fun discoverStagedPayloadRoots(): List diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/GILScope.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/GILScope.kt new file mode 100644 index 000000000..889e87f16 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/GILScope.kt @@ -0,0 +1,115 @@ +package python.multiplatform.ffi + +import python.native.ffi.PyGILState_Ensure +import python.native.ffi.PyGILState_Release +import python.native.ffi.PyEval_SaveThread +import python.native.ffi.PyEval_RestoreThread +import python.native.ffi.NativePointer + +public class ThreadGILState(var state: Int = 0, var depth: Int = 0) { + /** + * Outermost [withGIL] scopes this thread may still enter before it takes an automatic + * eval-loop checkpoint. See [Python3.drainPendingReleases]. + */ + public var checkpointCountdown: Int = 0 + + /** + * Set while this thread must not take a checkpoint: inside one already, or on a cleaner + * running [python.multiplatform.ffi.ReleaseCounter]-counted release actions. Running Python + * from a cleaner is what ROADMAP §1 turned into a deadlock and §9 explicitly rules out as a + * fix, and it would drain the wrong thread's queue in any case. + */ + public var checkpointsSuppressed: Boolean = false +} + +/** + * Decides whether this outermost scope should take an eval-loop checkpoint, and takes it. + * + * Two gates, both cheap. The countdown bounds how often a checkpoint can happen at all; the + * 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. + */ +@PublishedApi +internal fun maybeReachEvalCheckpoint(tState: ThreadGILState) { + val interval = Python3.autoDrainInterval + if (interval <= 0 || tState.checkpointsSuppressed) return + if (--tState.checkpointCountdown > 0) return + tState.checkpointCountdown = interval + + val released = ReleaseCounter.released + if (released == Python3.lastCheckpointReleaseMark) return + Python3.lastCheckpointReleaseMark = released + + tState.checkpointsSuppressed = true + try { + Python3.reachEvalCheckpointHoldingGIL() + } finally { + tState.checkpointsSuppressed = false + } +} + +public expect fun getThreadGILState(): ThreadGILState +public expect fun setThreadGILState(state: ThreadGILState?) + +/** + * Attaches this thread to the interpreter for the duration of [block]. + * + * ### Free-threaded builds + * + * The name is historical. `PyGILState_Ensure`/`Release` do two things: they attach a thread + * state to the calling thread, and -- on a build with the global lock -- they serialise + * execution. A free-threaded build drops the second, not the first: a thread still must be + * attached before it may touch any object, including a bare `Py_IncRef`. So this wrapper is + * required on both builds, and the reference-counting paths keep it. + * + * What does change is the cost of holding it. Under the GIL, keeping the scope open across a + * long operation blocks every other thread, which is why long-running work belongs in + * [withoutGIL]. Free-threaded, holding attachment costs other threads nothing, so widening a + * scope to cover a whole operation -- one attach instead of one per C API call -- is free of + * that penalty. The nesting counter here already makes inner scopes cheap; on a free-threaded + * build the outermost one stops being a bottleneck as well. + */ +public inline fun withGIL(block: () -> T): T { + val tState = getThreadGILState() + val isOutermost = tState.depth == 0 + if (isOutermost) { + tState.state = PyGILState_Ensure() + } + tState.depth++ + try { + // Attached and nested one level deep, so the checkpoint's own C API calls are cheap and + // cannot recurse back into this branch. + if (isOutermost) maybeReachEvalCheckpoint(tState) + return block() + } finally { + tState.depth-- + if (tState.depth == 0) { + val stateToRelease = tState.state + PyGILState_Release(stateToRelease) + } + } +} + +/** + * Detaches this thread for the duration of [block], so other threads can run Python. + * + * Wrap anything long or blocking that does not touch the C API -- I/O, a native computation, + * waiting on a lock. Under the GIL this is what keeps one slow call from stalling every other + * thread. On a free-threaded build there is no global lock to yield, so this mostly stops + * mattering; it stays correct either way, since detaching and reattaching is valid on both. + * + * The block must not call into CPython: after [PyEval_SaveThread] this thread has no thread + * state, and any C API call made without one is undefined behaviour. + */ +public inline fun withoutGIL(block: () -> T): T { + val save = PyEval_SaveThread() + try { + return block() + } finally { + if (save != null) { + PyEval_RestoreThread(save) + } + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/ProxyTypeFactory.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/ProxyTypeFactory.kt new file mode 100644 index 000000000..b8361dcf2 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/ProxyTypeFactory.kt @@ -0,0 +1,35 @@ +package python.multiplatform.ffi + +/** + * Builds the cycle-collecting heap type, and -- on the targets this has been carried all the way + * through -- puts a real base class in Python's hands for a generated proxy to subclass. + * + * `createProxyType()` alone was never the gap: `docs/roadmap/ROADMAP.md` §7 records that the type it builds has + * carried `tp_traverse`/`tp_clear`/`tp_dealloc` since `work/cycles` and that a real cycle collects + * through a hand-built instance of it (`CycleCollectionTest`). What stayed open is that no + * *production* proxy -- the classes `python.multiplatform.ffi.upcall.PythonProxySource` renders -- + * was ever an instance of it: they store their handle in a Python attribute (`self._pm_handle`), + * and `tp_traverse` reads it from `PyObject_GetTypeData`, a slot a plain Python attribute never + * reaches. [installGcBase] is the other half: it publishes the type into `__main__` as + * `_pm_proxy_base`, and [python.multiplatform.ffi.upcall.PythonProxySource]'s generated `_PmGcObject` + * subclasses it -- through a `PyMemberDef` carrying `Py_RELATIVE_OFFSET` (CPython 3.12+) named + * `_pm_handle`, so `self._pm_handle = ...` in the generated `__init__` writes straight into the C + * slot with no change to the generated Python at all. + */ +expect object ProxyTypeFactory { + fun createProxyType(): Long + + /** + * Publishes [createProxyType]'s type into `__main__` as `_pm_proxy_base`, so a generated proxy + * class can subclass a real `Py_TPFLAGS_HAVE_GC` type instead of a plain Python one. + * + * `true` on desktop, iOS and androidNative. Android and wasm still answer `false` and touch + * nothing else -- [python.multiplatform.ffi.upcall.PythonProxySource] reads `_pm_proxy_base` + * out of `globals()` at `exec` time and falls back to `_PmObject` when it is absent, which is + * this codebase's existing behaviour there. Wiring a target in means giving its + * `ProxyTypeFactory` a `PyMemberDef`-carrying handle slot (or an equivalent) and an + * `installGcBase` that publishes the type the same way; `docs/roadmap/ROADMAP.md` §7 has the per-target + * notes on what that needs. + */ + fun installGcBase(): Boolean +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/PyObject.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/PyObject.kt index 2e608d8b0..51b019301 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/PyObject.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/PyObject.kt @@ -1,76 +1,486 @@ package python.multiplatform.ffi import python.multiplatform.ffi.exceptions.PyException +import python.multiplatform.ref.PyAutoCloseable import python.native.ffi.NativePointer +import python.native.ffi.PyCallable_Check +import python.native.ffi.PyDict_New +import python.native.ffi.PyDict_SetItemString +import python.native.ffi.PyErr_Clear +import python.native.ffi.PyErr_Occurred +import python.native.ffi.PyLong_AsLongLong +import python.native.ffi.PyLong_FromLongLong +import python.native.ffi.PyObject_Call +import python.native.ffi.PyObject_CallNoArgs +import python.native.ffi.PyObject_CallObject +import python.native.ffi.PyObject_DelAttrString +import python.native.ffi.PyObject_GetAttrString +import python.native.ffi.PyObject_IsTrue +import python.native.ffi.PyObject_Repr +import python.native.ffi.PyObject_RichCompare +import python.native.ffi.PyObject_SetAttrString +import python.native.ffi.PyObject_Str +import python.native.ffi.PyObject_Type +import python.native.ffi.PyTuple_New +import python.native.ffi.PyTuple_SetItem +import python.native.ffi.PyUnicode_AsUTF8 +import python.native.ffi.Py_DecRef +import python.native.ffi.Py_IncRef +import python.native.ffi.memScoped -expect open class PyObject(pointer: NativePointer, borrowed: Boolean) { - val pointer: NativePointer +/** + * Rich-comparison operators understood by `PyObject_RichCompare` + * (`Py_LT` .. `Py_GE`, in CPython's fixed `0..5` ordering). + */ +enum class PyCompareOp(val opId: Int) { + LT(0), LE(1), EQ(2), NE(3), GT(4), GE(5) } -/* - //init { - // if (!borrowed) { - //PyIncRef(pointer) - // TODO: PyIncRef을 사용하는게 적절한 선택일까? - // } - //} - // TODO: Temporary commented due to the error: Expected declaration cannot have a body. - // TODO: Move this to the actual implementation. - val pointer: NativePointer - protected val typePointer: NativePointer = lazy { PyType_GetType(this.pointer) } - val Type: PyType = lazy { PyType(typePointer!!) } +/** + * Builds a [PyException] from CPython's current error indicator (via + * [PyException.fromCurrentError]), falling back to a generic message if the + * indicator happens not to be set (e.g. a `null`/failure return whose cause + * was not, in fact, a live Python exception). Centralises the + * "null means an exception is set" contract used throughout this file and + * [PyType]/[python.multiplatform.ffi.Python3] (same package, so no import + * needed at those call sites). + */ +internal fun pyErrorOrGeneric(fallback: String): PyException = + PyException.fromCurrentError() ?: PyException(fallback) - fun incRef() { - println("hi") + +/** + * Releases one reference that no Kotlin object owns. + * + * Unlike [PyObject.decRef] this takes a bare pointer, because the whole point is the window in + * which there is no wrapper yet. Guarded by the initialisation check for the same reason every + * other refcount path is: after `Py_Finalize()` the pointer no longer addresses anything. + */ +internal fun releaseUnownedReference(pointer: NativePointer) { + if (python.multiplatform.ffi.Python3.isInitialized) withGIL { Py_DecRef(pointer) } +} + +/** + * Runs [block] on a pointer the caller has just been handed as a **new reference**, releasing + * that reference if -- and only if -- [block] throws. + * + * This closes the one leak in this architecture that no collector can reach. Between a C API + * call returning a new reference and a wrapper adopting it, the reference belongs to nobody: + * there is no Kotlin object for a cleaner to be attached to, so an exception thrown in that + * window loses the reference for the life of the interpreter. See ROADMAP §4. + * + * ### The one thing this must not be wrapped around + * + * [block] may adopt the pointer only as its **last** act. [python.multiplatform.ref.PyAutoCloseable] + * registers the cleaner in its own constructor, before any subclass initialiser runs, so a + * wrapper that throws from `init` has *already* queued a release for that pointer -- releasing + * it here too would decrement it twice, which corrupts CPython's free lists and surfaces + * somewhere unrelated (ROADMAP §1 is the account of exactly that). [PyType.getInstance] is the + * constructor in this codebase that can throw, and it validates before it constructs for this + * reason. + */ +internal inline fun NativePointer.adoptingNewReference(block: (NativePointer) -> R): R { + try { + return block(this) + } catch (t: Throwable) { + releaseUnownedReference(this) + throw t + } +} + + +/** + * Releases one reference, from wherever the cleaner happens to run. + * + * A top-level function on purpose: registered as the cleanup action it must close over the + * pointer value and nothing else. Anything that reached back to the wrapper would keep that + * wrapper strongly reachable through the Cleanable, and the cleaner would then never run -- + * which is precisely the bug this replaced. + * + * The check is made twice. Once before touching anything, so a cleaner firing after shutdown + * returns without entering the C API; and once more inside the GIL scope, because between the + * two the interpreter may have started finalizing. [Python3.finalize] lowers the flag before + * calling Py_Finalize() so this ordering actually holds. + */ +/** Diagnostic: how many times a cleanup action has actually run. Test-visible. */ +internal object ReleaseCounter { + var ran: Int = 0 + var released: Int = 0 +} + +private fun topLevelDecRefAction(ptr: NativePointer) { + ReleaseCounter.ran++ + if (!python.multiplatform.ffi.Python3.isInitialized) return + // No eval-loop checkpoint from here. This runs on the cleaner for every reference the + // collector hands back, and on a free-threaded build the queue that needs draining belongs to + // the thread that *owns* the object, never to this one -- so a checkpoint taken here would run + // Python on a cleaner thread (what ROADMAP §1 turned into a deadlock, and what §9 rules out as + // a fix) and would drain an empty queue for its trouble. + val tState = getThreadGILState() + val wasSuppressed = tState.checkpointsSuppressed + tState.checkpointsSuppressed = true + try { + python.multiplatform.ffi.withGIL { + if (!python.multiplatform.ffi.Python3.isInitialized) return@withGIL + python.native.ffi.Py_DecRef(ptr) + ReleaseCounter.released++ + } + } finally { + tState.checkpointsSuppressed = wasSuppressed + } +} + +/** + * **Invariant: [pointer] is never the null pointer**, and that is enforced by the type system + * rather than by a check here. + * + * This used to carry a TODO asking for one more null check across `PyObject`, `PyType` and + * `PyException`. It was audited instead of guessed at: every `EmbedAPI` function that can return + * `NULL` is declared `NativePointer?`, and all **721** pointer-returning `actual`s -- 179 desktop, + * 182 android, 181 native, 179 wasmJs -- route their result through a conversion that maps address + * 0 to Kotlin `null` (`toNativePointerFromRaw`, or Kotlin/Native's `toCPointer()?.let`). Exactly + * one `expect` returns a non-nullable `NativePointer`, `AddressValue.toNativePointer()`, which + * converts an already-valid pointer rather than obtaining one. `NativePointer`'s own constructor is + * `internal`, so no caller outside the FFI layer can fabricate a zero one. + * + * So a null from C cannot reach this constructor without an explicit `!!`, and the `?: throw` + * at each call site is the check -- adding a runtime one here would only re-test what the + * compiler already proved. + * + * One inconsistency the audit did turn up, recorded because nothing tests it: androidMain's + * `Long.toNativePointer()` uses `if (this > 0)` where every other platform uses `!= 0`. It agrees + * for every address Android actually hands out (arm64 user-space VAs are well below 2^63), so it + * is a discrepancy rather than a defect, and it is unreachable from the production path in any + * case -- `bindings` returns raw longs that go through `toNativePointerFromRaw`. + */ +open class PyObject(val pointer: NativePointer, borrowed: Boolean): PyAutoCloseable( + pointer, + ::topLevelDecRefAction +) { + + init { + // Yes, Py_IncRef is the right call here, and the question this used to carry has since + // been answered the expensive way. `borrowed = true` states that the caller was *lent* + // this pointer and that the wrapper must obtain its own reference, because + // PyAutoCloseable has unconditionally registered a release for it. `borrowed = false` + // states the caller is handing over a reference it owns, so no increment is due. + // + // Both halves of that have been paid for. Wrapping a lent pointer with `borrowed = false` + // gave two owners for one reference; the resulting double free corrupted CPython's free + // lists and surfaced as a segfault in an unrelated test, which is what made §1 look + // unfixable for three attempts. In the other direction, declining the increment on a + // reference the caller only borrowed loses one per call -- see ROADMAP §4 and + // `OwnershipLeakTest`, where both directions are measured rather than argued. + if (borrowed) incRef() + } + + /** + * The Python type of this object, as the design sketch names it. + * + * On JVM targets this compiles to a `getType()` accessor, which is why there is no separate + * `getType()` method -- declaring both clashes on the JVM signature and breaks every + * subclass on Android and Desktop. + */ + val Type: PyType by lazy { + // PyObject_Type returns a new reference; PyType.getInstance's private + // constructor stores it via PyObject(pointer, borrowed = false), i.e. + // it takes ownership of exactly that reference (no extra incRef). + val typePointer: NativePointer = python.multiplatform.ffi.Python3.withPython { PyObject_Type(pointer) } + ?: throw pyErrorOrGeneric("Failed to get the type of this object") + // getInstance either adopts this reference (cache miss) or releases it (cache hit); it + // throws for an object that is not a type, which is what adoptingNewReference covers. + typePointer.adoptingNewReference { PyType.getInstance(it) } + } + + /** + * Takes an extra reference to this object. + * + * Refcounting is a C API call like any other and needs a thread state attached, so it goes + * through [withGIL]. That is true of free-threaded builds too: removing the global lock + * removes contention, not the requirement that the calling thread be attached. + * + * The initialisation check exists for [clean], which runs on a cleaner thread that may have + * never touched Python and may run after `Py_Finalize()`; attaching to a finalized + * interpreter is invalid. + */ + protected fun incRef() { + if (python.multiplatform.ffi.Python3.isInitialized) withGIL { Py_IncRef(pointer) } } - fun decRef() { - println("hi") + /** Releases one reference. See [incRef] for why the GIL and the initialisation check are here. */ + protected fun decRef() { + if (python.multiplatform.ffi.Python3.isInitialized) withGIL { Py_DecRef(pointer) } } @Throws(PyException::class) fun getAttr(name: String): PyObject { - val attr = PyObject_GetAttrString(pointer, name) - if (attr == null) { - throw PyException("Attribute '$name' not found") - } + // PyObject_GetAttrString: new reference on success, null + AttributeError + // (or similar) set on the error indicator on failure. + val attr = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(pointer, name) } + ?: throw pyErrorOrGeneric("Attribute '$name' not found") return PyObject(attr, false) } fun getAttrOrNull(name: String): PyObject? { - val attr = PyObject_GetAttrString(pointer, name) - return if (attr != null) PyObject(attr, false) else null + val attr = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(pointer, name) } + if (attr == null) { + // A missing attribute sets the Python error indicator (typically + // AttributeError). This is the "OrNull" variant -- the caller has + // opted out of exception handling -- so the indicator must be + // cleared here rather than left set: the C API contract is that + // you must not call back into it with a pending exception, and + // leaving one set would silently corrupt whatever Python call + // runs next (observed as spurious failures in unrelated, + // logically unconnected calls further down the line). + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + return null + } + return PyObject(attr, false) } @Throws(PyException::class) fun setAttr(name: String, value: PyObject) { - if (PyObject_SetAttrString(pointer, name, value.pointer) != 0) { - throw PyException("Failed to set attribute '$name'") + if (python.multiplatform.ffi.Python3.withPython { PyObject_SetAttrString(pointer, name, value.pointer) } != 0) { + throw pyErrorOrGeneric("Failed to set attribute '$name'") } } fun setAttrOrNull(name: String, value: PyObject?) { - PyObject_SetAttrString(pointer, name, value?.pointer) + val target = value?.pointer ?: return + // See getAttrOrNull() for why a failure here must clear the error + // indicator rather than leave it set for whatever runs next. + if (python.multiplatform.ffi.Python3.withPython { PyObject_SetAttrString(pointer, name, target) } != 0) { + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + } } @Throws(PyException::class) fun delAttr(name: String) { - if (PyObject_DelAttrString(pointer, name) != 0) { - throw PyException("Failed to delete attribute '$name'") + if (python.multiplatform.ffi.Python3.withPython { PyObject_DelAttrString(pointer, name) } != 0) { + throw pyErrorOrGeneric("Failed to delete attribute '$name'") } } fun delAttrOrNull(name: String) { - PyObject_DelAttrString(pointer, name) + // See getAttrOrNull() for why a failure here must clear the error + // indicator rather than leave it set for whatever runs next. + if (python.multiplatform.ffi.Python3.withPython { PyObject_DelAttrString(pointer, name) } != 0) { + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + } + } + + /** + * Calls this object as a Python callable. + * + * The arity-specific overloads are not boilerplate: a `vararg` parameter allocates an + * `Array` at every call site, and one-to-three argument calls dominate FFI + * use. These build the argument tuple directly and allocate nothing on the Kotlin side. + * The `vararg` form remains for wider calls and for spreading an existing array. + * + * Keyword arguments live on [call] rather than here so the common path never pays for a + * `Map` parameter it does not use. + */ + @Throws(PyException::class) + open operator fun invoke(): PyObject = python.multiplatform.ffi.Python3.withPython { + // No tuple at all -- PyObject_CallNoArgs is CPython's dedicated zero-argument path. + PyObject(PyObject_CallNoArgs(pointer) ?: throw pyErrorOrGeneric("Call failed"), false) + } + + @Throws(PyException::class) + open operator fun invoke(arg0: PyObject): PyObject = callWithTuple(1) { t -> + setArg(t, 0, arg0) + } + + @Throws(PyException::class) + open operator fun invoke(arg0: PyObject, arg1: PyObject): PyObject = callWithTuple(2) { t -> + setArg(t, 0, arg0); setArg(t, 1, arg1) + } + + @Throws(PyException::class) + open operator fun invoke(arg0: PyObject, arg1: PyObject, arg2: PyObject): PyObject = callWithTuple(3) { t -> + setArg(t, 0, arg0); setArg(t, 1, arg1); setArg(t, 2, arg2) + } + + @Throws(PyException::class) + open operator fun invoke(arg0: PyObject, arg1: PyObject, arg2: PyObject, arg3: PyObject): PyObject = callWithTuple(4) { t -> + setArg(t, 0, arg0); setArg(t, 1, arg1); setArg(t, 2, arg2); setArg(t, 3, arg3) + } + + @Throws(PyException::class) + open operator fun invoke(vararg args: PyObject): PyObject { + if (args.isEmpty()) return invoke() + return callWithTuple(args.size) { t -> + for (i in args.indices) setArg(t, i, args[i]) + } + } + + /** + * `self(*args, **kwargs)`. Separate from [invoke] because keyword arguments require + * building a dict as well as a tuple, and callers that do not need them should not pay + * for the parameter. + */ + @Throws(PyException::class) + open fun call(args: Array = emptyArray(), kwargs: Map): PyObject = + python.multiplatform.ffi.Python3.withPython { + if (kwargs.isEmpty()) return@withPython invoke(*args) + + val argTuple = PyTuple_New(args.size.toLong()) ?: throw pyErrorOrGeneric("Failed to build argument tuple") + try { + for (i in args.indices) { + // PyTuple_SetItem steals the reference it is given, and args[i].pointer stays + // owned by args[i], so hand the tuple a fresh +1 instead of that reference. + Py_IncRef(args[i].pointer) + if (PyTuple_SetItem(argTuple, i.toLong(), args[i].pointer) != 0) { + throw pyErrorOrGeneric("Failed to populate argument tuple") + } + } + val kwargsDict = PyDict_New() ?: throw pyErrorOrGeneric("Failed to build keyword argument dict") + try { + for ((key, value) in kwargs) { + // PyDict_SetItemString does not steal; CPython increfs internally. + if (PyDict_SetItemString(kwargsDict, key, value.pointer) != 0) { + throw pyErrorOrGeneric("Failed to populate keyword argument dict") + } + } + PyObject(PyObject_Call(pointer, argTuple, kwargsDict) ?: throw pyErrorOrGeneric("Call failed"), false) + } finally { + Py_DecRef(kwargsDict) + } + } finally { + Py_DecRef(argTuple) + } + } + + /** + * Builds an argument tuple of [size], lets [fill] populate it, and calls this object with it. + * The whole sequence runs under a single GIL acquisition rather than one per C API call. + */ + private inline fun callWithTuple(size: Int, fill: (NativePointer) -> Unit): PyObject = + python.multiplatform.ffi.Python3.withPython { + val argTuple = PyTuple_New(size.toLong()) ?: throw pyErrorOrGeneric("Failed to build argument tuple") + try { + fill(argTuple) + PyObject(PyObject_CallObject(pointer, argTuple) ?: throw pyErrorOrGeneric("Call failed"), false) + } finally { + Py_DecRef(argTuple) + } + } + + /** Stores [arg] at [index]. Caller must already hold the GIL. */ + private fun setArg(tuple: NativePointer, index: Int, arg: PyObject) { + // PyTuple_SetItem steals the reference; arg keeps its own, so give the tuple a fresh +1. + Py_IncRef(arg.pointer) + if (PyTuple_SetItem(tuple, index.toLong(), arg.pointer) != 0) { + throw pyErrorOrGeneric("Failed to populate argument tuple") + } + } + + /** `callable(self)`, i.e. whether [invoke] has any chance of succeeding. */ + open val isCallable: Boolean + get() = python.multiplatform.ffi.Python3.withPython { PyCallable_Check(pointer) } != 0 + + /** `bool(self)`. Kotlin has no truthiness protocol, so this stays an explicit query. */ + open val isTruthy: Boolean + get() { + val result = python.multiplatform.ffi.Python3.withPython { PyObject_IsTrue(pointer) } + if (result < 0) throw pyErrorOrGeneric("Failed to evaluate truthiness") + return result != 0 + } + + /** `repr(self)`. */ + open fun repr(): String { + // PyObject_Repr: new reference on success, null + exception set on failure. + val reprPointer = python.multiplatform.ffi.Python3.withPython { PyObject_Repr(pointer) } ?: throw pyErrorOrGeneric("Failed to compute repr()") + // The decode below is the only thing between the new reference and its release, so the + // release goes in a finally: a throw from there would otherwise strand the repr string. + val result = try { + python.multiplatform.ffi.Python3.withPython { PyUnicode_AsUTF8(reprPointer) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(reprPointer) } + } + return result ?: throw pyErrorOrGeneric("Failed to decode repr() result") + } + + /** `python.multiplatform.ffi.Python3.withPython { PyObject_RichCompare(self, other, op) }`, i.e. the Python-level `<`, `<=`, `==`, `!=`, `>`, `>=` operators. */ + open fun richCompare(other: PyObject, op: PyCompareOp): Boolean { + // PyObject_RichCompare: new reference to the (usually bool) result on + // success, null + exception set on failure. + val resultPointer = python.multiplatform.ffi.Python3.withPython { PyObject_RichCompare(pointer, other.pointer, op.opId) } + ?: throw pyErrorOrGeneric("Comparison failed") + // PyObject_IsTrue runs __bool__/__len__, i.e. arbitrary Python -- so the release of the + // comparison result belongs in a finally rather than after it. + val truthy = try { + python.multiplatform.ffi.Python3.withPython { PyObject_IsTrue(resultPointer) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(resultPointer) } + } + if (truthy < 0) throw pyErrorOrGeneric("Failed to evaluate comparison result") + return truthy != 0 + } + + /** + * Bridges Python's rich comparison to Kotlin's `<`, `<=`, `>` and `>=`. + * + * Python has no single three-way comparison, so this asks `<` and then `>`; a type that + * implements neither raises rather than silently reporting equality. Kotlin's `==` is left + * to [equals], which compares identity of the underlying pointer -- deliberately not the + * same question as Python's `==`, which [richCompare] answers. + */ + open operator fun compareTo(other: PyObject): Int = when { + richCompare(other, PyCompareOp.LT) -> -1 + richCompare(other, PyCompareOp.GT) -> 1 + else -> 0 } override fun toString(): String { - return PyObject_GetStr(pointer) + // PyObject_Str returns a new reference; release it once we've copied + // the UTF-8 contents out into a Kotlin String. toString() is not + // declared to throw, so on failure this clears whatever error + // PyObject_Str set (via fromCurrentError()) and falls back to a + // placeholder rather than propagating it. + val strPointer = python.multiplatform.ffi.Python3.withPython { PyObject_Str(pointer) } ?: run { + val message = PyException.fromCurrentError()?.errMsg + return "" + } + val result = try { + python.multiplatform.ffi.Python3.withPython { PyUnicode_AsUTF8(strPointer) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(strPointer) } + } + return result ?: "" } + /** + * Hashes on the pointer, matching [equals], which compares pointers. + * + * **TODO(open design question)**, sharpened from "the same pointer should only ever produce + * one wrapper". That would be *wrapper interning*: a global pointer -> `PyObject` map so + * `a === b` whenever `a == b`. It is not obviously right, and what decides it is not taste: + * + * - **A pointer-keyed cache aliases across a free.** CPython reuses addresses aggressively + * (free lists, obmalloc pools), so an entry left behind after the last reference goes away + * can be handed out for a *different* object that happens to land on the same address. This + * is the hazard `reflection/HandleTable` already answers with a generation counter (§7), and + * it is why interning cannot be added without a removal path keyed to release. + * - **[PyType.getInstance] is the existing precedent and it dodges the problem by never + * evicting**, which makes every type ever wrapped immortal for the life of the interpreter. + * That is affordable for types and not obviously affordable for every object. + * + * What to measure before deciding, rather than "is this right?": + * 1. the cost of the lookup on the hot path -- a map probe per wrapper construction against + * the ~2.65 ns an `invokeExact` FFI call costs on desktop (§8), since wrappers are built + * per element in bulk conversion; + * 2. how many wrappers a realistic workload builds for pointers it already holds -- if the + * hit rate is low, interning is pure overhead; + * 3. whether any caller actually needs `===`. Nothing in this repo does today: `equals`/ + * `hashCode` already make wrappers interchangeable as map keys and in `contains`, which is + * what the collection wrappers rely on. + * + * Until (3) produces a caller, the answer is "no", and the cost of being wrong is a leak plus + * an aliasing bug rather than a missing feature. + */ override fun hashCode(): Int { - // TODO: 같은 포인터 객체는 한번만 생성하도록 해야 함 return pointer.hashCode() } @@ -81,40 +491,4 @@ expect open class PyObject(pointer: NativePointer, borrowed: Boolean) { return false } - operator fun invoke(arg0: PyObject): PyObject { - return PyObject_CallObject(pointer, arg0) - } - - operator fun invoke(arg0: PyObject, arg1: PyObject): PyObject { - return PyObject_CallObject(pointer, arg0, arg1) - } - - //... - - operator fun invoke(vararg args: PyObject): PyObject { - return PyObject_CallObject(pointer, args) // TODO: 이거는 변수 하나로 잡히던가? 아님 여러개인가? - // 리스트로 들어오는 거였던가? - }// actual fun pyLongFromLong(arg0: Long): Long { -// if (!isInitialized) return -1 -// memScoped { -// val pyLong = PyLong_FromLong(arg0) -// if (pyLong == null) { -// throw IllegalStateException("Python long from long failed") -// } -// return pyLong.toLong() -// } -// } -// -// actual fun pyLongAsLong(arg0: Long): Long { -// if (!isInitialized) return -1 -// memScoped { -// val restoredPyObj: CValuesRef<_object>? = arg0.toCPointer() -// val ktLong = PyLong_AsLong(restoredPyObj) -// if (ktLong == -1L && PyErr_Occurred() != null) { -// throw IllegalStateException("Python long as long failed") -// } -// return ktLong -// } -// } -} -*/ \ No newline at end of file +} \ No newline at end of file diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/PyType.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/PyType.kt index b6419fccd..4d81883b6 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/PyType.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/PyType.kt @@ -1,4 +1,253 @@ package python.multiplatform.ffi -class PyType { +import python.multiplatform.ffi.exceptions.PyException +import python.multiplatform.ffi.exceptions.errors.PyTypeError +import python.multiplatform.ffi.types.collections.PyDict +import python.multiplatform.ffi.types.iteration.PyIterator +import python.native.ffi.NativePointer +import python.native.ffi.PyDict_New +import python.native.ffi.PyDict_Update +import python.native.ffi.PyErr_Clear +import python.native.ffi.PyObject_GetAttrString +import python.native.ffi.PyObject_GetIter +import python.native.ffi.PyObject_IsInstance +import python.native.ffi.PyObject_Type +import python.native.ffi.PyTuple_GetItem +import python.native.ffi.PyTuple_Size +import python.native.ffi.PyType_GetName +import python.native.ffi.PyType_IsSubtype +import python.native.ffi.PyUnicode_AsUTF8 +import python.native.ffi.Py_DecRef +import python.native.ffi.Py_IncRef + + +class PyType private constructor(pointer: NativePointer): PyObject(pointer, false) { + companion object { + private val cachedObjects: MutableMap = mutableMapOf() + + /** + * The [PyType] for [pointer], which the caller must hand over as a **new reference**. + * + * Ownership is settled here in every case, which it previously was not: + * + * - cache miss: the constructed [PyType] adopts the reference and, being cached for the + * life of the interpreter, holds it forever. + * - cache hit: the cached instance already owns one, so the caller's is released here. + * It used to be dropped on the floor instead, on the argument that types are immortal + * builtins -- true of `int` and `list`, false of every user-defined class, which then + * leaked one reference per `PyObject.Type` read. + * - not a type at all: rejected *before* anything is constructed, so no cleaner has been + * registered for [pointer] and the caller's `adoptingNewReference` is the only thing + * that will release it. Validating inside `init` instead would leave a queued release + * behind on the half-built object and turn that into a double free. + */ + fun getInstance(pointer: NativePointer): PyType { + cachedObjects[pointer]?.let { cached -> + releaseUnownedReference(pointer) + return cached + } + if (!isTypeObject(pointer)) throw PyException("Object is not a type") + val created = PyType(pointer) + cachedObjects[pointer] = created + return created + } + + private fun isTypeObject(pointer: NativePointer): Boolean { + // A "type" in the CPython sense is an object whose own type ("meta-type") + // is `type` (or, in principle, a metaclass derived from it -- but the + // FFI surface here has no PyType_Check/PyType_IsSubtype-friendly handle + // on the builtin `type` object itself to test against, so this checks + // the meta-type's __name__ instead of `pointer`'s own __name__, which + // is the bug this fixes: reading `pointer.__name__` rejects every + // legitimate type, since e.g. `int.__name__` is "int", never "type"). + val metaTypePointer: NativePointer = python.multiplatform.ffi.Python3.withPython { PyObject_Type(pointer) } ?: run { + // PyObject_Type basically never fails (every live object has a + // type), but if it somehow did, it would leave the error + // indicator set; this reports "not a type" rather than + // propagating, so the indicator must be cleared here too -- + // otherwise it would silently poison whatever Python call runs + // next (see the *OrNull helpers on PyObject for the same class + // of bug). + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + return false + } + // Both new references below are released in a finally, so a throw out of the decode + // (or out of withPython, after finalisation) cannot strand either of them. + try { + val metaTypeNamePointer: NativePointer? = + python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(metaTypePointer, "__name__") } + if (metaTypeNamePointer == null) { + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + return false + } + val metaTypeName: String? = try { + python.multiplatform.ffi.Python3.withPython { PyUnicode_AsUTF8(metaTypeNamePointer) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(metaTypeNamePointer) } + } + return metaTypeName == "type" + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(metaTypePointer) } + } + } + } + + val name: String by lazy { + // PyType_GetName: new reference on success. + val namePtr: NativePointer = python.multiplatform.ffi.Python3.withPython { PyType_GetName(pointer) } ?: throw pyErrorOrGeneric("Failed to get the type's name") + val nameStr: String? = try { + python.multiplatform.ffi.Python3.withPython { PyUnicode_AsUTF8(namePtr) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(namePtr) } + } + nameStr ?: throw pyErrorOrGeneric("Failed to decode the type's name") + } + + val baseType: PyType by lazy { + // PyObject_GetAttrString: new reference; getInstance() takes ownership + // of it when not already cached (see the comment on `getInstance`). + val attrPtr: NativePointer = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(pointer, "__base__") } + ?: throw pyErrorOrGeneric("Failed to get __base__") + // `object.__base__` is None, which getInstance rejects -- so this reference genuinely + // does need releasing on the throw path, not merely in theory. + attrPtr.adoptingNewReference { getInstance(it) } + } + + /** `type.__bases__`, i.e. the type's immediate (non-transitive) base classes. */ + val baseTypes: List + get() = tupleAttrAsTypes("__bases__") + + /** Method Resolution Order, i.e. `type.__mro__` -- the linearised lookup order for attributes. */ + val mro: List by lazy { + tupleAttrAsTypes("__mro__") + } + + /** Reads a tuple-valued attribute (e.g. `__bases__`, `__mro__`) off this type and wraps each entry as a [PyType]. */ + private fun tupleAttrAsTypes(attrName: String): List { + // PyObject_GetAttrString: new reference to the tuple. + val tuplePointer: NativePointer = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(pointer, attrName) } + ?: throw pyErrorOrGeneric("Failed to get $attrName") + try { + val size = python.multiplatform.ffi.Python3.withPython { PyTuple_Size(tuplePointer) } + if (size == -1L) throw pyErrorOrGeneric("Failed to get the size of $attrName") + + val list = ArrayList(size.toInt()) + for (i in 0 until size) { + // PyTuple_GetItem returns a *borrowed* reference; getInstance() + // requires a new one, so give it its own +1. It settles that + // reference either way now -- adopting it on a cache miss and + // releasing it on a cache hit. + val itemPointer = python.multiplatform.ffi.Python3.withPython { PyTuple_GetItem(tuplePointer, i) } + ?: throw pyErrorOrGeneric("Failed to get $attrName[$i]") + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_IncRef(itemPointer) } + list.add(itemPointer.adoptingNewReference { getInstance(it) }) + } + return list + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(tuplePointer) } + } + } + + /** + * The type's own namespace, as a **snapshot copy** of `type.__dict__`. + * + * Two things force the copy rather than a wrapper over the pointer `__dict__` returns. + * + * A type's `__dict__` is a **`mappingproxy`**, not a `dict`. Wrapping that pointer in a + * [PyDict] does not fail loudly, it fails silently and then corrupts: `PyDict_Size` and + * `PyDict_Items` reject a non-dict with `PyErr_BadInternalCall()`, returning `-1`/`NULL` + * *and leaving the error indicator set*, so the call that reports the problem is some + * unrelated Python call further down. That was measured, not reasoned about -- reading + * `int.__dict__` gave `size == -1` and the next `Python3.eval` in the suite died with + * `Objects/dictobject.c:4248: bad argument to internal function`. + * `PyTypeTest.readingDictLeavesNoPendingError` is the guard against it coming back. + * + * `PyType_GetDict()`, which an earlier note here suggested instead, is not an option: it is + * declared in `cpython/object.h`, i.e. outside the Limited API, so it is not in the Stable + * ABI this binding restricts itself to. `PyObject_GetAttrString` + `PyDict_Update` are, and + * `mappingproxy` supports the `keys()`/`__getitem__` protocol `PyDict_Update` needs. + * + * A snapshot loses nothing a live view would have given: `mappingproxy` is read-only, so + * mutation through this wrapper was never going to reach the type. It *is* a point-in-time + * copy, so re-read the property after `setattr` on the type. + */ + val dict: PyDict by lazy { + // PyObject_GetAttrString: new reference to the mappingproxy; released unconditionally + // below, since only the copy outlives this block. + val proxyPointer: NativePointer = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(pointer, "__dict__") } + ?: throw pyErrorOrGeneric("Failed to get __dict__") + try { + val copyPointer: NativePointer = python.multiplatform.ffi.Python3.withPython { PyDict_New() } + ?: throw pyErrorOrGeneric("Failed to allocate a dict to copy __dict__ into") + // The copy is a bare pointer until PyDict adopts it, and PyDict_Update runs arbitrary + // Python (mappingproxy.keys(), __getitem__) and so can fail -- see ROADMAP §4. + copyPointer.adoptingNewReference { + if (python.multiplatform.ffi.Python3.withPython { PyDict_Update(it, proxyPointer) } != 0) { + throw pyErrorOrGeneric("Failed to copy __dict__ into a dict") + } + PyDict(it, false) + } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(proxyPointer) } + } + } + + /** `python.multiplatform.ffi.Python3.withPython { PyType_IsSubtype(this, other) }`, i.e. Python's `issubclass(self, other)`. */ + fun isSubtypeOf(other: PyType): Boolean = python.multiplatform.ffi.Python3.withPython { PyType_IsSubtype(pointer, other.pointer) } != 0 + + /** + * Attempts to view [obj] as an instance of this type, raising [PyException] + * (mirroring a Python `TypeError`) if it is not an instance of, or convertible to, this type. + */ + @Throws(PyException::class) + fun cast(obj: PyObject): PyObject { + if (!isInstance(obj)) { + throw PyTypeError("Object is not an instance of '$name'") + } + return obj + } + + /** `python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(obj, this) }`, i.e. Python's `isinstance(obj, self)`. */ + fun isInstance(obj: PyObject): Boolean { + val result = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(obj.pointer, pointer) } + if (result < 0) throw pyErrorOrGeneric("isinstance() check failed") + return result != 0 + } + + /** `PyObject_GetIter` applied to an instance of this type, when this type describes an iterable. */ + fun getIterator(): PyIterator { + // Constructs a fresh, no-argument instance of this type and returns its iterator. + val instance = invoke() + val iterPointer = python.multiplatform.ffi.Python3.withPython { PyObject_GetIter(instance.pointer) } + ?: throw pyErrorOrGeneric("Instances of '$name' are not iterable") + return PyIterator(iterPointer, false) + } + + /** + * Calls this type object, i.e. constructs a new instance: `self(*args, **kwargs)`. + * + * Type objects are themselves callable -- `self(...)` runs `type.__call__`, i.e. `__new__` + * followed by `__init__` -- so this just names the operation; the arity-specific [invoke] + * overloads and [call] inherited from [PyObject] do the work. + */ + @Throws(PyException::class) + override fun call(args: Array, kwargs: Map): PyObject = + super.call(args, kwargs) + + /** `type.__new__(self)` -- allocates (but does not initialise) a new instance of this type. */ + fun __new__(): PyObject { + // `__new__` is a static method taking the class as its first argument; + // there's no dedicated tp_new FFI entry point here, so go through the + // normal attribute + call path (`type.__new__(self)`). + return getAttr("__new__").invoke(this) + } + + /** `type.__init__(obj, *args)` -- initialises an already-allocated [obj] in place. */ + fun __init__(obj: PyObject, vararg args: PyObject) { + obj.getAttr("__init__").invoke(*args) + } + + override fun hashCode(): Int { + return super.hashCode() + } } \ No newline at end of file diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/PyTypeChecks.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/PyTypeChecks.kt new file mode 100644 index 000000000..0b4f4174e --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/PyTypeChecks.kt @@ -0,0 +1,147 @@ +package python.multiplatform.ffi + +import python.multiplatform.ffi.exceptions.PyException +import python.native.ffi.NativePointer +import python.native.ffi.PyDict_GetItemString +import python.native.ffi.PyEval_GetBuiltins +import python.native.ffi.PyImport_AddModule +import python.native.ffi.PyObject_IsInstance +import python.native.ffi.PyObject_Type + +/** + * Fast, cross-platform substitute for CPython's `PyLong_Check`/`PyFloat_Check`/ + * `PyBool_Check`/`PyUnicode_Check`/`PyList_Check`/`PyTuple_Check`/`PyDict_Check`/ + * `PySet_Check`/`PyFrozenSet_Check`/`PyType_Check`/`PyModule_Check` macro + * family. + * + * ### Why none of these are `expect`/`actual` bindings + * + * None of the macros above are real, dynamically-linkable ABI symbols. + * `#define python.multiplatform.ffi.Python3.withPython { PyLong_Check(op) } PyType_FastSubclass(Py_TYPE(op), + * Py_TPFLAGS_LONG_SUBCLASS)` and its siblings for `list`/`tuple`/`dict`/ + * `set`/`frozenset`/`str` are preprocessor macros; `PyType_Check` and + * `PyModule_Check`'s underlying `PyObject_TypeCheck` are `static inline` C + * functions instead, but those are *also* not exported symbols (`nm` on the + * compiled `libpython3.13`/`Python.xcframework` binaries this project ships + * confirms none of them appear in the dynamic symbol table, unlike e.g. + * `PyList_Size` or `PyModule_GetName`, which do). There is therefore no + * `dlsym`/cinterop-visible entry point for any of them on *any* of the four + * platforms this library targets (iOS/androidNative via cinterop, Android via + * JNI, desktop via Panama) -- adding an `expect` declaration for one would + * compile on commonMain but leave every platform's `actual` with nothing to + * bind against, breaking the whole build identically everywhere. So instead, + * each check below is plain common Kotlin -- no `expect`/`actual` split at + * all -- built only out of primitives this ABI subset *does* expose as real + * functions, which by construction behaves identically on all four platforms. + * + * ### The chosen primitive: `PyObject_IsInstance` + * + * `python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(obj, type) }` is a genuine, exported `PyAPI_FUNC` + * (`abstract.h`), already bound as `python.native.ffi.PyObject_IsInstance`. + * It matches the macros' own subclass-inclusive semantics: `PyLong_Check` + * is true for `bool` (since `bool` subclasses `int` and therefore carries + * `Py_TPFLAGS_LONG_SUBCLASS`), and `isinstance(x, int)` agrees for the same + * reason; the same correspondence holds for every other check here, + * including `PyType_Check` (`isinstance(op, type)`, i.e. "is `op` itself a + * type/metaclass instance") and `PyModule_Check`. It is one FFI crossing, + * with no new reference returned to release afterwards. + * + * ### The cached type-object side + * + * `PyObject_IsInstance`'s second argument needs an actual type object to + * compare against, and the `python.multiplatform.ffi.Python3.withPython { PyAPI_DATA(PyTypeObject) } PyLong_Type` (etc.) + * globals this would naturally read are, like `Py_None` (see + * [python.multiplatform.ffi.types.basic.PyNone]'s class doc), *data* + * symbols, not functions -- pulling them in directly would mean adding + * raw-symbol-address plumbing per platform (a `dlsym`-alike for Android + * JNI, a `SymbolLookup` for Panama, alongside cinterop's own mechanism) + * instead of one portable helper, and would still need `expect`/`actual` + * despite the checks themselves not needing it. This resolves the same + * type objects Python itself uses, indirectly, the same way [PyNone] + * resolves the `None` singleton: via `python.multiplatform.ffi.Python3.withPython { PyEval_GetBuiltins() }` + + * `python.multiplatform.ffi.Python3.withPython { PyDict_GetItemString() }` for the ones that are builtins (`int`, `bool`, + * `float`, `str`, `list`, `tuple`, `dict`, `set`, `frozenset`, `type`), and + * via `python.multiplatform.ffi.Python3.withPython { PyObject_Type() }` of the `builtins` module itself for `module` + * (`types.ModuleType` is not a builtin name, but the `builtins` module + * object -- obtained via the already-bound `python.multiplatform.ffi.Python3.withPython { PyImport_AddModule("builtins") }` + * -- is always resolvable once the interpreter is up, and is itself an + * instance of exactly the type this needs). All of it reuses functions + * this ABI subset already exposes; each cache entry resolves once, lazily, + * and is retained for the process's lifetime (CPython treats built-in + * types as immortal, so this is harmless -- see the same reasoning in + * [PyType.getInstance]'s handling of already-cached entries). + * + * ### Why this is genuinely cheaper than the string-dispatch workaround + * + * Each check here is exactly one FFI crossing (`PyObject_IsInstance`, + * after the type cache has warmed up). The workaround it replaces + * ([python.multiplatform.ffi.types.collections.pyObjectToNative]/ + * `PyContext.typedWrap`'s `PyType.name` dispatch, before this file existed) + * cost *three* FFI crossings just to read a type's name well enough to + * compare it as a string (`PyObject_Type` + `PyType_GetName` + + * `PyUnicode_AsUTF8`), plus a UTF-8 string allocation/decode on every + * single dispatch, before any comparison could even start. + */ +internal object PyTypeChecks { + private val builtins: NativePointer by lazy { + python.multiplatform.ffi.Python3.withPython { PyEval_GetBuiltins() } ?: throw PyException.fromCurrentError() ?: PyException("Failed to get the builtins dict") + } + + private fun builtin(name: String): NativePointer = + python.multiplatform.ffi.Python3.withPython { PyDict_GetItemString(builtins, name) } + ?: throw PyException.fromCurrentError() ?: PyException("Builtin '$name' not found") + + val intType: NativePointer by lazy { builtin("int") } + val boolType: NativePointer by lazy { builtin("bool") } + val floatType: NativePointer by lazy { builtin("float") } + val strType: NativePointer by lazy { builtin("str") } + val bytesType: NativePointer by lazy { builtin("bytes") } + val bytearrayType: NativePointer by lazy { builtin("bytearray") } + val listType: NativePointer by lazy { builtin("list") } + val tupleType: NativePointer by lazy { builtin("tuple") } + val dictType: NativePointer by lazy { builtin("dict") } + val setType: NativePointer by lazy { builtin("set") } + val frozensetType: NativePointer by lazy { builtin("frozenset") } + val typeType: NativePointer by lazy { builtin("type") } + + /** `types.ModuleType` -- not a builtin name, so derived from the `builtins` module's own [PyObject_Type] instead (see class doc). */ + val moduleType: NativePointer by lazy { + val builtinsModule = python.multiplatform.ffi.Python3.withPython { PyImport_AddModule("builtins") } // borrowed reference; always resolvable once the interpreter is up + ?: throw PyException.fromCurrentError() ?: PyException("Failed to resolve the 'builtins' module") + python.multiplatform.ffi.Python3.withPython { PyObject_Type(builtinsModule) } // new reference, intentionally never released -- immortal builtin type, see class doc + ?: throw PyException.fromCurrentError() ?: PyException("Failed to resolve the 'module' type") + } +} + +/** `python.multiplatform.ffi.Python3.withPython { PyLong_Check(o) }`: is [o] a Python `int` (or subclass, e.g. `bool`)? See [PyTypeChecks]'s class doc for the approach. */ +fun PyLong_Check(o: NativePointer): Int = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(o, PyTypeChecks.intType) } + +/** `python.multiplatform.ffi.Python3.withPython { PyBool_Check(o) }`: is [o] a Python `bool`? See [PyTypeChecks]'s class doc for the approach. */ +fun PyBool_Check(o: NativePointer): Int = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(o, PyTypeChecks.boolType) } + +/** `python.multiplatform.ffi.Python3.withPython { PyFloat_Check(o) }`: is [o] a Python `float` (or subclass)? See [PyTypeChecks]'s class doc for the approach. */ +fun PyFloat_Check(o: NativePointer): Int = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(o, PyTypeChecks.floatType) } + +/** `python.multiplatform.ffi.Python3.withPython { PyUnicode_Check(o) }`: is [o] a Python `str` (or subclass)? See [PyTypeChecks]'s class doc for the approach. */ +fun PyUnicode_Check(o: NativePointer): Int = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(o, PyTypeChecks.strType) } + +/** `python.multiplatform.ffi.Python3.withPython { PyList_Check(o) }`: is [o] a Python `list` (or subclass)? See [PyTypeChecks]'s class doc for the approach. */ +fun PyList_Check(o: NativePointer): Int = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(o, PyTypeChecks.listType) } + +/** `python.multiplatform.ffi.Python3.withPython { PyTuple_Check(o) }`: is [o] a Python `tuple` (or subclass)? See [PyTypeChecks]'s class doc for the approach. */ +fun PyTuple_Check(o: NativePointer): Int = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(o, PyTypeChecks.tupleType) } + +/** `python.multiplatform.ffi.Python3.withPython { PyDict_Check(o) }`: is [o] a Python `dict` (or subclass)? See [PyTypeChecks]'s class doc for the approach. */ +fun PyDict_Check(o: NativePointer): Int = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(o, PyTypeChecks.dictType) } + +/** `python.multiplatform.ffi.Python3.withPython { PySet_Check(o) }`: is [o] a Python `set` (or subclass)? See [PyTypeChecks]'s class doc for the approach. */ +fun PySet_Check(o: NativePointer): Int = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(o, PyTypeChecks.setType) } + +/** `python.multiplatform.ffi.Python3.withPython { PyFrozenSet_Check(o) }`: is [o] a Python `frozenset` (or subclass)? See [PyTypeChecks]'s class doc for the approach. */ +fun PyFrozenSet_Check(o: NativePointer): Int = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(o, PyTypeChecks.frozensetType) } + +/** `python.multiplatform.ffi.Python3.withPython { PyType_Check(o) }`: is [o] itself a type object (or a metaclass instance)? See [PyTypeChecks]'s class doc for the approach. */ +fun PyType_Check(o: NativePointer): Int = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(o, PyTypeChecks.typeType) } + +/** `python.multiplatform.ffi.Python3.withPython { PyModule_Check(o) }`: is [o] a Python module object? See [PyTypeChecks]'s class doc for the approach. */ +fun PyModule_Check(o: NativePointer): Int = python.multiplatform.ffi.Python3.withPython { PyObject_IsInstance(o, PyTypeChecks.moduleType) } 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 8cee7e75c..48aa9cc63 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/Python3.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/Python3.kt @@ -1,5 +1,10 @@ package python.multiplatform.ffi +import python.multiplatform.BuildConfig +import python.multiplatform.env.PythonHomeCheck +import python.multiplatform.env.PythonPayload +import python.multiplatform.env.applyPackagedPythonHome +import python.multiplatform.ffi.exceptions.PyException import python.multiplatform.ffi.types.modules.PyModule import python.native.ffi.* @@ -14,27 +19,113 @@ object Python3 { /** * Ensure Python is initialized */ - inline fun withPython(block: () -> T): T { + inline fun withPython(block: () -> T): T { if (!isInitialized) throw IllegalStateException("Python is not initialized") - return memScoped { - block() + return withGIL { + memScoped { + block() + } } } + /** + * The thread state belonging to the thread that ran [initialize]. + * + * `Py_Initialize()` leaves its calling thread holding the GIL. If that thread simply kept it, + * no other thread could ever attach: `PyGILState_Ensure` would block forever waiting for a GIL + * that is never released. So the state is saved away here immediately after initialisation, + * leaving the GIL unheld, and every entry into Python goes through [withGIL] instead. + * + * `Py_Finalize()` must run with the GIL held, so [finalize] restores this first. + */ + private var mainThreadState: NativePointer? = null + /** * Initialize Python */ fun initialize(silent: Boolean = false) { - if (isInitialized) return + if (isInitialized) { + // Not a no-op: something else may have reached Py_Initialize() first (isInitialized is + // seeded from Py_IsInitialized()), in which case this is the only call that will ever + // get here and the consumer's payload has still not been put on sys.path. The hook + // does its work at most once per process, so arriving here repeatedly is free. + PythonPayload.installStagedRootsOnStartup() + installBuiltinCompiledOnce() + return + } + // Runs once per process, before the call whose own failure mode is either an uncatchable + // `Py_FatalError()` abort or -- on a sandboxed `PYTHONHOME` -- a silent hang (see + // PythonHomeCheck's doc comment). Cheap even so: one or two filesystem probes against a + // handful of candidate paths, not a directory walk. + PythonHomeCheck.verifyOrThrow() + // A packaged desktop app has no PYTHONHOME in its environment and hands its own prefix to + // CPython here instead (SPEC L-9); a no-op everywhere else, and whenever PYTHONHOME is set. + // Before Py_Initialize() because that is the last point a home can be chosen, and it + // throws rather than continuing because Py_Initialize() would abort instead. + applyPackagedPythonHome() memScoped { Py_Initialize() + // There is nothing richer to report than this. `Py_Initialize()` returns void and, + // per its own contract, treats a failed start-up as a *fatal* error -- it calls + // Py_FatalError and aborts the process rather than returning, so a Kotlin frame that + // could inspect an error indicator is never reached, and there is no interpreter left + // to hold one anyway. The check below is therefore a belt-and-braces guard against a + // no-op second call, not an error channel. (The API that does report status is + // `Py_InitializeFromConfig`/`PyStatus`, which this binding does not use: it would + // mean carrying a `PyConfig` struct layout across four platforms, and struct layout + // is exactly what the Stable ABI does not promise.) if (Py_IsInitialized() == 0) { - // TODO: Add error handling throw IllegalStateException("Python initialization failed") } if (!silent) println("INFO: Python initialized successfully!") isInitialized = true } + // Release the GIL so other threads (in particular cleaner threads running + // Py_DecRef) can attach via PyGILState_Ensure. Every C API call reachable from + // commonMain and commonTest is now inside withPython{} or withGIL{}, so this is safe. + // See ROADMAP §1 and §4 for the history and the previous revert. + if (mainThreadState == null) mainThreadState = PyEval_SaveThread() + // `compiled` becomes a builtin name here (docs/SPEC.md N-7). Needs the GIL, so it comes + // after the parking above and goes through exec()'s withPython/withGIL. + installBuiltinCompiledOnce() + // The consumer's own Python code goes on sys.path here: after Py_Initialize() built the + // list (nothing can add to it before that point) and before this function returns, so + // before any import a caller can reach. `PYTHONHOME` above is the *standard library* and + // is a separate mechanism entirely -- see PythonPayload for the whole reasoning, and for + // the `autoInstall` switch that turns this off. + PythonPayload.installStagedRootsOnStartup() + } + + private var builtinCompiledInstalled = false + + private fun installBuiltinCompiledOnce() { + if (builtinCompiledInstalled) return + installBuiltinCompiled() + builtinCompiledInstalled = true + } + + /** + * Sets `builtins.compiled` to an identity decorator so `@compiled` needs no import. + * The TypedPython compiler picks decorated functions at build time; at run time it does + * nothing. An existing `builtins.compiled` (e.g. the typedpython dev shim) is left alone. + * + * Runs through [exec], which holds the GIL for every C API call it makes; no raw pointer is + * held here, so there is no reference to state. The snippet runs inside a function so it + * leaves no names behind in `__main__`. + */ + internal fun installBuiltinCompiled() { + exec( + """ + def _pm_install_compiled(): + import builtins + if not hasattr(builtins, "compiled"): + def compiled(f): + return f + builtins.compiled = compiled + _pm_install_compiled() + del _pm_install_compiled + """.trimIndent() + ) } /** @@ -42,44 +133,808 @@ object Python3 { */ internal fun finalize(silent: Boolean = false) { if (!isInitialized) return + // Py_Finalize() requires the GIL. Reclaim the state parked by initialize(). + mainThreadState?.let { + PyEval_RestoreThread(it) + mainThreadState = null + } + // Lowered BEFORE Py_Finalize(), not after. Cleaner threads consult this flag to decide + // whether releasing a reference is still legal, and finalization is exactly the window + // where it stops being legal -- leaving the flag up until afterwards let a cleaner pass + // the check and call Py_DecRef while Py_Finalize() was tearing the interpreter down, + // which crashed the process inside _PyObject_ClearFreeLists. + // + // With the flag down first, a cleaner arriving from here on returns without touching the + // C API at all. One already inside its GIL scope still holds the GIL, and Py_Finalize() + // waits for it, so that case finishes safely before teardown begins. + isInitialized = false + // The checkpoint function dies with the interpreter; drop the pointer so a later + // initialize() rebuilds it instead of calling through a dangling one. Same for the + // runMain/runApp helpers, which are cached the same way and would dangle the same way. + checkpointCallable = null + builtinCompiledInstalled = false + runModuleCallable = null + runTopLevelCallable = null + runFileCallable = null memScoped { Py_Finalize() - // TODO: print error message if exists + // No error message is printable here, and that is a property of the C API rather than + // an omission. `Py_Finalize()` returns void; by the time it returns there is no + // interpreter left to hold an error indicator, so `PyErr_*` cannot be consulted. The + // only status available at all is `Py_FinalizeEx()`'s `int` -- 0, or -1 when flushing + // buffered data failed -- which carries no message either. Switching to it is a real + // (small) improvement and is recorded in ROADMAP §12 rather than done here, because + // finalization is untested: the only caller is `artMain/JniExport.kt`, and a test + // that exercises it destroys the interpreter the rest of the suite shares. if (Py_IsInitialized() != 0) { + isInitialized = true throw IllegalStateException("Python finalization failed") } if (!silent) println("INFO: Python finalized successfully!") - isInitialized = false } } /** - * Run Python main module + * Runs the module [moduleName] as `__main__`, the way `python -m ` does, in an + * interpreter that is **still alive when this returns**. + * + * ### 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 + * 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 + * said `true`. (`Py_BytesMain()` is not an escape either: it is the whole CLI `main`, and it + * reaches the same `Py_RunMain()`. See [runApp].) The same old body also did + * `sys.argv[1] = ...`, an assignment to an index that need not exist, through + * `PyRun_SimpleString`, which prints and clears the error indicator so the resulting + * `IndexError` was invisible. + * + * `PyImport_ImportModule(moduleName)` is the other obvious candidate and is *not* the same + * thing twice over: it runs the module body under its own `__name__`, so the + * `if __name__ == "__main__":` block that is usually the entire point does not fire, and it + * caches the module in `sys.modules`, so calling it a second time runs nothing at all. + * + * ### What it does + * + * `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` + * makes `runpy._run_module_code` wrap the execution in two context managers: + * + * - `_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 + * 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. + * + * ### `sys.argv` + * + * `sys.argv` is set to `[moduleName] + `[args] for the duration and **put back afterwards**, + * 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 + * 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 + * existing. + * + * Element 0 is a placeholder: `runpy` overwrites it with the module's origin, so what the + * module actually observes is `[] + `[args], exactly as under `-m`. + * + * ### 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 + * an ordinary failure, not a poisoned runtime. + * + * `SystemExit` is deliberately **not** in that class. A module written to be run as `__main__` + * uses `sys.exit(n)` to say "I am done, and this is my status"; CPython's own command line + * agrees, catching `SystemExit` and turning it into the process exit status rather than + * printing a traceback. The half of that behaviour an embedder must not inherit is the + * *process* exit, so the status is returned instead of being acted on: + * + * - `sys.exit()` / `sys.exit(None)` / falling off the end of the module → `0` + * - `sys.exit(n)` for an integer `n` → `n` + * - `sys.exit(x)` for anything else → `x` is written to `sys.stderr` and `1` is returned, + * which is what CPython's `_Py_HandleSystemExit` does with it + * + * ### Cost + * + * This is not in the same class as [exec] and should not be reached for in a loop: every call + * goes back through `importlib.util.find_spec`, obtains the module's code object again and + * builds a fresh namespace. `RunMainTest.runMainCostAgainstExec` prints the measured + * per-call cost of both side by side. + * + * `RunMainTest` is the guard; ROADMAP §12 records the decision. + * + * @param moduleName an absolute module or package name, as after `python -m`. A package runs + * its `__main__` submodule, since that is what `runpy._get_module_details` resolves to. + * @param args the arguments the module should see as `sys.argv[1:]`. + * @return the exit status: 0 for a clean run, otherwise whatever the module passed to + * `sys.exit()`. + * @throws PyException if the module cannot be found, or if it raises anything other than + * `SystemExit`. */ - fun runMain(moduleName: String) { - withPython { - PyRun_SimpleString("import sys\nsys.argv[1] = '$moduleName'\n") - Py_RunMain() - // TODO: Add error handling + fun runMain(moduleName: String, args: List = emptyList()): Int = withPython { + val helper = runModuleHelperHoldingGIL() + + // The list the module will see as sys.argv. Element 0 is a placeholder that runpy + // replaces with the module's origin (_ModifiedArgv0); the rest is args verbatim. + // PyList_New: new reference. + val argv = PyList_New(0) + ?: throw pyErrorOrGeneric("Failed to build sys.argv for '$moduleName'") + try { + for (arg in listOf(moduleName) + args) { + // PyUnicode_FromString: new reference. PyList_Append does *not* steal, so this + // is released either way. + val item = PyUnicode_FromString(arg) + ?: throw pyErrorOrGeneric("Failed to convert argument '$arg' for '$moduleName'") + try { + if (PyList_Append(argv, item) != 0) { + throw pyErrorOrGeneric("Failed to append argument '$arg' for '$moduleName'") + } + } finally { + Py_DecRef(item) + } + } + + // PyTuple_New: new reference. PyTuple_SetItem *steals* one reference per slot, and + // steals it even when it fails, so nothing set below is released again here. + val callArgs = PyTuple_New(2) + ?: throw pyErrorOrGeneric("Failed to build the argument tuple for '$moduleName'") + try { + val name = PyUnicode_FromString(moduleName) + ?: throw pyErrorOrGeneric("Failed to convert the module name '$moduleName'") + if (PyTuple_SetItem(callArgs, 0, name) != 0) { + throw pyErrorOrGeneric("Failed to build the argument tuple for '$moduleName'") + } + // The tuple takes a reference of its own; `argv`'s is still ours to release in + // the outer finally. + Py_IncRef(argv) + if (PyTuple_SetItem(callArgs, 1, argv) != 0) { + throw pyErrorOrGeneric("Failed to build the argument tuple for '$moduleName'") + } + + // PyObject_CallObject: new reference, or null with the indicator set. Anything + // the module raised other than SystemExit arrives here -- the helper lets it + // through untouched, so the type and traceback are the module's own. + val result = PyObject_CallObject(helper, callArgs) + ?: throw pyErrorOrGeneric("Running module '$moduleName' as __main__ failed") + try { + val status = PyLong_AsLongLong(result) + if (status == -1L && PyErr_Occurred() != null) { + throw pyErrorOrGeneric("Could not read the exit status of '$moduleName'") + } + return@withPython status.toInt() + } finally { + Py_DecRef(result) + } + } finally { + Py_DecRef(callArgs) + } + } finally { + Py_DecRef(argv) + } + } + + /** The cached `__pmp_run_module__` helper compiled from [RUN_MODULE_SOURCE]. */ + private var runModuleCallable: NativePointer? = null + + /** + * 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 + * `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 + * in the one path that exists to report failures. + * + * What it deliberately does *not* do is catch anything but `SystemExit`: any other exception + * leaves the helper with the indicator set, so [runMain]'s `PyObject_CallObject` returns null + * and `pyErrorOrGeneric` builds a [PyException] out of the module's own error. + */ + private const val RUN_MODULE_SOURCE = """ +import runpy as _pmp_runpy +import sys as _pmp_sys + + +def __pmp_run_module__(mod_name, argv): + _pmp_saved = _pmp_sys.argv if hasattr(_pmp_sys, "argv") else None + _pmp_sys.argv = list(argv) + try: + try: + _pmp_runpy.run_module(mod_name, run_name="__main__", alter_sys=True) + except SystemExit as exc: + code = exc.code + if code is None: + return 0 + if isinstance(code, int): + return code + try: + _pmp_sys.stderr.write(str(code) + "\n") + _pmp_sys.stderr.flush() + except Exception: + pass + return 1 + return 0 + finally: + if _pmp_saved is None: + try: + del _pmp_sys.argv + except AttributeError: + pass + else: + _pmp_sys.argv = _pmp_saved +""" + + /** + * 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 + * 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. + * + * The private dict is released here; the function keeps it alive through `__globals__`. + * + * The caller must hold the GIL. + */ + private fun runModuleHelperHoldingGIL(): NativePointer { + runModuleCallable?.let { return it } + val code = Py_CompileString(RUN_MODULE_SOURCE, "", PY_FILE_INPUT) + ?: throw pyErrorOrGeneric("Failed to compile the runMain helper") + try { + val globals = PyDict_New() ?: throw pyErrorOrGeneric("Failed to allocate the runMain helper's globals") + try { + // This is where `import runpy` actually happens, so a build with no standard + // library reports it here rather than as a mysterious null further down. + val evaluated = PyEval_EvalCode(code, globals, globals) + ?: throw pyErrorOrGeneric("Failed to define the runMain helper (is `runpy` importable?)") + Py_DecRef(evaluated) + // PyDict_GetItemString returns a borrowed reference, so take one of our own. + val borrowed = PyDict_GetItemString(globals, "__pmp_run_module__") + ?: throw pyErrorOrGeneric("The runMain helper did not define __pmp_run_module__") + Py_IncRef(borrowed) + runModuleCallable = borrowed + return borrowed + } finally { + Py_DecRef(globals) + } + } finally { + Py_DecRef(code) + } + } + + /** + * 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: + * `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 + * 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 + * 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 + * 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 + * 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 + * 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] == + * '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 + * 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 + * 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 + * 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 + * 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 + * caller's own script that happens to start with `-` is never mistaken for one of the options + * above. + * + * ### `sys.exit()`, exceptions, and `sys.argv` restoration + * + * Identical contract to [runMain], because it is the same design decision applied to a + * different execution path: `sys.exit(n)` is this function's return value, not a process + * exit (`sys.exit()`/`None`/falling off the end → `0`; integer `n` → `n`; anything else is + * written to `sys.stderr` and reported as `1`, mirroring CPython's `_Py_HandleSystemExit`). + * Any other exception propagates as a [PyException] with the interpreter left usable. + * `sys.argv` is set for the duration of the call and restored afterwards, on both the + * 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 + * `argv[1:]`. + * @return the exit status: 0 for a clean run, otherwise whatever the program passed to + * `sys.exit()`. + * @throws IllegalArgumentException if `-c` or `-m` is given with no following argument. + * @throws UnsupportedOperationException for `-`, empty `args`, `--` with nothing after it, or + * any other option that only has meaning before `Py_Initialize()`. + * @throws PyException if the program raises anything other than `SystemExit`, or if a `-m` + * module cannot be found. + */ + fun runApp(args: List): Int { + if (args.isEmpty()) { + throw UnsupportedOperationException( + "Python3.runApp got no arguments: CPython's own default with nothing on the " + + "command line is to read the program from stdin, and an embedded interpreter " + + "has no sane default for that. Pass -c , -m , or a script path." + ) + } + val first = args[0] + return when { + first == "-c" -> { + if (args.size < 2) throw IllegalArgumentException("Argument expected for the -c option") + val command = args[1] + runTopLevelHoldingGIL(command, "", listOf("-c") + args.drop(2)) + } + first == "-m" -> { + if (args.size < 2) throw IllegalArgumentException("Argument expected for the -m option") + runMain(args[1], args.drop(2)) + } + first == "-" -> throw UnsupportedOperationException( + "Python3.runApp does not support '-' (reading the program from stdin); pass " + + "-c , -m , or a script path instead." + ) + first == "--" -> { + if (args.size < 2) throw UnsupportedOperationException( + "Python3.runApp got '--' with no script path after it; reading the program " + + "from stdin is not supported." + ) + val scriptPath = args[1] + runFileHoldingGIL(scriptPath, listOf(scriptPath) + args.drop(2)) + } + first.startsWith("-") -> throw UnsupportedOperationException( + "Python3.runApp refuses '$first': it is only meaningful before Py_Initialize() " + + "sets up the interpreter (a PyConfig field), and this interpreter is already " + + "running. Supported: -c , -m , a script path, or '--' followed " + + "by one. See ROADMAP §12/§14b item 10." + ) + else -> runFileHoldingGIL(first, listOf(first) + args.drop(1)) + } + } + + /** The cached `__pmp_run_toplevel__` helper compiled from [RUN_APP_SOURCE]. */ + private var runTopLevelCallable: NativePointer? = null + + /** The cached `__pmp_run_file__` helper compiled from [RUN_APP_SOURCE]. */ + private var runFileCallable: NativePointer? = null + + /** + * The Python side of [runApp]'s `-c` and script-path branches. + * + * Same reasoning as [RUN_MODULE_SOURCE]: every line here is a `try`/`finally` or an `except`, + * so writing it in Python is less code than re-deriving `PyErr_GivenExceptionMatches` against + * 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 + * (`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 — + * passing a `str` would have silently ignored one. + */ + private const val RUN_APP_SOURCE = """ +import sys as _pmp_sys + + +def __pmp_run_toplevel__(source, filename, argv): + _pmp_saved = _pmp_sys.argv if hasattr(_pmp_sys, "argv") else None + _pmp_sys.argv = list(argv) + try: + try: + _pmp_code = compile(source, filename, "exec") + exec(_pmp_code, _pmp_sys.modules["__main__"].__dict__) + except SystemExit as exc: + code = exc.code + if code is None: + return 0 + if isinstance(code, int): + return code + try: + _pmp_sys.stderr.write(str(code) + "\n") + _pmp_sys.stderr.flush() + except Exception: + pass + return 1 + return 0 + finally: + if _pmp_saved is None: + try: + del _pmp_sys.argv + except AttributeError: + pass + else: + _pmp_sys.argv = _pmp_saved + + +def __pmp_run_file__(path, argv): + with open(path, "rb") as _pmp_f: + _pmp_source = _pmp_f.read() + return __pmp_run_toplevel__(_pmp_source, path, argv) +""" + + /** + * Compiles [RUN_APP_SOURCE] once and caches both functions it defines, exactly as + * [runModuleHelperHoldingGIL] does for [RUN_MODULE_SOURCE]. + * + * The caller must hold the GIL. + */ + private fun runAppHelpersHoldingGIL(): Pair { + runTopLevelCallable?.let { topLevel -> runFileCallable?.let { file -> return topLevel to file } } + val code = Py_CompileString(RUN_APP_SOURCE, "", PY_FILE_INPUT) + ?: throw pyErrorOrGeneric("Failed to compile the runApp helper") + try { + val globals = PyDict_New() ?: throw pyErrorOrGeneric("Failed to allocate the runApp helper's globals") + try { + val evaluated = PyEval_EvalCode(code, globals, globals) + ?: throw pyErrorOrGeneric("Failed to define the runApp helper") + Py_DecRef(evaluated) + // PyDict_GetItemString returns a borrowed reference, so take one of our own -- twice. + val topLevelBorrowed = PyDict_GetItemString(globals, "__pmp_run_toplevel__") + ?: throw pyErrorOrGeneric("The runApp helper did not define __pmp_run_toplevel__") + Py_IncRef(topLevelBorrowed) + val fileBorrowed = PyDict_GetItemString(globals, "__pmp_run_file__") + ?: throw pyErrorOrGeneric("The runApp helper did not define __pmp_run_file__") + Py_IncRef(fileBorrowed) + runTopLevelCallable = topLevelBorrowed + runFileCallable = fileBorrowed + return topLevelBorrowed to fileBorrowed + } finally { + Py_DecRef(globals) + } + } finally { + Py_DecRef(code) + } + } + + /** Runs [runApp]'s `-c` branch: executes [source] (compiled under [filename]) in `__main__`. */ + private fun runTopLevelHoldingGIL(source: String, filename: String, argv: List): Int = withPython { + val (topLevel, _) = runAppHelpersHoldingGIL() + callRunAppHelperHoldingGIL(topLevel, listOf(source, filename), argv, "Python3.runApp(-c)") + } + + /** Runs [runApp]'s script-path branch: reads and executes [path] in `__main__`. */ + private fun runFileHoldingGIL(path: String, argv: List): Int = withPython { + val (_, file) = runAppHelpersHoldingGIL() + callRunAppHelperHoldingGIL(file, listOf(path), argv, "Python3.runApp('$path')") + } + + /** + * Builds a new Python list of strings out of [items]. Returns a new reference the caller must + * release with `Py_DecRef`. The caller must hold the GIL. + */ + private fun buildPyStringListHoldingGIL(items: List): NativePointer { + val list = PyList_New(0) ?: throw pyErrorOrGeneric("Failed to build a Python list") + try { + for (item in items) { + // PyUnicode_FromString: new reference. PyList_Append does *not* steal, so this is + // released either way. + val value = PyUnicode_FromString(item) + ?: throw pyErrorOrGeneric("Failed to convert argument '$item'") + try { + if (PyList_Append(list, value) != 0) { + throw pyErrorOrGeneric("Failed to append argument '$item'") + } + } finally { + Py_DecRef(value) + } + } + return list + } catch (e: Throwable) { + Py_DecRef(list) + throw e + } + } + + /** + * 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 + * only non-`SystemExit` failure mode is an escaped exception, which arrives here as + * `PyObject_CallObject` returning `null` with the indicator set. + * + * The caller must hold the GIL. + */ + private fun callRunAppHelperHoldingGIL( + helper: NativePointer, + leadingArgs: List, + argv: List, + label: String, + ): Int { + val argvList = buildPyStringListHoldingGIL(argv) + try { + // PyTuple_New: new reference. PyTuple_SetItem *steals* one reference per slot, and + // steals it even when it fails, so nothing set below is released again here. + val callArgs = PyTuple_New((leadingArgs.size + 1).toLong()) + ?: throw pyErrorOrGeneric("Failed to build the argument tuple for $label") + try { + for ((index, value) in leadingArgs.withIndex()) { + val item = PyUnicode_FromString(value) + ?: throw pyErrorOrGeneric("Failed to convert argument '$value' for $label") + if (PyTuple_SetItem(callArgs, index.toLong(), item) != 0) { + throw pyErrorOrGeneric("Failed to build the argument tuple for $label") + } + } + // The tuple takes a reference of its own; argvList is still ours to release below. + Py_IncRef(argvList) + if (PyTuple_SetItem(callArgs, leadingArgs.size.toLong(), argvList) != 0) { + throw pyErrorOrGeneric("Failed to build the argument tuple for $label") + } + + // PyObject_CallObject: new reference, or null with the indicator set. Anything the + // program raised other than SystemExit arrives here untouched, so the type and + // traceback are the program's own. + val result = PyObject_CallObject(helper, callArgs) + ?: throw pyErrorOrGeneric("$label failed") + try { + val status = PyLong_AsLongLong(result) + if (status == -1L && PyErr_Occurred() != null) { + throw pyErrorOrGeneric("Could not read the exit status of $label") + } + return status.toInt() + } finally { + Py_DecRef(result) + } + } finally { + Py_DecRef(callArgs) + } + } finally { + Py_DecRef(argvList) + } + } + + /** `Py_file_input`, the compiler-mode token for a sequence of statements (as opposed to a single expression). */ + private const val PY_FILE_INPUT: Int = 257 + + /** + * Outermost [withGIL] scopes between automatic checkpoints when they are enabled. + * + * Chosen against the measured cost of one checkpoint relative to one outermost `withGIL` + * scope: see `EvalCheckpointTest.testCheckpointCostAgainstTheAlternatives`. Amortised over + * this many scopes the checkpoint is a small fraction of the attach/detach it rides on, while + * still bounding how much the deferred-release queue can grow. + */ + private const val DEFAULT_AUTO_DRAIN_INTERVAL: Int = 32 + + + //////////////////////////////////////////////////////////////////////////////////////////// + // Eval-loop checkpoints (ROADMAP §9) + //////////////////////////////////////////////////////////////////////////////////////////// + + /** + * 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 + * + * - merges the free-threaded build's biased reference-counting queue + * (`_PY_EVAL_EXPLICIT_MERGE_BIT` → `_Py_brc_merge_refcounts`), + * - processes QSBR-deferred frees, + * - runs a scheduled cyclic collection (`_PY_GC_SCHEDULED_BIT`), + * - drains the pending-call queue and runs pending signal handlers. + * + * An embedder that drives CPython purely through the C API never executes a bytecode frame and + * therefore never reaches one. On a free-threaded build that is not merely untidy: a + * `Py_DecRef` issued from a thread that does not own the object cannot run `tp_dealloc` there, + * so the object is pushed onto its owner's queue and stays alive until the owner reaches a + * checkpoint. Every reference the cleaner gives back is in exactly that position, so a process + * that only ever calls `PyObject_Call` grows without bound while its reference counts stay + * perfectly correct. + * + * This is the cheapest way to reach a checkpoint: one call to a cached, empty, Python-level + * function. It is not `exec("pass")`, which recompiles a module on every call. + * + * The merge happens on the **calling** thread, for objects that thread owns. That is the whole + * 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 + * 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 + * `_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 + * `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 — + * 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 + * side effect the caller did not request, and is why [autoDrainInterval] defaults to off on + * the GIL build rather than always on. + * + * Safe to call at any time, including before initialisation and on a thread that has never + * touched Python; it returns without doing anything when there is nothing it may legally do. + */ + fun drainPendingReleases() { + if (!isInitialized) return + val tState = getThreadGILState() + // Already inside a checkpoint (or on a thread that forbids them, i.e. a cleaner). + if (tState.checkpointsSuppressed) return + tState.checkpointsSuppressed = true + try { + withGIL { reachEvalCheckpointHoldingGIL() } + } finally { + tState.checkpointsSuppressed = false + tState.checkpointCountdown = autoDrainInterval } } /** - * Run Python script as an application (Automatically initializes Python) + * How many outermost [withGIL] scopes a thread may enter between automatic checkpoints, or 0 + * to take none automatically. A checkpoint is additionally skipped when nothing has been + * 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 + * cleaner's `Py_DecRef` immediately, with no queue to drain, so that half of the checkpoint's + * job really is pure overhead there. + * + * **It still defaults to off on the GIL build**, but not because there is nothing left to + * reclaim: reference *cycles* are collected only by a scheduled cyclic collection, and that + * scheduling is read by the same eval-loop checkpoint on both builds (see + * [drainPendingReleases] and `docs/investigations/gc-scheduling-investigation.md` §1). An embedder that never + * runs Python bytecode accumulates cyclic garbage forever on the GIL build too. The default + * stays off there because turning it on is a behavioural change an embedder has to opt into + * knowingly, not a free one: a checkpoint can run `__del__`/weakref callbacks/pending calls at + * 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] + * and `PyGC_Collect` both work regardless of this setting. */ - fun runApp(argv: Array) { - //Py_BytesMain(argv) - // TODO: Add error handling + var autoDrainInterval: Int = if (BuildConfig.pythonFreeThreaded) DEFAULT_AUTO_DRAIN_INTERVAL else 0 + + /** Diagnostic counters for the checkpoint machinery. Test-visible. */ + internal object CheckpointCounter { + /** Checkpoints actually taken. */ + var reached: Long = 0 + /** Checkpoints declined because the caller had an unread error indicator. */ + var skipped: Long = 0 + /** Checkpoints that could not be taken, or that came back with an exception. */ + var failed: Long = 0 + } + + /** + * `ReleaseCounter.released` as of the last checkpoint. Read and written without + * synchronisation on purpose: a stale value can only cost one redundant checkpoint or delay + * one by an interval, and an `Int` does not tear. + */ + internal var lastCheckpointReleaseMark: Int = -1 + + /** The cached zero-argument Python-level function whose body is `pass`. */ + private var checkpointCallable: NativePointer? = null + + private const val CHECKPOINT_SOURCE = "def __pmp_eval_checkpoint__():\n pass\n" + + /** + * Compiles and evaluates [CHECKPOINT_SOURCE] once into a private globals dict and keeps a + * strong reference to the resulting function. + * + * A module-level `def` needs no `__builtins__` entry of its own: frame setup falls back to + * the interpreter's builtins when the globals mapping has none. + * + * The caller must hold the GIL. + */ + private fun checkpointCallableHoldingGIL(): NativePointer? { + checkpointCallable?.let { return it } + val code = Py_CompileString(CHECKPOINT_SOURCE, "", PY_FILE_INPUT) + ?: run { PyErr_Clear(); return null } + try { + val globals = PyDict_New() ?: run { PyErr_Clear(); return null } + try { + val evaluated = PyEval_EvalCode(code, globals, globals) + ?: run { PyErr_Clear(); return null } + Py_DecRef(evaluated) + // PyDict_GetItemString returns a borrowed reference, so take one of our own. + val borrowed = PyDict_GetItemString(globals, "__pmp_eval_checkpoint__") + ?: run { PyErr_Clear(); return null } + Py_IncRef(borrowed) + checkpointCallable = borrowed + return borrowed + } finally { + Py_DecRef(globals) + } + } finally { + Py_DecRef(code) + } + } + + /** Takes one checkpoint. The caller must hold the GIL and have suppressed re-entry. */ + 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 + // handler or a finaliser raising is enough. + if (PyErr_Occurred() != null) { + CheckpointCounter.skipped++ + return + } + val callable = checkpointCallableHoldingGIL() ?: run { + CheckpointCounter.failed++ + return + } + 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 + // 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() + CheckpointCounter.failed++ + } else { + Py_DecRef(result) + } + CheckpointCounter.reached++ } /** * Run Simple String + * + * Deliberately implemented on top of [PyRun_String] rather than + * `PyRun_SimpleString`: the latter internally calls `PyErr_Print()` on + * failure, which *prints and clears* the error indicator before we ever + * get a chance to inspect it, so [PyException.fromCurrentError] would + * always see nothing pending and every failure would be reported with a + * generic message instead of the real Python exception (type included). */ fun exec(command: String) { - return withPython { - if (PyRun_SimpleString(command) != 0) { - // TODO: Add error handling - throw IllegalStateException("Python exec failed") + withPython { + // PyImport_AddModuleRef: new/strong reference to the __main__ module. + val modulePointer = PyImport_AddModuleRef("__main__") + ?: throw pyErrorOrGeneric("Failed to access the __main__ module") + try { + // PyObject_GetAttrString: new reference to __main__'s globals dict. + val globalsPointer = PyObject_GetAttrString(modulePointer, "__dict__") + ?: throw pyErrorOrGeneric("Failed to access __main__.__dict__") + try { + val result = PyRun_String(command, PY_FILE_INPUT, globalsPointer, globalsPointer) + ?: throw pyErrorOrGeneric("Python exec failed") + // PyRun_String returns a new reference (usually None for + // statement-mode execution); we have no use for it here. + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(result) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(globalsPointer) } + } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(modulePointer) } } } } @@ -88,22 +943,32 @@ object Python3 { * Evaluate Python script */ fun eval(str: String, start: Int, globals: PyObject, locals: PyObject): PyObject { - withPython { + return withPython { + // PyRun_String: new reference on success, null + exception set on failure. val result = PyRun_String(str, start, globals.pointer, locals.pointer) - if (result == null) { - throw IllegalStateException("Python eval failed (result is null)") - } else { - // TODO: 레퍼런스 카운팅 - return PyObject(result, false) - } + ?: throw pyErrorOrGeneric("Python eval failed") + PyObject(result, false) } } /** * Import Python module + * + * Previously this pre-checked `name in sys.modules` and failed fast if it + * wasn't already present -- but `sys.modules` only holds modules that + * have *already* been imported, so any first-time import was rejected + * before [PyImport_ImportModule] (which does the actual importing) ever + * ran. Let CPython's own import machinery attempt the import and report + * failure through the error indicator instead. */ - fun import(): PyModule { - return PyModule() + fun import(name: String): PyModule { + return withPython { + // PyImport_ImportModule: new reference on success, null + exception + // (typically ModuleNotFoundError) set on failure. + val module: NativePointer = PyImport_ImportModule(name) + ?: throw pyErrorOrGeneric("Failed to import module '$name'") + PyModule(module, false) + } } val version by lazy { withPython { Py_GetVersion() ?: throw IllegalStateException("Failed to get Python version") } } diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/WrapperSupport.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/WrapperSupport.kt new file mode 100644 index 000000000..f4e5a5308 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/WrapperSupport.kt @@ -0,0 +1,214 @@ +package python.multiplatform.ffi + +import python.multiplatform.ffi.types.basic.PyNone +import python.native.ffi.NativePointer +import python.native.ffi.PyDict_GetItemString +import python.native.ffi.PyErr_Clear +import python.native.ffi.PyErr_Occurred +import python.native.ffi.PyEval_GetBuiltins +import python.native.ffi.PyLong_AsInt +import python.native.ffi.PyLong_FromLongLong +import python.native.ffi.PyObject_CallObject +import python.native.ffi.PyObject_GetAttrString +import python.native.ffi.PyTuple_New +import python.native.ffi.PyTuple_SetItem +import python.native.ffi.PyUnicode_AsUTF8 +import python.native.ffi.Py_DecRef +import python.native.ffi.Py_IncRef + +/** + * Shared plumbing for the wrapper types under `python.multiplatform.ffi.types`. + * + * Two jobs, both of which every wrapper would otherwise repeat: + * + * 1. **Reading a dunder attribute as a Kotlin value.** The Stable ABI has no + * struct-field accessors -- `PyFunction_GetCode`, `PyMethod_Function`, + * `PySlice_Unpack` and friends are all outside the limited API, and the + * struct layouts they read are deliberately opaque under `Py_LIMITED_API`. + * `PyObject_GetAttrString` is therefore the only supported route to + * `__name__`, `__func__`, `range.start`, `slice.stop` and the rest of it. + * Going through [PyObject.getAttr] would build a full wrapper (with a + * cleaner registration) plus a `PyObject_Str` crossing for a value that is + * immediately thrown away; these decode in place instead. + * + * 2. **Calling a builtin.** Several types (`slice`, `range`, ...) have no + * constructor function in this project's `EmbedAPI` surface, so they are + * built by calling the builtin of the same name. [callBuiltinStealing] + * concentrates the reference bookkeeping that this requires in one place. + * + * Every function here assumes nothing about the GIL: each acquires it via + * [Python3.withPython], which nests. + */ + +/** + * `self.`, decoded as UTF-8 text. + * + * Throws if the attribute is missing or is not a `str` -- use + * [attrAsStringOrNull] where either is a legitimate answer (`__doc__` is + * `None` on an undocumented object, for instance). + */ +internal fun PyObject.attrAsString(attr: String): String = Python3.withPython { + // PyObject_GetAttrString: new reference on success, null + AttributeError on failure. + val attrPointer = PyObject_GetAttrString(pointer, attr) + ?: throw pyErrorOrGeneric("Attribute '$attr' not found") + try { + // PyUnicode_AsUTF8 hands back a view of the str's own buffer -- nothing extra to release, + // but the str itself must outlive the decode, which is why the release is in the finally. + PyUnicode_AsUTF8(attrPointer) ?: throw pyErrorOrGeneric("Attribute '$attr' is not a str") + } finally { + Py_DecRef(attrPointer) + } +} + +/** + * `self.` as UTF-8 text, or `null` when the attribute is absent or is + * not a `str` (in particular when it is `None`). + * + * Both of those set CPython's error indicator, and this is the variant whose + * caller has opted out of exceptions, so the indicator is cleared rather than + * left pending -- see [PyObject.getAttrOrNull] for why leaving one set + * corrupts whatever call runs next. + */ +internal fun PyObject.attrAsStringOrNull(attr: String): String? = Python3.withPython { + val attrPointer = PyObject_GetAttrString(pointer, attr) ?: run { + PyErr_Clear() + return@withPython null + } + try { + val decoded = PyUnicode_AsUTF8(attrPointer) + if (decoded == null) PyErr_Clear() + decoded + } finally { + Py_DecRef(attrPointer) + } +} + +/** `self.` as an `Int`. Throws if the attribute is missing or is not an integer. */ +internal fun PyObject.attrAsInt(attr: String): Int = Python3.withPython { + val attrPointer = PyObject_GetAttrString(pointer, attr) + ?: throw pyErrorOrGeneric("Attribute '$attr' not found") + try { + val value = PyLong_AsInt(attrPointer) + // -1 is both a perfectly good value and the failure return; only the error indicator + // tells the two apart. + if (value == -1 && PyErr_Occurred() != null) { + throw pyErrorOrGeneric("Attribute '$attr' is not an int") + } + value + } finally { + Py_DecRef(attrPointer) + } +} + +/** + * `self.` as an `Int`, or `null` when the attribute is `None`. + * + * `slice.start` and friends are `None` rather than absent when omitted, so + * `None` is the expected answer here and maps to `null`; anything else that + * is not an integer is still an error. + */ +internal fun PyObject.attrAsIntOrNull(attr: String): Int? = Python3.withPython { + val attrPointer = PyObject_GetAttrString(pointer, attr) ?: run { + PyErr_Clear() + return@withPython null + } + try { + if (attrPointer == PyNone.get().pointer) return@withPython null + val value = PyLong_AsInt(attrPointer) + if (value == -1 && PyErr_Occurred() != null) { + throw pyErrorOrGeneric("Attribute '$attr' is neither an int nor None") + } + value + } finally { + Py_DecRef(attrPointer) + } +} + +/** + * Reads `self.` and hands the resulting **new reference** to [wrap], + * which adopts it (i.e. is expected to be a `PyXxx(pointer, borrowed = false)` + * construction). + * + * Deliberately *not* routed through [adoptingNewReference]: [wrap] builds a + * [python.multiplatform.ref.PyAutoCloseable], and that registers its cleaner + * in its own constructor, before any subclass initialiser runs. A wrapper + * that threw from `init` would therefore have already queued a release for + * this pointer, and releasing it here as well is exactly the double-free + * ROADMAP §1 is the account of. + */ +internal inline fun PyObject.attrAs(attr: String, wrap: (NativePointer) -> T): T { + val attrPointer = Python3.withPython { PyObject_GetAttrString(pointer, attr) } + ?: throw pyErrorOrGeneric("Attribute '$attr' not found") + return wrap(attrPointer) +} + +/** + * Calls `builtins.(*args)` and returns the **new reference** the call produced. + * + * [args] are **consumed**: each must be a new reference the caller owns, and + * this takes ownership of every one of them on all paths -- they are handed + * to the argument tuple, which steals them, and the tuple is released here. + * Written that way because each caller is building throwaway arguments for a + * single call and would otherwise have to unwind them itself across the four + * separate ways this can fail. + * + * The builtin is looked up in the builtins *namespace* rather than through + * `PyImport_ImportModule("builtins")` so that no module wrapper (and no + * cleaner registration) is created for what is a single dictionary lookup. + * + * [args] is a plain array rather than a `vararg` because Kotlin prohibits a + * value class as a `vararg` element type, and [NativePointer] is one. + */ +internal fun callBuiltinStealing(name: String, args: Array): NativePointer = Python3.withPython { + val argTuple = PyTuple_New(args.size.toLong()) + if (argTuple == null) { + for (arg in args) Py_DecRef(arg) + throw pyErrorOrGeneric("Failed to build the argument tuple for builtins.$name()") + } + try { + for (i in args.indices) { + // PyTuple_SetItem steals, and releases the value itself when it fails, so on failure + // only the arguments not yet handed over are still ours to release. + if (PyTuple_SetItem(argTuple, i.toLong(), args[i]) != 0) { + for (j in i + 1 until args.size) Py_DecRef(args[j]) + throw pyErrorOrGeneric("Failed to populate the argument tuple for builtins.$name()") + } + } + // PyEval_GetBuiltins and PyDict_GetItemString both return BORROWED references. + val builtins = PyEval_GetBuiltins() ?: throw pyErrorOrGeneric("The builtins namespace is unavailable") + val callable = PyDict_GetItemString(builtins, name) + ?: throw pyErrorOrGeneric("builtins.$name is not available") + PyObject_CallObject(callable, argTuple) ?: throw pyErrorOrGeneric("builtins.$name() failed") + } finally { + Py_DecRef(argTuple) + } +} + +/** + * Builds one new `int` reference per element of [values], mapping `null` to a + * new reference to `None`. + * + * The loop releases whatever it has already built if a later element fails: + * between creating a reference and handing it to [callBuiltinStealing] it + * belongs to no Kotlin object, so nothing else would ever release it. + */ +internal fun newIntsOrNone(vararg values: Int?): Array = Python3.withPython { + val built = ArrayList(values.size) + try { + for (value in values) { + built.add( + if (value == null) { + // PyNone.get() holds a borrowed-then-increfed reference of its own; the + // caller of this function needs one it can hand away, so take another. + PyNone.get().pointer.also { Py_IncRef(it) } + } else { + PyLong_FromLongLong(value.toLong()) ?: throw pyErrorOrGeneric("Failed to build a Python int") + } + ) + } + } catch (t: Throwable) { + for (pointer in built) Py_DecRef(pointer) + throw t + } + built.toTypedArray() +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/Context.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/Context.kt index 1345bc302..568b6e89a 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/Context.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/Context.kt @@ -1,4 +1,182 @@ package python.multiplatform.ffi.conversion -class Context { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyTypeChecks +import python.multiplatform.ffi.pyErrorOrGeneric +import python.multiplatform.ffi.types.basic.PyBool +import python.multiplatform.ffi.types.basic.PyByteArray +import python.multiplatform.ffi.types.basic.PyBytes +import python.multiplatform.ffi.types.basic.PyFloat +import python.multiplatform.ffi.types.basic.PyInt +import python.multiplatform.ffi.types.basic.PyNone +import python.multiplatform.ffi.types.basic.PyString +import python.multiplatform.ffi.types.collections.PyDict +import python.multiplatform.ffi.types.collections.PyFrozenSet +import python.multiplatform.ffi.types.collections.PyList +import python.multiplatform.ffi.types.collections.PySet +import python.multiplatform.ffi.types.collections.PyTuple +import python.multiplatform.ffi.types.collections.pyObjectToNative +import python.native.ffi.NativePointer +import python.native.ffi.PyObject_Type +import python.native.ffi.Py_DecRef + +/** + * Marker/behavioural contract for a conversion context: something that can + * take a live [PyObject] and hand back a Kotlin-side representation of it. + */ +interface Context { + /** Converts [obj] to whatever this context's active strategy dictates. */ + fun convertValue(obj: PyObject): Any? +} + +/** + * Default [Context] implementation. Bridges Python objects to Kotlin values + * according to the active [ConversionStrategy], caching conversions where + * the strategy allows it (see [PyValue]). + */ +class PyContext(private var strategy: ConversionStrategy = ConversionStrategy.DEFAULT) : Context { + + /** The [ConversionStrategy] this context currently applies. */ + val activeStrategy: ConversionStrategy get() = strategy + + /** + * Runs [block] with the conversion strategy temporarily switched to + * [strategy], restoring the previous strategy afterwards -- even if + * [block] throws. + */ + fun withContext(strategy: ConversionStrategy, block: () -> T): T { + val previous = this.strategy + this.strategy = strategy + try { + return block() + } finally { + this.strategy = previous + } + } + + /** + * Converts [obj] according to [activeStrategy], dispatching to + * [autoConvert], [proxyConvert] or [naiveConvert] -- plus two strategies + * ([ConversionStrategy.RAW] and [ConversionStrategy.NATIVE]) that are + * cheap/leaf enough to not warrant their own private helper (see the + * class doc on [ConversionStrategy] for what each variant means). + */ + override fun convertValue(obj: PyObject): Any? { + return when (strategy) { + ConversionStrategy.DEFAULT -> autoConvert(obj) + ConversionStrategy.TYPED -> proxyConvert(obj) + ConversionStrategy.UNMANAGED -> naiveConvert(obj) + // RAW is deliberately *not* routed through naiveConvert: per the + // design note on ConversionStrategy, RAW means "no wrapper object + // materialised at all" -- UNMANAGED is the variant that still + // wraps in a PyObject. Handing back the bare NativePointer here is + // what actually keeps the two strategies distinct. + ConversionStrategy.RAW -> obj.pointer + ConversionStrategy.NATIVE -> pyObjectToNative(obj) + } + } + + /** + * Picks the best strategy for [obj]'s Python type and converts + * accordingly ([ConversionStrategy.DEFAULT]). + * + * Per the design note on [ConversionStrategy], DEFAULT defers to TYPED as + * the sane general-purpose choice: a typed wrapper that converts to a + * native value lazily/on demand, rather than NATIVE's always-eager, + * fully-recursive walk (expensive, and not even always possible -- e.g. + * for a user-defined object with no native Kotlin counterpart) or RAW's + * total loss of type safety and refcount bookkeeping. + */ + private fun autoConvert(obj: PyObject): Any? { + return proxyConvert(obj) + } + + /** + * Wraps [obj] in a lazily-converting [PyValue] ([ConversionStrategy.TYPED]), + * first re-wrapping [obj] as the most specific [PyObject] subclass its + * Python-level type has a dedicated wrapper for (via [typedWrap]) so that + * subclass's own `cachedNativeValue` accessor is what eventually performs + * the native conversion. + * + * Whichever branch [typedWrap] takes, the [PyValue] ends up holding a + * reference of its own -- see the comment in the body, and [PyProxy]'s + * lifetime rule. + */ + private fun proxyConvert(obj: PyObject): PyValue { + val typed = typedWrap(obj) + // `typedWrap` returns a *fresh* wrapper (built `borrowed = true`, i.e. with its own + // incref) for the nine builtins it knows, and [obj] itself for everything else. On that + // second path the PyValue used to hold the caller's wrapper rather than a reference of + // its own -- measured as a missing +1 on `sys.getrefcount` in + // `PyValueLazyConversionTest.pyContextGivesEveryPyValueItsOwnReference` -- so closing that + // wrapper, which callers do, left the PyValue pointing at a released object. Taking an + // independent reference here is what makes PyProxy's stated rule ("cachedPyObjectValue + // must hold an owned reference, never a borrowed one") true for both branches instead of + // only the first. + return PyValue(if (typed === obj) PyObject(obj.pointer, borrowed = true) else typed) + } + + /** + * Passes [obj] through unchanged, still wrapped as a plain [PyObject] + * under Python's own refcount management -- no defensive copy, no typed + * re-wrap, no caching ([ConversionStrategy.UNMANAGED]). + */ + private fun naiveConvert(obj: PyObject): PyObject { + return obj + } + +} + +/** + * Re-wraps [obj] as the most specific [PyObject] subclass its Python-level + * type corresponds to (e.g. a Python `int` becomes a [PyInt]), matching + * what [python.multiplatform.ffi.types.collections.pyObjectToNative] does + * for [ConversionStrategy.NATIVE] one level up (recursively, all the way + * to native Kotlin values) but stopping one level short here, at the typed + * wrapper itself. Falls back to [obj] unchanged for any Python type this + * library has no dedicated wrapper for (arbitrary user-defined objects) -- + * TYPED cannot go further than that (see the [ConversionStrategy] doc). + * + * That fallback is *identity*, deliberately: returning the same instance is + * what lets a caller ask "does this type have a wrapper at all?" by comparing + * references, which is how [PyProxy] decides whether a native projection + * exists without keeping a second copy of the table below. + * + * Dispatches the same way [python.multiplatform.ffi.types.collections.pyObjectToNative] + * does (see its doc for the full rationale): `None` first, via the + * zero-FFI-call [PyNone.isNone]; everything else via a single + * `PyObject_Type` call compared against [PyTypeChecks]'s cached builtin + * type pointers, rather than [python.multiplatform.ffi.PyType.name] + * string dispatch (three FFI crossings and a UTF-8 decode per call, just + * to read a name well enough to compare). + * New reference ownership: [PyObject.pointer] is still owned by [obj] + * afterwards, so each typed wrapper below is built with `borrowed = true` + * to take its own independent, incref'd reference to the same pointer. + * + * Top-level and `internal` rather than private to [PyContext] because + * [PyProxy]'s lazy conversion needs exactly this dispatch too; duplicating it + * there would be the third copy of the same table. + */ +internal fun typedWrap(obj: PyObject): PyObject { + if (PyNone.isNone(obj)) return PyNone.get() + + val typePtr = python.multiplatform.ffi.Python3.withPython { PyObject_Type(obj.pointer) } ?: throw pyErrorOrGeneric("Failed to get the type of this object") + try { + return when (typePtr) { + PyTypeChecks.boolType -> PyBool(obj.pointer, true) + PyTypeChecks.intType -> PyInt(obj.pointer, true) + PyTypeChecks.floatType -> PyFloat(obj.pointer, true) + PyTypeChecks.strType -> PyString(obj.pointer, true) + PyTypeChecks.bytesType -> PyBytes(obj.pointer, true) + PyTypeChecks.bytearrayType -> PyByteArray(obj.pointer, true) + PyTypeChecks.listType -> PyList(obj.pointer, true) + PyTypeChecks.tupleType -> PyTuple(obj.pointer, true) + PyTypeChecks.dictType -> PyDict(obj.pointer, true) + PyTypeChecks.setType -> PySet(obj.pointer, true) + PyTypeChecks.frozensetType -> PyFrozenSet(obj.pointer, true) + else -> obj + } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(typePtr) } // PyObject_Type: new reference, only needed for the dispatch above + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/PyProxy.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/PyProxy.kt index ad565166c..2bd4f9590 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/PyProxy.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/PyProxy.kt @@ -1,25 +1,364 @@ package python.multiplatform.ffi.conversion import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.exceptions.errors.PyTypeError +import python.multiplatform.ffi.types.basic.PyNone +import python.multiplatform.ffi.types.basic.asPyObject +import python.multiplatform.ffi.types.collections.pyObjectToNative +import python.native.ffi.NativePointer +/** + * A two-sided view of one value: the live Python object, and its native + * Kotlin projection of type [T], each materialised on demand and cached. + * + * ## What "lazy" means here + * + * Laziness is about *when*, not about *how far*. Nothing is converted until + * [toKotlin]/[toKotlinOrNull] is called; once it is, the value is converted + * all the way down to native Kotlin types by + * [python.multiplatform.ffi.types.collections.pyObjectToNative] -- the same + * recursive walk [ConversionStrategy.NATIVE] performs eagerly. That is the + * whole difference between the two strategies: TYPED pays for the walk only + * if someone actually asks, and pays for it once. + * + * ## Values with no native Kotlin counterpart + * + * An arbitrary user-defined Python object (or any type this library has no + * dedicated wrapper for) cannot be projected onto a Kotlin value at all -- + * TYPED is as far as it goes, as [ConversionStrategy]'s doc says. Of the + * three defensible answers (throw / return the [PyObject] unchanged / return + * null) this interface offers the two honest ones and rejects the third: + * + * - [toKotlinOrNull] returns `null`. Nullability is already in the signature, + * so the caller is told about the possibility at the type level and can + * handle it. + * - [toKotlin] throws [PyTypeError]. It promises a [T] and cannot deliver + * one; failing loudly at the boundary keeps the blame at the conversion. + * - Returning the [PyObject] unchanged from [toKotlin] is *not* offered. `T` + * is erased on every target here, so the unchecked cast would succeed and + * the mismatch would only surface at some unrelated use site far from its + * cause -- exactly the failure mode that is hardest to debug. Callers who + * want the Python object already have it: [toPython] hands it back. + * + * Python's `None` is treated the same way by [toKotlin] (it has a Kotlin + * counterpart -- `null` -- but not a `T`-typed one), and is reported as + * `null` by [toKotlinOrNull]. + * + * ## Lifetime rule + * + * A cached native value and the [PyObject] it came from have *different* + * lifetimes, so the rule is: **nothing cached in [cachedNativeValue] may + * point into Python-owned memory.** It is enforced, not just stated: + * [isIndependentOfPythonMemory] decides, every store to [cachedNativeValue] + * on the conversion path goes through it, and a value it rejects is returned + * but re-converted on the next call instead of kept. The full per-type table + * -- including the types that are refused outright, and why -- is in + * `docs/design/object-lifetime.md`. Case by case, for everything [pyObjectToNative] + * can produce: + * + * - `int`/`float`/`bool` -> Kotlin `Long`/`Double`/`Boolean`. + * `PyLong_AsLongLong` and friends copy a scalar out of the object; the + * result is a plain Kotlin value that shares nothing with CPython. Safe to + * cache, and safe to outlive its source. + * - `str` -> Kotlin `String`. `PyUnicode_AsUTF8` returns a `const char*` + * *owned by CPython*, valid only while the `str` object lives. That + * pointer is never cached here and never escapes: each platform binding + * decodes it into a Kotlin `String` at the boundary (`toKString()` on + * Kotlin/Native, an equivalent copy through Panama on desktop), and the + * copy is independent. Cache the `String`; never the pointer. + * - `list`/`tuple`/`dict`/`set`/`frozenset` -> Kotlin `List`/`Map`/`Set`. + * [pyObjectToNative] recurses, so every element is itself already one of + * the independent cases above -- no element holds a `PyObject`, a + * `NativePointer` or a borrowed C pointer. The container is therefore + * independent too. It is, however, a **snapshot**: mutating the Python + * container afterwards does not update it. Call [invalidateNativeCache] to + * force the next read to re-convert. + * - `bytes`/`bytearray` -> Kotlin `ByteArray`. These used to be refused + * alongside `memoryview`, on the grounds that their native form is a pointer + * into the object's own buffer (`PyBytes_AsStringAndSize` and the buffer + * protocol hand out exactly that) and that a `bytearray`'s buffer moves when + * it is resized. That objection is about the *pointer*: a `ByteArray` is a + * copy by construction, exactly as the decoded `String` of a `str` is, so it + * satisfies the same rule. The read therefore never touches a buffer pointer + * -- it goes through `hex()`; see + * [python.multiplatform.ffi.types.basic.PyBytes] for why, and for what that + * costs. `bytes` is immutable so its cached copy can never go stale; a + * `bytearray`'s is a **snapshot**, like a container's, with + * [invalidateNativeCache] as the way to re-read it. + * - `memoryview` -> **refused**, and the copy argument above does not rescue + * it: a memoryview carries a format, a shape and strides, so there is no one + * flat `ByteArray` that is its value, and a non-contiguous view cannot + * produce one at all. No wrapper converts it, so it takes the branch below + * rather than acquiring a cache of a shape nobody chose. + * - anything else (a user-defined class, a subclass of a builtin, `complex`, + * ...) -> nothing is cached; [toKotlinOrNull] returns `null` and [toKotlin] + * throws. + * + * The other direction has the mirror-image rule: [cachedPyObjectValue] must + * hold an *owned* reference, never a borrowed one, because the proxy can + * outlive whatever handed the object over. Everything that populates it does + * so: [PyContext]'s `proxyConvert` hands over either a `typedWrap` wrapper + * (built `borrowed = true`, which increfs) or, for a type with no dedicated + * wrapper, a fresh wrapper it increfs for exactly this reason; and + * [toPython]'s native-to-Python path builds new objects whose sole reference + * the returned wrapper owns. + * + * A [PyValue] a caller constructs directly is the caller's own business, with + * one exception the rule can rule out on sight: a bare + * [python.native.ffi.NativePointer] as the initial native value is rejected by + * [PyValue]'s constructor. + */ interface PyProxy { + /** + * The cached native projection, or `null` if it has not been computed. + * + * Implementors that can compute their value in one FFI call (e.g. + * [python.multiplatform.ffi.types.basic.PyInt]) override this as a + * computing getter and never store anything; for those, "the cache" is + * always warm and the default [toKotlin] below returns on its first line. + */ var cachedNativeValue: T? + + /** The cached Python side. See the lifetime rule in the class doc: this must be an owned reference. */ var cachedPyObjectValue: PyObject? + /** + * The live Python object this proxy converts from, if there is one. + * + * A proxy is either a wrapper that *is* a [PyObject] (`PyInt`, `PyList`, + * ... -- note their [cachedPyObjectValue] field starts out `null`, so the + * `this` branch is the one that answers for them) or a separate holder + * such as [PyValue] that points at one. Only a [PyValue] built from a + * Kotlin value alone has neither. + */ + fun sourcePyObject(): PyObject? = cachedPyObjectValue ?: (this as? PyObject) + + /** + * The native Kotlin projection of this value, or `null` when there is + * none: Python `None`, or a Python type with no native Kotlin + * counterpart (see the class doc). + * + * Converts on first call and caches the result; later calls make no FFI + * call at all. `None` is the one case that is re-derived every time -- + * `null` is indistinguishable from "not yet computed" in the cache field + * -- which costs one pointer comparison and no FFI crossing. + */ + @Suppress("UNCHECKED_CAST") + fun toKotlinOrNull(): T? { + cachedNativeValue?.let { return it } + + val source = sourcePyObject() ?: return null + + // If the source is one of this library's typed wrappers, its own + // accessor is the cheapest route -- PyInt/PyFloat/PyBool/PyString + // convert in a single FFI call there, which is exactly what + // PyContext.typedWrap re-wraps for. Collection wrappers store a plain + // null here instead, so they fall through to the generic walk below. + if (source !== this && source is PyProxy<*>) { + (source.cachedNativeValue as T?)?.let { + if (isIndependentOfPythonMemory(it)) cachedNativeValue = it + return it + } + } + + // None has a Kotlin counterpart (null) but no non-null one. Checked + // first, and by pointer identity, so it costs no FFI call. + if (PyNone.isNone(source)) return null + + if (!hasNativeCounterpart(source)) return null + + val converted = pyObjectToNative(source) as T? + // Cached only if the lifetime rule allows it. Every branch pyObjectToNative can reach + // today produces an independent Kotlin value, so the guard passes for all of them -- it + // is here so that a branch added later cannot acquire a cache it is not entitled to + // without someone deciding that it should. A value that fails the guard is still + // returned; it is simply re-converted on the next call rather than kept. + if (isIndependentOfPythonMemory(converted)) cachedNativeValue = converted + return converted + } + + /** + * The native Kotlin projection of this value. + * + * @throws PyTypeError if this value has no native Kotlin counterpart, or + * is Python's `None` (whose counterpart, `null`, is not a `T`). Use + * [toKotlinOrNull] when either is expected. + */ fun toKotlin(): T { - if (cachedNativeValue == null) { - //cachedNativeValue = toKotlinInternal() - // TODO: Implement the conversion logic to Kotlin type T + val value = toKotlinOrNull() + if (value != null) return value + + val source = sourcePyObject() + ?: throw PyTypeError("This proxy holds neither a native value nor a Python object to convert from") + throw if (PyNone.isNone(source)) { + PyTypeError("Python None has no non-null Kotlin counterpart; use toKotlinOrNull()", value = source) + } else { + PyTypeError( + "No native Kotlin counterpart for Python type '${source.Type.name}'; " + + "TYPED conversion stops at the PyObject itself -- use toPython()", + value = source, + ) } - return cachedNativeValue!! } + /** + * The Python side of this value, materialising it from the cached native + * value if this proxy was built from a Kotlin value alone. + * + * Only the scalar cases and `ByteArray` are materialised, because those + * have exactly one unambiguous Python spelling and an existing constructor + * to build it with. A Kotlin `List`/`Map`/`Set` does not: whether it should become a + * `list` or a `tuple`, a `set` or a `frozenset`, and who then owns the + * references to the converted elements, are decisions that belong to the + * collection wrappers (`PyList.fromList` and friends), not to a generic + * proxy. Those raise [PyTypeError] here rather than guessing. + */ fun toPython(): PyObject { - if (cachedPyObjectValue == null) { - //cachedPyObjectValue = toPythonInternal() - // TODO: Implement the conversion logic to PyObject + cachedPyObjectValue?.let { return it } + + // Wrappers that are themselves PyObjects are their own Python side. + (this as? PyObject)?.let { + cachedPyObjectValue = it + return it + } + + val built = nativeToPyObject(cachedNativeValue) + // Owned, not borrowed: every constructor below takes ownership of a + // brand-new reference (or, for None, of the immortal singleton), so + // the proxy can outlive its caller safely. + cachedPyObjectValue = built + return built + } + + /** + * Drops the cached native value so the next [toKotlin]/[toKotlinOrNull] + * re-converts from Python. + * + * The reason this exists is the snapshot rule in the class doc: a cached + * `List`/`Map`/`Set` is independent of Python-owned memory precisely + * *because* it is a copy, which also means it stops tracking a container + * that is still being mutated on the Python side. Implementors with a + * computing [cachedNativeValue] accessor never cache anything, so this is + * a no-op for them. + */ + fun invalidateNativeCache() { + cachedNativeValue = null + } +} + +/** + * Whether [value] may be kept in [PyProxy.cachedNativeValue] -- i.e. whether it + * would still be valid after the [PyObject] it was converted from is released. + * + * This is the class doc's lifetime rule written as code rather than prose, so + * that a conversion added later cannot quietly acquire a cache it is not + * entitled to. The full per-type table, and what each branch of + * [pyObjectToNative] actually returns, is in `docs/design/object-lifetime.md`. + * + * `true` for the values that walk copies out of CPython -- `Long`, `Double`, + * `Boolean`, `String`, and containers built recursively out of those. `false` + * for everything else, and the two cases that matter are: + * + * - a [python.native.ffi.NativePointer]. `ConversionStrategy.RAW` hands one + * back deliberately: no wrapper, no incref, nothing that releases it. It is + * an address, not a reference, so it stops being valid at a moment nothing + * here can observe. + * - a [PyObject]. This one *is* a reference and would not dangle, but it is + * not a native value either -- caching it would mean [PyProxy.toKotlin] + * handing back the Python side under a `T` it cannot honour. + */ +internal fun isIndependentOfPythonMemory(value: Any?): Boolean = when (value) { + null, is Long, is Int, is Short, is Byte, is Double, is Float, is Boolean, is Char, is String -> true + // A Kotlin array is a copy by construction; it cannot alias a C buffer. + is ByteArray -> true + is List<*> -> value.all { isIndependentOfPythonMemory(it) } + is Set<*> -> value.all { isIndependentOfPythonMemory(it) } + is Map<*, *> -> value.all { isIndependentOfPythonMemory(it.key) && isIndependentOfPythonMemory(it.value) } + else -> false +} + +/** + * Whether [obj]'s Python type has a native Kotlin counterpart at all. + * + * Answered by [typedWrap] rather than by a table of its own: `typedWrap` + * already *is* the list of types this library has a dedicated wrapper for, + * and returns [obj] unchanged for everything else, so asking it keeps the two + * from drifting apart when a wrapper is added. The wrapper it builds is + * thrown away immediately -- it exists only to answer the question -- and is + * closed rather than left to the cleaner, so the incref `typedWrap` performed + * is undone at once instead of at some arbitrary later GC. + * + * Cost: one extra `PyObject_Type` crossing plus an incref/decref pair, paid + * once per proxy (the result is then cached), and only for a source that is + * not already a typed wrapper. + */ +private fun hasNativeCounterpart(obj: PyObject): Boolean { + if (obj is PyProxy<*>) return true // already a typed wrapper: it exists precisely because it has one + val typed = typedWrap(obj) + if (typed === obj) return false + typed.close() + return true +} + +/** + * Builds a Python object for a native Kotlin scalar. See [PyProxy.toPython] + * for why containers are deliberately not handled here. + */ +private fun nativeToPyObject(value: Any?): PyObject = when (value) { + null -> PyNone.get() + is Boolean -> value.asPyObject() + is Long -> value.asPyObject() + is Int -> value.asPyObject() + is Short -> value.toLong().asPyObject() + is Byte -> value.toLong().asPyObject() + is Double -> value.asPyObject() + is Float -> value.toDouble().asPyObject() + is String -> value.asPyObject() + // A ByteArray has exactly one Python spelling, `bytes` -- unlike a List/Map/Set, whose + // ambiguity is why those are refused here. `bytearray` is the mutable spelling and would be a + // guess; a caller who wants one can build it from the `bytes` this returns. + is ByteArray -> value.asPyObject() + else -> throw PyTypeError("No unambiguous Python counterpart for a cached value of type '${value::class.simpleName}'") +} + +/** + * Concrete [PyProxy] holding both sides of a conversion: the originating + * [PyObject] and, once computed, its native Kotlin projection of type [T]. + * + * This is the `PyValue` from the design sketch -- [asNative]/[asPyObject] + * are thin aliases over [toKotlin]/[toPython] using the sketch's naming. + * + * At least one side must be supplied. A [PyValue] with neither would have + * nothing to convert from in either direction, and would only be able to + * report that fact from whichever accessor happened to be called first. + */ +class PyValue( + pyObj: PyObject? = null, + initialNativeValue: T? = null, +) : PyProxy { + init { + require(pyObj != null || initialNativeValue != null) { + "A PyValue needs a source PyObject, a native value, or both -- it cannot convert from nothing" + } + // The one caller-supplied shape the lifetime rule can rule out on sight. Everything else + // handed in here is the caller's own value; a bare pointer is not -- it is what + // ConversionStrategy.RAW returns, owned by nobody, and storing it would produce exactly + // the failure this class's rule exists to prevent, at a point where nothing can detect it. + require(initialNativeValue !is NativePointer) { + "A bare NativePointer (what ConversionStrategy.RAW returns) is an address, not a reference: " + + "nothing increfs it and nothing releases it, so it cannot be cached as a native value. " + + "Pass the PyObject instead, or convert first." } - return cachedPyObjectValue!! } + + override var cachedNativeValue: T? = initialNativeValue + override var cachedPyObjectValue: PyObject? = pyObj + + fun asNative(): T = toKotlin() + + /** [asNative]'s null-returning form; see [toKotlinOrNull]. */ + fun asNativeOrNull(): T? = toKotlinOrNull() + + fun asPyObject(): PyObject = toPython() } diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/Strategy.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/Strategy.kt index e0533e9d1..6b845ba18 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/Strategy.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/conversion/Strategy.kt @@ -1,29 +1,34 @@ package python.multiplatform.ffi.conversion - /** - * TODO: 영어로 다시 작성 - * - * 기본 자료형 - * - RAW 원시 포인터로 보관 가능 - * - TYPED 타입을 가지고, 필요 시 네이티브로 변환하고 그 값 캐싱 - * - NATIVE 코틀린 자료형으로 변환 가능 - * - * 콜렉션 자료형, 함수 등 - * - RAW 원시 포인터로 보관 가능 - * - TYPED 타입을 가지고, 필요 시 네이티브로 변환하고 그 값 캐싱 - * - NATIVE 코틀린 자료형으로 변환 가능 + * Controls how much a [PyObject][python.multiplatform.ffi.PyObject] gets + * converted towards a native Kotlin value when it crosses the Kotlin/Python + * boundary, trading off safety/fidelity against overhead. * - * 코틀린으로 변환 불가능한 자료형 - * - RAW 원시 포인터로 보관 가능 - * - TYPED 타입을 가지고, 필요 시 네이티브로 변환하고 그 값 캐싱 - * - * 사용자 정의 오브젝트 자료형 - * - 원시 포인터로만 보관 가능 - * - * ** 컨택스트가 2개가 필요할 듯 하다 + * Roughly, from cheapest/most-Python-native to most-eager/most-Kotlin-native: * + * - [RAW]: keep only the raw `NativePointer`; no wrapper object is + * materialised at all. Cheapest, but loses type safety and refcount + * bookkeeping convenience. + * - [UNMANAGED]: wrap in a [python.multiplatform.ffi.PyObject] (or the + * appropriate subclass) but leave it fully under Python's own refcount + * management -- no defensive copy, no caching of a converted value. + * - [TYPED]: wrap in the appropriate typed [PyObject] subclass (e.g. + * [python.multiplatform.ffi.types.basic.PyInt]) and lazily convert to a + * native value on demand, caching the result (see + * [PyProxy.cachedNativeValue]). + * - [NATIVE]: eagerly convert all the way down to native Kotlin + * types/collections (e.g. a Python `dict` becomes a Kotlin `Map` + * recursively). Most convenient, most expensive, and only possible for + * Python values that have a native Kotlin counterpart at all -- custom + * user-defined Python objects cannot go further than [TYPED]. + * - [DEFAULT]: defers to whatever [PyContext] considers the sane default + * (currently [TYPED]). */ -class Strategy { - +enum class ConversionStrategy { + DEFAULT, + UNMANAGED, + RAW, + TYPED, + NATIVE, } diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/PyException.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/PyException.kt index 90e392dac..dbaa5a149 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/PyException.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/PyException.kt @@ -1,16 +1,207 @@ package python.multiplatform.ffi.exceptions -class PyException { - -// -// -// fun checkPyError(): Boolean { -// if (PyErr_Occurred() != null) { -// PyErr_Clear(); -// return true -// } -// return false -// } -// - -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType +import python.multiplatform.ffi.adoptingNewReference +import python.native.ffi.NativePointer +import python.native.ffi.PyErr_Clear +import python.native.ffi.PyErr_GetRaisedException +import python.native.ffi.PyErr_Occurred +import python.native.ffi.PyErr_SetRaisedException +import python.native.ffi.PyErr_SetString +import python.native.ffi.PyException_GetCause +import python.native.ffi.PyException_GetContext +import python.native.ffi.PyException_GetTraceback +import python.native.ffi.PyObject_Str +import python.native.ffi.PyObject_Type +import python.native.ffi.PyType_GetName +import python.native.ffi.PyUnicode_AsUTF8 +import python.native.ffi.Py_DecRef +import python.native.ffi.Py_IncRef + +/** + * `True` if [this] points at Python's `None` singleton. There is no direct + * `Py_None` accessor exposed through this project's FFI layer, so this is + * approximated by comparing `type(x).__name__` to `"NoneType"` -- sufficient + * here because the only place this is used ([PyException_GetCause] results) + * only ever hands back either a real exception instance or `None`. + * + * `internal` (module-wide) rather than `private` because it's shared between + * this file and [python.multiplatform.ffi.exceptions.PyTraceback], which is + * a different file in the same package. + */ +internal fun NativePointer.isNoneObject(): Boolean { + val typePointer = python.multiplatform.ffi.Python3.withPython { PyObject_Type(this) } ?: run { + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + return false + } + // Two new references, neither owned by any wrapper, so both releases go in a finally + // rather than after the decode. + try { + val namePointer = python.multiplatform.ffi.Python3.withPython { PyType_GetName(typePointer) } ?: return false + val name = try { + python.multiplatform.ffi.Python3.withPython { PyUnicode_AsUTF8(namePointer) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(namePointer) } + } + return name == "NoneType" + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(typePointer) } + } +} + +/** + * Kotlin-side mirror of a Python exception. + * + * Historically this only carried a plain message (used throughout + * [python.multiplatform.ffi.PyObject] / [python.multiplatform.ffi.PyType] for + * FFI-level failures where no live Python error is involved). It now also + * exposes the pieces of `python.multiplatform.ffi.Python3.withPython { PyErr_Fetch() }` / `PyErr_GetRaisedException()` + * (type, value, traceback) so a genuine Python-level error can be surfaced + * as a fully-populated Kotlin throwable, matching the mermaid sketch + * (`type`, `value`, `traceback`, `message`, `cause`, `context`). + * + * [errMsg] stays the primary constructor parameter (and thus [message]) so + * every existing call site (`PyException("...")`) keeps compiling unchanged. + */ +open class PyException( + val errMsg: String, + val type: PyType? = null, + val value: PyObject? = null, + val traceback: PyTraceback? = null, + /** Python's `__context__`: the exception that was being handled when this one was raised, if any. */ + val context: PyException? = null, + cause: PyException? = null, +) : Exception(errMsg, cause) { + + companion object { + /** + * Builds a [PyException] from whatever CPython's error indicator + * currently holds (via `python.multiplatform.ffi.Python3.withPython { PyErr_GetRaisedException() }`), clearing the + * indicator in the process. Returns `null` if no error is currently + * set. + * + * This is the one central bridge from "a C API call returned a null + * that means an exception is pending" to a proper Kotlin throwable; + * every other class in this module (`PyObject`, `PyType`, `Python3`) + * routes its null-return error paths through here (via the + * `pyErrorOrGeneric` helper in `PyObject.kt`) rather than raising a + * bare, type-less [PyException]. + */ + fun fromCurrentError(): PyException? { + // python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } is a *borrowed* reference used purely as a + // presence check here -- it must not be decref'd. + if (python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } == null) return null + + // python.multiplatform.ffi.Python3.withPython { PyErr_GetRaisedException() } hands back a new reference to the + // exception *instance* and atomically clears the indicator. In + // CPython >= 3.12 there is no separate "type"/"traceback" triple + // to fetch -- the instance itself carries its type + // (PyObject_Type) and traceback (PyException_GetTraceback). + val excPointer = python.multiplatform.ffi.Python3.withPython { PyErr_GetRaisedException() } ?: return null + return fromExceptionInstance(excPointer) + } + + /** + * Builds a [PyException] from [excPointer], an *owned* (new or + * already-incref'd) reference to a live `BaseException` instance -- + * either the exception just pulled off the error indicator, or one + * reached transitively via `__context__`/`__cause__`. + * + * Ownership of [excPointer] is transferred to the returned + * [PyException.value] (a plain [PyObject] wrapping it with + * `borrowed = false`, i.e. no extra incRef); the caller must not + * touch [excPointer] again after calling this. + */ + private fun fromExceptionInstance(excPointer: NativePointer): PyException { + // First, before anything that can throw. PyObject(_, borrowed = false) takes + // ownership of excPointer -- no extra incRef, matching the "new reference" we were + // handed -- and from here on a cleaner can reclaim it. Doing this last (as it used + // to be) meant a failure anywhere below stranded the exception instance itself, in + // the one code path that exists to report failures. + val value = PyObject(excPointer, false) + + // New reference; PyType.getInstance() settles it either way -- adopting it on a + // cache miss, releasing it on a cache hit. + val typePointer = python.multiplatform.ffi.Python3.withPython { PyObject_Type(excPointer) } + if (typePointer == null) python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + val type = typePointer?.adoptingNewReference { PyType.getInstance(it) } + + val message = messageOf(excPointer) + + // New reference, or null if this exception was never associated + // with a traceback (e.g. constructed but never raised/propagated). + val tracebackPointer = python.multiplatform.ffi.Python3.withPython { PyException_GetTraceback(excPointer) } + val traceback = tracebackPointer?.let { PyTraceback(it, false) } + + // New reference; python.multiplatform.ffi.Python3.withPython { PyException_GetContext() } itself returns null + // when there genuinely is no context (unlike GetCause, see below). + val contextPointer = python.multiplatform.ffi.Python3.withPython { PyException_GetContext(excPointer) } + val context = contextPointer?.let { fromExceptionInstance(it) } + + // New reference; unlike GetContext, python.multiplatform.ffi.Python3.withPython { PyException_GetCause() } always + // returns *something* -- either a real exception instance, or the + // `None` singleton when `raise ... from ...` was never used. Only + // recurse for the former; release (and drop) the latter. + val causePointer = python.multiplatform.ffi.Python3.withPython { PyException_GetCause(excPointer) } + val cause = when { + causePointer == null -> null + causePointer.isNoneObject() -> { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(causePointer) } + null + } + else -> fromExceptionInstance(causePointer) + } + + return PyException(message, type, value, traceback, context, cause) + } + + /** `str(excPointer)`, used as this [PyException]'s [errMsg]. */ + private fun messageOf(excPointer: NativePointer): String { + // PyObject_Str: new reference on success. This is already deep + // inside error-reporting machinery, so on failure (e.g. a buggy + // `__str__` on a custom exception) fall back to a placeholder + // rather than letting a second exception escape from here -- + // but still clear the indicator it would otherwise leave set, + // per the "never call back into the C API with a pending + // exception" contract (see the *OrNull helpers on PyObject). + val strPointer = python.multiplatform.ffi.Python3.withPython { PyObject_Str(excPointer) } ?: run { + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + return "" + } + val message = try { + python.multiplatform.ffi.Python3.withPython { PyUnicode_AsUTF8(strPointer) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(strPointer) } + } + return message ?: "" + } + } + + /** Re-raises this exception into the current Python error indicator (`PyErr_Restore`/`PyErr_SetObject`). */ + fun restore() { + val liveValue = value + val liveType = type + when { + liveValue != null -> { + // python.multiplatform.ffi.Python3.withPython { PyErr_SetRaisedException() } *steals* a reference to its + // argument. `value` still owns its own reference for the rest + // of its lifetime (released by PyObject.clean()/decRef on + // close), so the interpreter needs a fresh one, not that one. + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_IncRef(liveValue.pointer) } + python.multiplatform.ffi.Python3.withPython { PyErr_SetRaisedException(liveValue.pointer) } + } + liveType != null -> { + // No live exception instance to hand back (e.g. this + // PyException was constructed by hand as + // `PyException(message, type = someType)`); synthesize one + // from the message instead. PyErr_SetString does not steal + // `liveType.pointer` -- CPython increfs the type internally. + python.multiplatform.ffi.Python3.withPython { PyErr_SetString(liveType.pointer, errMsg) } + } + // else: nothing Python-level to restore (a purely Kotlin-side + // PyException with no captured type/value) -- there is no + // meaningful error indicator to set. + } + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/PyTraceback.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/PyTraceback.kt index 0c7155891..edd876cb7 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/PyTraceback.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/PyTraceback.kt @@ -1,4 +1,98 @@ package python.multiplatform.ffi.exceptions -class PyTraceback { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.pyErrorOrGeneric +import python.native.ffi.NativePointer +import python.native.ffi.PyErr_Clear +import python.native.ffi.PyLong_AsInt +import python.native.ffi.PyObject_GetAttrString +import python.native.ffi.PyUnicode_AsUTF8 +import python.native.ffi.Py_DecRef + +/** + * Wraps a Python traceback object (`PyTracebackObject`, i.e. what + * `PyException_GetTraceback` returns). Tracebacks form a singly linked list + * from the outermost frame to the one where the exception was raised. + * + * `PyFrame` is explicitly out of scope for this pass (see task notes), so + * [frame] is intentionally omitted; only the bits needed to render a + * human-readable trace are exposed. There is no dedicated traceback/frame/code + * FFI surface in this project (no `tb_lineno`/`tb_next`/`tb_frame` bindings), + * so these read the well-known Python-level attributes directly + * (`tb_lineno`, `tb_next`, `tb_frame.f_code.co_filename`) via the generic + * `PyObject_GetAttrString`, each hop releasing its own new reference once the + * value has been extracted. + */ +class PyTraceback(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed) { + + /** The line number that was executing in this frame when the exception propagated through it. */ + val lineNumber: Int + get() { + val linenoPointer = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(pointer, "tb_lineno") } + ?: throw pyErrorOrGeneric("Failed to read tb_lineno") + val value = python.multiplatform.ffi.Python3.withPython { PyLong_AsInt(linenoPointer) } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(linenoPointer) } + return value + } + + /** Name of the source file for this frame, if known. */ + val fileName: String? + get() { + // This getter reports "unknown" as `null` rather than throwing, + // so every early-return below must also clear whatever error + // indicator the failed lookup left set (see the *OrNull helpers + // on PyObject for the same "don't call back into the C API with + // a pending exception" contract). + val framePointer = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(pointer, "tb_frame") } ?: run { + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + return null + } + val codePointer = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(framePointer, "f_code") } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(framePointer) } + if (codePointer == null) { + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + return null + } + + val filenamePointer = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(codePointer, "co_filename") } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(codePointer) } + if (filenamePointer == null) { + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + return null + } + + val name = python.multiplatform.ffi.Python3.withPython { PyUnicode_AsUTF8(filenamePointer) } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(filenamePointer) } + return name + } + + /** The next traceback entry (one frame further down the call stack), or `null` at the end of the chain. */ + val next: PyTraceback? + get() { + // tb_next is either another traceback object or None at the end + // of the chain; PyObject_GetAttrString hands back a new reference + // either way, which the returned PyTraceback (borrowed = false) + // then owns. + val nextPointer = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(pointer, "tb_next") } ?: run { + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + return null + } + if (nextPointer.isNoneObject()) { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(nextPointer) } + return null + } + return PyTraceback(nextPointer, false) + } + + /** Renders this traceback (and the chain reachable via [next]) the way Python's `traceback` module would. */ + fun format(): String { + val builder = StringBuilder("Traceback (most recent call last):\n") + var current: PyTraceback? = this + while (current != null) { + val location = current.fileName ?: "" + builder.append(" File \"").append(location).append("\", line ").append(current.lineNumber).append('\n') + current = current.next + } + return builder.toString() + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/errors/PyTypeError.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/errors/PyTypeError.kt index 9bcee15c4..bbfc5ae54 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/errors/PyTypeError.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/errors/PyTypeError.kt @@ -1,4 +1,26 @@ package python.multiplatform.ffi.exceptions.errors -class PyTypeError { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.exceptions.PyException +import python.multiplatform.ffi.exceptions.PyTraceback + +/** + * Kotlin-side mirror of Python's built-in `TypeError`. + * + * `type` is left `null` at construction time -- resolving the actual + * `PyType` for `TypeError` requires a live interpreter (it must be looked + * up from `builtins`), which this constructor deliberately avoids doing + * eagerly. Prefer [PyException.fromCurrentError] when the exception is + * being built from a real Python error indicator, which can populate + * [PyException.type] properly. + */ +class PyTypeError( + errMsg: String, + value: PyObject? = null, + traceback: PyTraceback? = null, +) : PyException(errMsg, type = null, value = value, traceback = traceback) { + companion object { + /** The Python type name this Kotlin class mirrors. */ + const val PYTHON_TYPE_NAME: String = "TypeError" + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/errors/PyValueError.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/errors/PyValueError.kt index a26401ea7..03675d00b 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/errors/PyValueError.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/exceptions/errors/PyValueError.kt @@ -1,4 +1,20 @@ package python.multiplatform.ffi.exceptions.errors -class PyValueError { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.exceptions.PyException +import python.multiplatform.ffi.exceptions.PyTraceback + +/** + * Kotlin-side mirror of Python's built-in `ValueError`. + * See [PyTypeError] for why [PyException.type] is left unresolved here. + */ +class PyValueError( + errMsg: String, + value: PyObject? = null, + traceback: PyTraceback? = null, +) : PyException(errMsg, type = null, value = value, traceback = traceback) { + companion object { + /** The Python type name this Kotlin class mirrors. */ + const val PYTHON_TYPE_NAME: String = "ValueError" + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonCallables.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonCallables.kt new file mode 100644 index 000000000..5304789c4 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonCallables.kt @@ -0,0 +1,656 @@ +package python.multiplatform.ffi.pythonx + +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.types.basic.PyBool +import python.multiplatform.ffi.types.basic.PyFloat +import python.multiplatform.ffi.types.basic.PyInt +import python.multiplatform.ffi.types.basic.PyNone +import python.multiplatform.ffi.types.basic.PyString +import python.multiplatform.reflection.ExposedCallable +import python.multiplatform.reflection.FunctionTableFragment +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.ObjectReference +import python.multiplatform.reflection.TypeTag + +/** + * The other direction: **a Python callable arriving at a Kotlin function-typed parameter.** + * + * `docs/archive/pythonx-adapter-design.md` §6 opens by saying the mechanism is half present already -- + * `UpcallTrampoline.toKotlinObject` wraps anything Python sends that is not an integer handle as a + * `PyObject`, so a `lambda:` does reach Kotlin with a reference of its own. What it reaches is a + * `PyObject`, and every container composable's `content` slot wants a `kotlin.jvm.functions.FunctionN`. + * That gap is the whole of why `Column`, `Row`, `Box` and `Button` were reachable only with `content` + * defaulted -- and `Column`'s `content` declares no default at all, so it was not reachable. + * + * ### Why the wrapper can be built without Compose on the classpath + * + * A lowered `@Composable ColumnScope.() -> Unit` is, in the compiled form the thunk calls, + * `Function3` -- and generics erase. The thunk's `CHECKCAST` is + * to `kotlin/jvm/functions/Function3`, and Compose's own call is + * `Function3.invoke(Object, Object, Object)`. So a Kotlin lambda of type `(Any?, Any?, Any?) -> Unit` + * written in `commonMain` **is** an admissible `content`, and nothing here has to name `Composer`, + * `ColumnScope` or any other Compose type. That is what keeps this file in `commonMain`, where the + * `pythonx` layer it serves already lives, rather than in a Compose-aware module. + * + * ### What separates a `Function0` from a composable lambda, and how it is known + * + * Not by guessing at the arity. `ArtifactScanner.functionSlotTypeName` compares the two arities the + * walker already has -- the declared `kotlin.FunctionN` from `@Metadata` against the + * `Lkotlin/jvm/functions/FunctionM;` in the JVM descriptor -- and a composable lambda is exactly the + * case `M == N + 2`, because the Compose plugin appends a `Composer` and one `$changed` to the + * *function type* just as it does to a function. `Button`'s `onClick` is `Function0` in both, so it + * is plain; `Column`'s `content` is `Function1` declared and `Function3` compiled, so it is lowered. + * The walker writes the answer into the type name it already carries, and `pythonx` reads it there. + * + * The consequence for a caller is the one that matters: a **composable** lambda is invoked with the + * composer its container established, which is threaded back into `pythonx`'s composer stack for the + * duration of the call, so a `content` may itself call composables. A **plain** one is not, and + * receives nothing. + * + * ### Lifetime: the scope is the holder, and it is the only one that can be + * + * Compose stores a `content` in the slot table and calls it again on later recompositions, so the + * Python callable has to outlive the call that passed it -- `Column(content=lambda: ...)` drops + * Python's last reference at the end of the statement. Something on the Kotlin side must hold one. + * + * Four candidates, and three of them do not work: + * + * | holder | why not | + * |---|---| + * | the `PyObject`'s own cleaner | it fires when the *Kotlin* wrapper is collected, which is whenever the collector gets round to it and never on a target without one. `docs/design/object-lifetime.md`'s point exactly | + * | `HandleTable` | a strong root nothing gives back: the handle reaches Kotlin as a slot value and is never handed to Python, so there is no `__del__` to hang the release off (`HandleTable`'s own KDoc: "this table leaks by construction") | + * | the composition **pass** | ends long before Compose stops calling the content | + * | **[PythonCallableScope]** | lives exactly as long as the composition, because a Compose-side `RememberObserver` closes it in `onForgotten` | + * + * So this file owns the first three quarters of that -- creating, holding and releasing -- and the + * one Compose type involved, `RememberObserver`, stays in the module that has Compose + * (`:ksp-fixtures:compose`'s `PythonComposition`). `docs/archive/pythonx-adapter-design.md` §6 item 1 names + * `onForgotten` as "the one that fails silently and should be tested first"; the seam is here and the + * test is there. + * + * ### Interning, and what the holder costs without it + * + * The scope releases everything at once, which settles *whether* a callable comes back and says + * nothing about how much is held meanwhile. `RecompositionAccumulationTest` measured that: one + * `content=` composed twelve times held **twelve** wrappers, twelve `HandleTable` roots and twelve + * Python references, all of them until disposal. Linear in frames, and a UI recomposes several times + * a second. + * + * So a crossing now asks the scope for a wrapper it already made before making one. What counts as + * "already made" is `PythonxAdapter._intern_key`, and it is neither `id()` nor `==`: a `lambda:` is a + * new object every pass *and* `PythonComposition` `exec`s a source string, so it is a newly compiled + * one. The key is what the callable is made of -- its code by value, its captures and defaults by + * address, and the slot it is going into. [PythonCallableScope.reuseCount] reports how many crossings + * that answered, so "the count stopped growing" cannot be confused with "nothing crossed". + * + * Two callables that are genuinely different still get two wrappers, and one closing over a value + * that changes between passes is genuinely different -- sharing there would leave Compose calling + * the first pass's capture forever. That case still accumulates, and it is the one a release point + * would have to solve; `RecompositionAccumulationTest` states its number rather than leaving it to be + * discovered. + * + * ### The release point, and why nothing releases early yet + * + * `RememberObserver` is not it and cannot be: a `content` is a *parameter*, not a remembered value, + * so nothing reports the slot being dropped. The candidate that remains is a sweep at the end of each + * composition pass -- give back every wrapper the pass neither built nor reused -- and its + * precondition was measured rather than assumed: + * `RecompositionAccumulationTest.everyLiveWrapperIsResuppliedOnEveryPassEvenWhenNested` shows a + * nested `content` re-supplying **both** its wrappers on every pass, so nothing Compose holds goes + * untouched. That test is the guard a sweep would be built on. + * + * It is not built, and the reason was a shape nothing had measured: a slot Compose *retains across a + * re-supply*. `LaunchedEffect(key) { block }` keeps the block it has when `key` is unchanged, so a + * pass supplying a different wrapper would leave the retained one untouched and still live -- and a + * wrapper released while Compose still calls it is `agent-rules` §14's failure, arriving somewhere + * else. Interning bounds the case that actually recurs; the sweep needed that measurement first. + * + * **That measurement now exists, and the verdict is that a same-pass sweep is unsafe.** + * `:ksp-fixtures:compose`'s `RetainedSlotSweepPreconditionTest + * .aRetainingSlotKeepsPassZerosWrapperWhileLaterPassesCrossAndGoUntouched` drives a real + * `remember(key)` (the mechanism `LaunchedEffect(key)` is built on) with a constant key across six + * recompositions of one composition, each supplying a callable with a distinct capture -- so each + * pass crosses a genuinely different wrapper, exactly `aCapturedValueThatChangesIsADifferentCallable + * AndIsNotShared`'s shape. Measured over those six passes: `remember(key)` returns pass 0's wrapper + * on every one of them (confirmed against the real Compose runtime, not assumed from its docs), while + * [PythonCallableScope.liveCount] grows by exactly one new wrapper per pass and + * [PythonCallableScope.reuseCount] stays at zero throughout -- meaning pass 0's wrapper is *never + * touched again* (neither rebuilt nor reused) from pass 1 on, which is precisely the criterion a + * same-pass "untouched" sweep would release it under. Pass 0's underlying Python callable's own + * refcount is confirmed unmoved across all six passes, so the reference the scope is holding for it + * is real, single, and still exactly what `remember(key)` is still hands out on the last pass -- not + * already gone, which is why releasing it would be worse than the current leak rather than merely + * redundant. + * + * So: **a sweep keyed on "built or reused this pass" must not be built as the only criterion.** It + * would release a wrapper a retaining slot is still returning, the first time a composition contains + * one. Closing the leak this file otherwise documents needs the sweep to also recognise (or otherwise + * exempt) a retained slot's wrapper -- which this measurement did not attempt, since implementing the + * sweep was not its task. + * + * ### Double release + * + * [PythonCallableScope.close] reports how many wrappers it released and answers `0` for every call + * after the first, which is what `PythonxCallableTest` asserts against rather than only asserting + * that the count came back. `agent-rules` §14 is the reason the two are separate assertions: a + * reference released twice does not fail where it happens. + * + * A wrapper invoked after its scope closed **refuses**. Calling through a released `PyObject` would + * not raise -- it would decrement whatever now occupies that address. + */ +object PythonCallables { + + /** The scopes currently open, innermost last. Not synchronised, for [HandleTable]'s reason: + * every mutation happens on a thread that holds the GIL. */ + private val open = ArrayList() + + /** The scope a callable crossing right now belongs to, or `null` if none is open. */ + val current: PythonCallableScope? get() = open.lastOrNull() + + /** How many scopes are open. A composition that leaked one leaves this non-zero. */ + val openScopeCount: Int get() = open.size + + /** A new, empty scope. Nothing is ambient until it is passed to [withScope]. */ + fun newScope(): PythonCallableScope = PythonCallableScope() + + /** + * Makes [scope] the ambient one for [block]. + * + * Nested rather than replaced, because a `content` that is itself a container establishes an + * inner composition-shaped region while an outer one is still running. Innermost wins, which is + * the same rule `pythonx`'s composer stack uses and for the same reason. + */ + inline fun withScope(scope: PythonCallableScope, block: () -> T): T { + push(scope) + try { + return block() + } finally { + pop(scope) + } + } + + /** @suppress internal to [withScope], which is the only supported way to nest a scope. */ + @PublishedApi + internal fun push(scope: PythonCallableScope) { + open.add(scope) + } + + /** @suppress see [push]. */ + @PublishedApi + internal fun pop(scope: PythonCallableScope) { + val last = open.removeAt(open.size - 1) + check(last === scope) { "pythonx callable scopes were closed out of order" } + } + + /** + * What `pythonx._make_function` calls: wrap [body] as a Kotlin `FunctionN` of [jvmArity]. + * + * @param body the Python callable, already holding a reference of its own -- the trampoline's + * `PyObject(value, borrowed = true)`. The scope takes ownership of that reference and is what + * gives it back. + * @param jvmArity how many arguments the *compiled* function type takes: 0 for `() -> Unit`, 1 + * for `(Float) -> Unit`, 3 for a lowered `@Composable ColumnScope.() -> Unit`. + * @param composable whether the last two of those are `$composer` and `$changed`. + * @param argTags one [TypeTag] name per **forwarded** argument, comma-separated and possibly + * empty -- see [marshalArgument] for why this is passed rather than derived here. + * @return the raw [ObjectReference] the wrapper is rooted under, which Python puts straight into + * the OBJECT slot as a bare handle. + */ + /** + * The cached wrapper for [internKey] in the open scope, or `0` if there is none. + * + * Asked **before** the thunk is built, which is the whole point of it being a separate entry: on + * a hit the Python side skips `inspect.signature` and the closure allocation entirely, and a + * recomposing composition takes that path every time after the first. Routing the question + * through [newFunction] instead would mean paying for both on every pass and then throwing the + * result away. + * + * `0` rather than an exception for "no scope is open": a lookup that cannot find a scope has not + * found a wrapper either, and the caller's next step is [newFunction], which raises the message + * that explains what a scope is. Two places saying it would let them drift. + */ + internal fun findFunction(internKey: String): Long { + if (internKey.isEmpty()) return ObjectReference.NONE_RAW + return current?.reuse(internKey) ?: ObjectReference.NONE_RAW + } + + internal fun newFunction( + body: PyObject, + jvmArity: Int, + composable: Boolean, + argTags: String, + internKey: String, + ): Long { + val scope = current ?: run { + // Closed here rather than leaked: the trampoline already took a reference for this + // wrapper and nothing downstream exists to give it back. + body.close() + throw IllegalStateException( + "no pythonx callable scope is open, so a Python callable has nowhere to live: a " + + "callable can only cross into Kotlin from inside a composition (or an explicit " + + "PythonCallables.withScope), because Compose keeps calling it after the call " + + "that passed it has returned", + ) + } + // Asked again even though `findFunction` was asked first: that call is an optimisation the + // Python side may skip (it does, for a callable with no structure to key on), and a second + // entry under one key would be a wrapper the scope holds and nothing can ever reach. + val cached = scope.reuse(internKey) + if (cached != ObjectReference.NONE_RAW) { + body.close() + return cached + } + if (composable && jvmArity < 2) { + body.close() + throw IllegalArgumentException( + "a lowered composable lambda takes at least a \$composer and a \$changed, so arity " + + "$jvmArity cannot be one", + ) + } + val tags = if (argTags.isEmpty()) emptyList() else argTags.split(',') + val forwarded = jvmArity - if (composable) COMPOSABLE_LOWERED_SLOTS else 0 + if (tags.size != forwarded) { + body.close() + // Not a diagnostic: the two sides marshal from the *same* list, and this is the check + // that they are in fact the same list. A short one would silently pair a `Float` with an + // OBJECT rule and hand Python a handle to a boxed number. + throw IllegalArgumentException( + "a Kotlin function type of compiled arity $jvmArity forwards $forwarded argument(s), " + + "but ${tags.size} marshalling tag(s) were given: '$argTags'", + ) + } + val function = PythonFunction(scope, body, composable, tags) + val erased = try { + function.erasedAs(jvmArity) + } catch (t: Throwable) { + // Nothing has taken ownership yet, so the reference the trampoline took for this + // wrapper is this frame's to give back. + body.close() + throw t + } + return scope.add(body, erased, internKey) + } + + /** How many of a lowered composable lambda's compiled arguments are the compiler's rather than + * the declaration's: the `$composer` and the one `$changed`. Mirrors + * `ArtifactScanner.COMPOSABLE_LOWERED_SLOTS`, and the two are checked against each other by the + * arity/tag-count agreement above rather than by being shared, since they are in different + * modules. */ + private const val COMPOSABLE_LOWERED_SLOTS = 2 + + /** + * The table entry [newFunction] is reached through. + * + * A fragment rather than a new `_pm_*` entry point on five bootstraps: this is one ordinary + * Kotlin function called from Python, which is precisely what the upcall table is, and adding a + * fourth C shape to `UpcallStub`, `bindings.kt` and three native bootstraps to carry it would be + * platform work for no new capability. [PythonxAdapter.install] registers it, so the layer that + * needs the service is the layer that declares it. + * + * Each entry is named by its **true Kotlin fully-qualified name** + * (`python.multiplatform.ffi.pythonx.PythonCallables.newFunction`). It used to be + * `pythonx.runtime.newFunction`, which made the proxy layer create a `pythonx` module in + * `sys.modules` -- a binder-made name squatting on the real pythonx package -- and exported a + * Kotlin declaration under a name that was not its own. Neither is allowed (`AGENTS.md` §12.1, + * §12.2). + */ + internal object Fragment : FunctionTableFragment { + + override val moduleName: String = "python_multiplatform_runtime" + + override fun entries(): List = listOf( + ExposedCallable( + name = NEW_FUNCTION, + arity = 5, + paramTypes = listOf(TypeTag.OBJECT, TypeTag.INT, TypeTag.BOOLEAN, TypeTag.STRING, TypeTag.STRING), + returnType = TypeTag.INT, + paramNames = listOf("body", "jvmArity", "composable", "argTags", "internKey"), + // `body` is declared as the Python object type here, not `kotlin.Any` -- the + // callable below does `args[0] as PyObject`, and `PythonProxySource.argValues` + // (`PY_OBJECT`) unwraps every OBJECT slot to its raw handle *except* one declared + // exactly this name. `kotlin.Any` would make a proxy passed as `body` through a + // rendered call site get unwrapped to the Kotlin object behind it, and fail this + // cast -- `PythonCallablesProxyArgumentTest` pins the reproduction and the fix. + paramTypeNames = listOf( + "python.multiplatform.ffi.PyObject", "kotlin.Int", "kotlin.Boolean", "kotlin.String", + "kotlin.String", + ), + paramHasDefault = listOf(false, false, false, false, false), + callable = { args -> + newFunction( + args[0] as PyObject, + (args[1] as Long).toInt(), + args[2] as Boolean, + args[3] as String, + args[4] as String, + ) + }, + ), + ExposedCallable( + name = FIND_FUNCTION, + arity = 1, + paramTypes = listOf(TypeTag.STRING), + returnType = TypeTag.INT, + paramNames = listOf("internKey"), + paramTypeNames = listOf("kotlin.String"), + paramHasDefault = listOf(false), + callable = { args -> findFunction(args[0] as String) }, + ), + // Issue #69: a Python scalar written into a `kotlin.Any` slot, boxed as the Kotlin type + // `PythonxAdapter._box_scalar` chose. Each takes the primitive tag that already carries + // that value exactly, and answers with the handle of the box (see [boxScalar]). + boxEntry(BOX_INT, TypeTag.INT, "kotlin.Int") { (it as Long).toInt() }, + boxEntry(BOX_LONG, TypeTag.INT, "kotlin.Long") { it as Long }, + boxEntry(BOX_DOUBLE, TypeTag.FLOAT, "kotlin.Double") { it as Double }, + boxEntry(BOX_BOOLEAN, TypeTag.BOOLEAN, "kotlin.Boolean") { it as Boolean }, + boxEntry(BOX_STRING, TypeTag.STRING, "kotlin.String") { it as String }, + ExposedCallable( + name = UNBOX_SCALAR, + arity = 1, + // INT, not OBJECT: this needs the handle *number*, to release it, and an OBJECT + // slot would hand over only the object it resolves to. + paramTypes = listOf(TypeTag.INT), + returnType = TypeTag.OBJECT, + paramNames = listOf("handle"), + paramTypeNames = listOf("kotlin.Long"), + // No return type name, like the two entries above: the result is a Python scalar or + // `None`, never a handle, so no producer may try to own it. + paramHasDefault = listOf(false), + callable = { args -> unboxScalar(args[0] as Long) }, + ), + ) + + private fun boxEntry(name: String, tag: TypeTag, typeName: String, box: (Any?) -> Any): ExposedCallable = + ExposedCallable( + name = name, + arity = 1, + paramTypes = listOf(tag), + returnType = TypeTag.INT, + paramNames = listOf("value"), + paramTypeNames = listOf(typeName), + paramHasDefault = listOf(false), + callable = { args -> boxScalar(box(args[0])) }, + ) + } + + /** + * Roots [value] -- a Kotlin box the binding layer asked for -- and returns its raw handle. + * + * ### Why a box crosses as a handle at all + * + * The value is on its way into an `OBJECT` slot, and the only thing an `OBJECT` slot reads a + * Python `int` as is a [HandleTable] handle (`UpcallTrampoline.toKotlinObject`). So the box is + * made here, rooted, and its handle is what Python then puts in the slot; the slot resolves it to + * this very object, and whatever Kotlin stores it in holds it from then on by an ordinary + * reference. + * + * ### Who gives the root back + * + * Python, as soon as the argument tuple the handle sits in is gone: `PythonxAdapter + * ._BoxedArgument` is an `int` whose `__del__` releases it, so the root lives exactly as long as + * the one call it was made for. Releasing here instead would hand the slot a stale handle. + */ + internal fun boxScalar(value: Any): Long = HandleTable.register(value).raw + + /** + * What a handle Python read out of a `kotlin.Any` slot means, if it is a boxed scalar. + * + * @return a [python.multiplatform.ffi.upcall.ScalarResult] -- which the trampoline marshals as + * the Python scalar, as a new reference -- **after releasing [raw]**, because the Python side + * keeps no proxy over a scalar and the handle has no other owner; or `null` (`None`) for any + * other object, whose handle is left rooted for the proxy Python then builds over it. A stale + * [raw] is `null` too: there is nothing to release, and the proxy built over it refuses on use. + */ + internal fun unboxScalar(raw: Long): Any? { + val held = HandleTable.resolveRaw(raw) ?: return null + when (held) { + is Boolean, is Int, is Long, is Short, is Byte, is Double, is Float, is String, is Char -> Unit + else -> return null + } + HandleTable.release(ObjectReference(raw)) + return python.multiplatform.ffi.upcall.ScalarResult(held) + } + + /** The name the binding layer resolves. Shared so the Python source and the entry cannot drift. */ + internal const val NEW_FUNCTION: String = "python.multiplatform.ffi.pythonx.PythonCallables.newFunction" + + /** @see findFunction */ + internal const val FIND_FUNCTION: String = "python.multiplatform.ffi.pythonx.PythonCallables.findFunction" + + /** @see boxScalar */ + internal const val BOX_INT: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxInt" + + /** @see boxScalar */ + internal const val BOX_LONG: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxLong" + + /** @see boxScalar */ + internal const val BOX_DOUBLE: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxDouble" + + /** @see boxScalar */ + internal const val BOX_BOOLEAN: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxBoolean" + + /** @see boxScalar */ + internal const val BOX_STRING: String = "python.multiplatform.ffi.pythonx.PythonCallables.boxString" + + /** @see unboxScalar */ + internal const val UNBOX_SCALAR: String = "python.multiplatform.ffi.pythonx.PythonCallables.unboxScalar" +} + +/** + * Everything one composition handed to Kotlin, and the single point that gives it all back. + * + * Created by [PythonCallables.newScope], made ambient by [PythonCallables.withScope], and closed by + * whoever owns the composition -- on Compose that is a `RememberObserver`, whose `onForgotten` is the + * only hook that reports a slot being dropped. + */ +class PythonCallableScope internal constructor() { + + private val bodies = ArrayList() + private val roots = ArrayList() + + /** + * The wrappers this scope can hand out again, by the key `PythonxAdapter._intern_key` computed + * for the callable and its slot. + * + * ### Why the table is here and not in Python + * + * The value is a `HandleTable` root, and a root outlives its scope by exactly nothing -- [close] + * releases it. A Python-side cache would have to be told when that happened, which is the same + * "who says it is over" problem the wrapper itself has, one level up. Here the answer is free: + * the map dies with the thing it describes. + * + * ### Why the key can contain `id()`s and still be sound + * + * `_intern_key` is built out of the addresses of a callable's code object, its captured values + * and its defaults, and an address is only unambiguous while the object at it is alive. It is: + * every entry in this map is a root over a wrapper that holds the `PyObject` for the callable, + * which holds all of them. So no key in this table can name a freed object, and the id reuse + * that would make two different callables collide cannot happen while the entry exists. + * `HandleTable.register`'s own KDoc says registration is *not* interning and that two proxies + * over one object need not agree -- that stays true, and this is the layer above it that does + * intern, for the one case where the object crossing is a callable Compose will call again. + */ + private val interned = HashMap() + + private var closed = false + private var reused = 0 + + /** How many callables this scope is currently holding a Python reference for. */ + val liveCount: Int get() = bodies.size + + /** + * How many crossings were answered out of [interned] instead of building a wrapper. + * + * Reported so that "the count stopped growing" and "nothing crossed at all" are distinguishable: + * a `content=` that stopped reaching Kotlin would leave [liveCount] at 1 as well, and only this + * says the later passes were served rather than skipped. + */ + val reuseCount: Int get() = reused + + /** Whether [close] has already run. A wrapper checks this before touching its `PyObject`. */ + val isClosed: Boolean get() = closed + + /** The handle already issued for [internKey], or [ObjectReference.NONE_RAW]. */ + internal fun reuse(internKey: String): Long { + if (internKey.isEmpty() || closed) return ObjectReference.NONE_RAW + val found = interned[internKey] ?: return ObjectReference.NONE_RAW + reused++ + return found + } + + internal fun add(body: PyObject, erased: Any, internKey: String): Long { + val root = HandleTable.register(erased) + bodies.add(body) + roots.add(root) + // An empty key is "this callable has no structure to key on" -- a C builtin, say. It gets a + // wrapper per crossing, which is what everything got before this table existed. + if (internKey.isNotEmpty()) interned[internKey] = root.raw + return root.raw + } + + /** + * Gives every Python reference back and unroots every wrapper. + * + * @return how many callables this call actually released -- `0` for every call after the first. + * A count rather than a `Boolean` so that a test can assert both "the scope held what it said + * it held" and "the second close released nothing", which are different failures. + */ + fun close(): Int { + if (closed) return 0 + closed = true + val released = bodies.size + for (root in roots) HandleTable.release(root) + // `PyObject.close()` runs the registered cleanup action, which every platform's cleaner + // performs at most once -- so the decref here cannot be doubled by a later collection of the + // same wrapper. + for (body in bodies) body.close() + roots.clear() + bodies.clear() + // Cleared last and unconditionally: a key left behind would name a released root, and + // `reuse` would hand it out. The `closed` guard in `reuse` covers the same case twice on + // purpose -- one of them is a policy and the other is the thing that makes it true. + interned.clear() + return released + } +} + +/** + * One Python callable, and the invocation convention its slot implied. + * + * Kept separate from the erased lambda [erasedAs] produces so that the state -- the scope, the + * `PyObject`, whether a composer is threaded -- exists once regardless of arity, and the per-arity + * part is a lambda with nothing in it but a call. + */ +internal class PythonFunction( + private val scope: PythonCallableScope, + internal val body: PyObject, + private val composable: Boolean, + /** One [TypeTag] name per forwarded argument, in order. See [marshalArgument]. */ + private val argTags: List, +) { + + /** + * This callable as a `kotlin.FunctionN` of [jvmArity]. + * + * A `when` over the arities rather than one generic shape, because there is no generic shape: a + * JVM `Function3` is a different interface from a `Function2`, and the thunk's `CHECKCAST` names + * one of them. The ceiling is where measurement stops rather than where the technique does -- + * `androidx.compose.foundation.layout` and `material3` declare no lowered content lambda wider + * than this, and a wider one refuses by name instead of being cast to the wrong interface. + */ + fun erasedAs(jvmArity: Int): Any { + // One `return` per arity rather than a `when` expression: the branches of a `when` are + // unified against each other, and `(Any?) -> Unit` and `(Any?, Any?) -> Unit` are unrelated + // types. Each `return` is checked against `Any` on its own instead. + when (jvmArity) { + 0 -> return fun() { invoke(emptyArray()) } + 1 -> return fun(a: Any?) { invoke(arrayOf(a)) } + 2 -> return fun(a: Any?, b: Any?) { invoke(arrayOf(a, b)) } + 3 -> return fun(a: Any?, b: Any?, c: Any?) { invoke(arrayOf(a, b, c)) } + 4 -> return fun(a: Any?, b: Any?, c: Any?, d: Any?) { invoke(arrayOf(a, b, c, d)) } + 5 -> return fun(a: Any?, b: Any?, c: Any?, d: Any?, e: Any?) { invoke(arrayOf(a, b, c, d, e)) } + } + throw IllegalArgumentException( + "a Kotlin function type of arity $jvmArity is not bound: nothing measured declares one, " + + "and guessing at the interface would be a ClassCastException at the call site", + ) + } + + private fun invoke(args: Array) { + check(!scope.isClosed) { + "this Python callable was released with its composition and cannot be invoked: the " + + "scope that held it is closed" + } + PythonCallables.withScope(scope) { + // The composer Compose established for *this* invocation, which is not necessarily the + // one that was ambient when the lambda crossed. `$changed` -- the last argument -- is + // the caller's claim about argument staticness and is not Python's to read. Everything + // before those two is the declaration's own, and is Python's. + val composerRoot = if (!composable) null else HandleTable.register( + args[args.size - 2] + ?: throw IllegalStateException("a composable lambda was invoked with a null \$composer"), + ) + try { + // Built before the call and closed after it, all of them, so that a failure part way + // through the marshalling does not leave the earlier ones to a collector that may + // not exist on this target. + val marshalled = ArrayList(argTags.size + 1) + try { + // The push and the pop are Python's, in `pythonx._callable_thunk`: doing it here + // would mean two more boundary crossings per invocation to reach `push_composer` + // and `pop_composer`, and the `finally` that guarantees the pop belongs next to + // the call it guards. + if (composerRoot != null) marshalled += PyInt.from(composerRoot.raw) + for (i in argTags.indices) marshalled += marshalAn(argTags[i], args[i]) + body(*marshalled.toTypedArray()).close() + } finally { + for (value in marshalled) value.close() + } + } finally { + if (composerRoot != null) HandleTable.release(composerRoot) + } + } + } + + /** + * One Kotlin argument on its way into the Python callable. + * + * ### Why the tag comes from the walker rather than from the value + * + * A `when (value) { is Float -> ...; is String -> ... }` would need no tag at all and is wrong, + * because **the Python side has to make the same decision independently** -- an OBJECT arrives as + * a handle integer and has to be given a proxy of the declared type, and a `kotlin.Int` arrives + * as an integer that must stay one. Deciding from the runtime type here and from the declared + * name there is two rules that can disagree, and the case where they do is silent: a slot + * declared `kotlin.Number` holding an `Int` would be marshalled as a number and then wrapped as + * a proxy over the "handle" 5. Both sides therefore read the *same* list, which + * [PythonCallables.newFunction] checks the length of. + * + * Nullability is not in the tag and does not need to be: `null` is `None` whatever the slot + * said, which is the rule `UpcallTrampoline.toKotlin` already applies in the other direction. + */ + private fun marshalAn(tag: String, value: Any?): PyObject { + // A reference of this frame's own rather than `PyNone.get()` itself: that is a cached + // singleton *wrapper*, and the caller closes everything it is given -- closing it would + // release the one every later `get()` hands back. `borrowed = true` is what makes the + // constructor take a reference instead of adopting one it was not given. + if (value == null) return PyObject(PyNone.get().pointer, borrowed = true) + return when (tag) { + "INT" -> PyInt.from((value as Number).toLong()) + "FLOAT" -> PyFloat.from((value as Number).toDouble()) + "BOOLEAN" -> PyBool.from(value as Boolean) + "STRING" -> PyString.from(value as String) + // Rooted, and **not** released here. The proxy `pythonx._adapt_arguments` builds over + // this handle is what gives it back, through the `__del__` every `_proxy_type` carries + // -- which is `HandleTable`'s stated contract for a handle that reaches Python, and the + // reason the thunk wraps every forwarded argument before it drops the ones the callable + // did not ask for. Releasing it here as well would be the double release `agent-rules` + // §14 is about; releasing it here *instead* would hand Python a stale handle for the + // duration of a callback that is allowed to keep it. + "OBJECT" -> PyInt.from(HandleTable.register(value).raw) + else -> throw IllegalArgumentException( + "a Kotlin function type argument tagged '$tag' has no marshalling rule", + ) + } + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt new file mode 100644 index 000000000..4fa114245 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt @@ -0,0 +1,2283 @@ +package python.multiplatform.ffi.pythonx + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.KotlinSurface +import python.multiplatform.reflection.ExposedCallable +import python.multiplatform.reflection.UpcallTable + +/** + * The binding layer -- `python_multiplatform.binding`, the hand-written Python that serves Kotlin + * declarations on their Kotlin-named modules, and the generated table it reads. + * + * **It renames nothing, and it is not `pythonx`.** A Kotlin-named module carries Kotlin's own + * surface: Kotlin declaration names, keyword arguments by Kotlin parameter names, Kotlin defaults, + * overloads, value-class rules and the `$composer` slot (`docs/INTENT.md` §2.2). The Kotlin + * package and class names below (`ffi.pythonx`, `PythonxAdapter`) are historical; the Python module + * this installs is binder-owned, and `pythonx` is a real package in pythonx-compose that builds a + * Pythonic API on top of this one through `python_multiplatform.describe` and `inspect.signature` + * ([KotlinSurface]). + * + * `docs/archive/pythonx-adapter-design.md` §7 draws one line through this whole area: **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.** This object is where the two meet, and it keeps them + * apart on purpose: + * + * | | what | who wrote it | + * |---|---|---| + * | [SOURCE] | the finder, the name rule, the overload dispatcher, the receiver proxies | a person, once. It names no Kotlin declaration anywhere | + * | [renderTable] | one row per entry in [UpcallTable] | this function, from the table | + * + * The 2024 `pythonx-compose` put both on the wrong side of that line -- a Python file per Compose + * component, 37 of them, 28 empty -- and `docs/archive/pythonx-adapter-design.md` §1 measures what it cost: + * `padding()` composed nothing and `fill_max_size()` returned `self`, because a per-declaration + * wrapper is written once and then never again. Nothing here is per-declaration. + * + * ### What the Python needs from the host, and what it does not + * + * The same two names [python.multiplatform.ffi.upcall.PythonProxySource] needs -- `_pm_resolve` and + * `_pm_invoke` in `__main__` -- plus `_pm_release` if handles are to be given back. It does **not** + * need `PythonProxySource.install()`: the eager whole-table render and this lazy one are two + * independent consumers of the same boundary, and `PythonxAdapterTest` installs only this one. + * That is §2.3's laziness claim made executable rather than argued. + * + * ### Why the source is a Kotlin string and not a `.py` file + * + * The same reason [python.multiplatform.ffi.upcall.PythonProxySource] gives, and §2.5 restates for + * this layer specifically: **there is no resource path to put a `.py` on that works everywhere.** + * Kotlin/Native has no `getResourceAsStream`, and `sys.path` on iOS, androidNative and wasm points + * into the per-platform CPython trees under `src/nativeInterop/cinterop/lib/...`. `Python3.exec` + * works on all five today and nothing else does. + * + * [python.multiplatform.env.PythonPayload] has since solved *half* of this, and the half it solved + * is not this one. It puts a **consumer's** payload directory on `sys.path` before the first + * import, from a real packaging step, on desktop (a jar resource, extracted) and Android (an APK + * asset, unpacked). It is not a route for a *library-generated* file: iOS needs an Xcode phase + * nobody has written, androidNative and wasm have no packaging step at all, and the table this + * renders is assembled at run time from whatever `UpcallTable.install` was given -- the second + * reason `PythonProxySource` gives, which no packaging work touches. + * + * This is the third candidate §2.5 lists, and its cost is named there and not disputed here: the + * source is hostile to editing, diffing and shipping to PyPI, which `pythonx-compose`'s + * `pyproject.toml` says it is eventually meant to do. **§2.5 stays open.** What is *not* paid is + * duplication: this is the only copy, so there is nothing for a `.py` beside it to drift from. + * + * One mechanical constraint follows from the delivery route and is worth stating where it can be + * broken: [SOURCE] is handed to Python inside an `r"""..."""` literal, so it must contain no triple + * double-quote and must not end in a backslash. Its docstrings are `'''` for exactly that reason. + * [render] checks it rather than leaving the failure to a `SyntaxError` at `exec` time. + * + * ### GraalVM native image + * + * Unchanged from `PythonProxySource`: this renders a *Python* string out of data that is already in + * a statically-emitted Kotlin table. No reflection, no class loading, no dynamic Kotlin. The closed + * world stays closed. (Unverified here: no native image was built for this change.) + */ +object PythonxAdapter { + + /** + * Where the binding layer lives in `sys.modules`: a binder-owned name under + * [KotlinSurface.MODULE_NAME], never `pythonx` (see [DELIVERY]). + */ + const val MODULE_NAME: String = "python_multiplatform.binding" + + /** + * The adaptation layer itself: hand-written Python, the same for every table. + * + * Read it as a file -- it is one, and the indentation the Kotlin literal adds is removed by + * `trimIndent`. What it contains, in the order the design asks for it: + * + * | | `docs/archive/pythonx-adapter-design.md` | + * |---|---| + * | `_Finder` / `_Loader` | §2.3, the hook a module `__getattr__` cannot replace | + * | the module `__getattr__` the loader installs | §4.1, adapted once and then a dict hit | + * | Kotlin names, unchanged | `docs/INTENT.md` §2.2 -- no rule converts a name in either direction | + * | `_Overloads` | `docs/design/kotlin-extensions-in-python.md` §3.1, the dispatcher that has to live here | + * | `_proxy_type` / `_Hybrid` | §4.2, an extension as a method on its receiver, both spellings | + * | `_coerce`'s allowlist | §4.4, `Dp` yes, a packed value class no | + */ + val SOURCE: String = """ + # GENERATED-FREE: this file is hand-written Python. See PythonxAdapter.kt. + # + # `python_multiplatform.binding` -- the binding layer over the Kotlin declarations the upcall + # table exposes. It serves Kotlin-named modules with **Kotlin's own surface**: Kotlin + # declaration names, keyword arguments by Kotlin parameter names, Kotlin defaults, overloads, + # value-class rules and the `${'$'}composer` slot. It renames nothing. Making that surface + # Pythonic is the job of a real Python package built on top of it (pythonx-compose), which + # reads `python_multiplatform.describe` and `inspect.signature` to do it by rule. + # + # `docs/archive/pythonx-adapter-design.md` §7 draws the line this file lives on: *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.* Nothing here mentions a Kotlin declaration by name. What arrives + # per declaration is the table `PythonxAdapter.renderTable` emits into `_register_table`, and the + # only per-library knowledge is the package map and the value-class allowlist, both of which are + # data and both of which a consumer can extend at run time. + + import importlib as _importlib + import importlib.machinery as _machinery + # Read once per crossing, never per invocation: `_positional_capacity` asks it how many + # arguments a callable takes at the moment the callable becomes a Kotlin `FunctionN`, and the + # answer is closed over by the thunk. + import inspect as _inspect + import sys as _sys + import types as _types + + from python_multiplatform import KOTLIN_DEFAULT, signature_of as _signature_of + + + + # --------------------------------------------------------------------------- the raw boundary + + _BOUNDARY = {} + + + def _no_release(_handle): + # A host that bound no `_pm_release` gets the behaviour that existed before anything released: + # a leak, rather than a NameError raised inside `__del__` where nothing can act on it. Same + # policy as `PythonProxySource._pm_no_release`. + return 0 + + + def _boundary(): + if not _BOUNDARY: + _main = _sys.modules['__main__'] + _resolve_fn = getattr(_main, '_pm_resolve', None) + _invoke_fn = getattr(_main, '_pm_invoke', None) + if _resolve_fn is None or _invoke_fn is None: + raise RuntimeError( + 'the raw upcall entry points are not bound: define _pm_resolve(name_bytes) -> ' + 'handle and _pm_invoke(handle, args_tuple) -> result before installing the binding layer' + ) + _BOUNDARY['resolve'] = _resolve_fn + _BOUNDARY['invoke'] = _invoke_fn + _BOUNDARY['release'] = getattr(_main, '_pm_release', _no_release) + return _BOUNDARY + + + def _resolve(kotlin_name): + # `bytes`, not `str`: desktop reaches its resolver through + # `ctypes.CFUNCTYPE(c_long, c_char_p)` and `c_char_p` refuses a `str` outright. Every other + # bootstrap accepts both. Same reasoning as `PythonProxySource._pm_lookup`. + handle = _boundary()['resolve'](kotlin_name.encode('utf-8')) + if handle == -1: + raise AttributeError('no exposed Kotlin declaration named ' + kotlin_name) + return handle + + + # --------------------------------------------------------------------------- the table + + class _Decl: + '''One row of what `PythonxAdapter.renderTable` sent, plus its resolved handle.''' + + __slots__ = ( + 'kotlin_name', 'package', 'leaf', 'base', 'suffix', 'arity', 'kind', 'is_suspend', + 'param_names', 'param_tags', 'param_type_names', 'return_tag', 'return_type_name', + 'is_extension', 'receiver_type_name', 'param_has_default', 'handle', + 'composer_index', 'changed_slots', 'default_slots', 'return_supertypes', 'row', + 'binding', + ) + + def __init__(self, row): + self.update(row) + + def update(self, row): + # In place, and reusing the object: everything already adapted -- a `_Binding` in a module + # dict, a `_Hybrid` on a proxy type -- points at this object, and a table reinstall must + # refresh what they see rather than leave them looking at the previous epoch's row. + # The row itself is kept as sent: it is what `python_multiplatform.describe` and + # `inspect.signature` read, the same tuple the proxy layer publishes with. + self.row = row + self.binding = None + (self.kotlin_name, self.arity, self.kind, self.is_suspend, self.param_names, + self.param_tags, self.param_type_names, self.return_tag, self.return_type_name, + self.is_extension, self.receiver_type_name, self.param_has_default) = row + # The declared return type and what it **is a** travel in one string; see + # `_split_supertypes`. Split here, once, so that everything downstream -- `_wrap`, + # `_kotlin_type_name`, every message -- keeps seeing exactly the name it did before. + self.return_type_name, self.return_supertypes = _split_supertypes(self.return_type_name) + self.package, _, self.leaf = self.kotlin_name.rpartition('.') + self.base, _, self.suffix = self.leaf.partition('__') + self.handle = None + # A `@Composable`, recognised by the slot no ordinary declaration has. Nothing was + # added to `ExposedCallable` to say so: `${'$'}composer` is the Compose compiler's own + # spelling, a dollar is not a Python identifier character, and only `ArtifactScanner`'s + # composable path ever emits it -- so the name *is* the flag, and one that cannot + # drift out of step with the slot it describes. + names = self.param_names or () + self.composer_index = names.index('${'$'}composer') if '${'$'}composer' in names else -1 + self.changed_slots = tuple(i for i, n in enumerate(names) if n.startswith('${'$'}changed')) + self.default_slots = tuple(i for i, n in enumerate(names) if n.startswith('${'$'}default')) + + def declared_arity(self): + '''The slots a Python caller writes: everything before `${'$'}composer`. + + The synthetic ones are real parameters of the JVM method and real slots of the + binding -- that is the whole mechanism -- but they are never the caller's to fill. + ''' + return self.arity if self.composer_index < 0 else self.composer_index + + def signature(self): + parts = [] + for index in range(self.declared_arity()): + name = self.param_names[index] if self.param_names else 'a' + str(index) + if name == '': + continue + type_name = self.param_type_names[index] if self.param_type_names else self.param_tags[index] + part = name + ': ' + _simple_name(type_name) + if self.omittable(index): + # The same `= ...` a `.pyi` writes, and for the same reason: a refusal that + # listed an optional parameter as if it were required would send the caller + # looking for a value they never had to supply. + part += ' = ...' + parts.append(part) + return self.leaf + '(' + ', '.join(parts) + ')' + + def omittable(self, index): + '''Whether slot [index] may be left out of a call. + + Read from `ExposedCallable.paramHasDefault`, which is a statement about the **binding** + and not quite about the Kotlin declaration: it marks the slots the generated Kotlin + body has a call expression for that does not mention them. Those are the same set for + everything the walker binds today, and they part company where the generator declined + to enumerate the omission sets -- see `ArtifactScanner.MAX_OMITTABLE_PARAMETERS`. The + binding's answer is the one that matters here, because it is the one that decides + whether Kotlin will actually reach a default. + ''' + if not self.param_has_default or index >= len(self.param_has_default): + return False + return bool(self.param_has_default[index]) + + def bound_handle(self): + if self.handle is None: + self.handle = _resolve(self.kotlin_name) + return self.handle + + + _TABLE = {} # kotlin fqn -> _Decl + _MODULES = [] # the Kotlin-named modules this layer has adapted + _BY_PACKAGE = {} # kotlin package -> {kotlin name -> [_Decl]} + _BY_RECEIVER = {} # kotlin receiver type -> {kotlin name -> [_Decl]} + _PACKAGES_SEEN = set() + _CHILDREN = {} # kotlin package -> the Kotlin names of its direct child packages/objects + _SUPERTYPES = {} # kotlin type name -> the types it is a, nearest first + _PROPERTIES = {} # kotlin type a property is read on -> {kotlin name -> _Property} + + + # The two row kinds that are attributes of a *value* rather than names in a package (issue #38): + # `Owner.prop` (and `Owner.prop=` for a `var`), and an extension property's `pkg.prop`. Neither + # is indexed by package -- `MutableState.value` is not a module, and `Icons.Filled.Add` is read + # off `Icons.Default`, not imported -- so both live in `_PROPERTIES`, keyed by the type the + # row's `receiver_type_name` says they are read on. + _PROPERTY_KINDS = ('GETTER', 'SETTER') + + + class _Property: + '''One Kotlin property of one type: its getter row and, for a `var`, its setter row.''' + + __slots__ = ('getter', 'setter') + + def __init__(self): + self.getter = None + self.setter = None + + + # What `ArtifactRendering.SUPERTYPE_SEPARATOR` writes between a declared return type and its + # ancestry. Neither character is legal in a Kotlin fully-qualified name, which is the same + # property `${'$'}composer` and `_COMPOSABLE_MARK` rely on: the string is the flag, and it cannot + # drift out of step with the thing it describes. + _SUPERTYPE_SEPARATOR = '<:' + + + def _split_supertypes(type_name): + '''`'A<:B<:C'` -> `('A', ('B', 'C'))`. A name with no ancestry comes back unchanged. + + Only a *walked jar* can answer what a type is a subtype of -- it is the one producer with + the class files open -- so this is written by `ArtifactScanner` and by nothing else. A KSP + fragment's rows have no separator in them and reach the same code path with an empty + ancestry, which is the truth rather than a special case. + ''' + if not type_name or _SUPERTYPE_SEPARATOR not in type_name: + return type_name, () + parts = type_name.split(_SUPERTYPE_SEPARATOR) + return parts[0], tuple(parts[1:]) + + + def _is_a(declared, wanted): + '''Whether a value declaring type [declared] may fill a slot declared [wanted]. + + Nominal equality, widened by exactly the ancestry the table carries and by nothing else. + There is no rule here for a type nothing produced -- an entry appears in `_SUPERTYPES` + only because some bound declaration *returns* it, which is the only way a value of it can + reach a slot in the first place. + ''' + return declared == wanted or wanted in _SUPERTYPES.get(declared, ()) + + + def _register_table(rows): + '''Called by the generated fragment. Per-declaration data, produced by Kotlin, consumed here. + + Idempotent, and **destructive of everything already adapted**, because a reinstall is a new + table: `CallableHandle` packs the table epoch precisely so that a handle cached across one is + detected rather than silently invoked against a different function, and every name this layer + has resolved is such a cached handle. `UpcallTable.install` bumps that epoch; this is the other + half of that contract. + ''' + _invalidate() + _BY_PACKAGE.clear() + _BY_RECEIVER.clear() + _MEMBER_ALIASES.clear() + _MEMBER_KEYWORDS.clear() + _PACKAGES_SEEN.clear() + _CHILDREN.clear() + _SUPERTYPES.clear() + _PROPERTIES.clear() + present = set() + for row in rows: + kotlin_name = row[0] + decl = _TABLE.get(kotlin_name) + if decl is None: + decl = _Decl(row) + _TABLE[kotlin_name] = decl + else: + decl.update(row) + present.add(kotlin_name) + # Keyed by the type rather than by the declaration: an ancestry is a fact about a + # type, and several declarations routinely return the same one. Written once and + # never merged -- two producers of one type read it off the same class file, so a + # disagreement would be a walker bug rather than something to reconcile here. + if decl.return_supertypes and decl.return_type_name not in _SUPERTYPES: + _SUPERTYPES[decl.return_type_name] = decl.return_supertypes + if decl.kind in _PROPERTY_KINDS: + # A property is served on its receiver's proxy, never from a package (see + # `_PROPERTY_KINDS`). A row with no receiver has nowhere to be served, and is + # still described by name through `_TABLE`. + if decl.receiver_type_name: + name = decl.leaf[:-1] if decl.kind == 'SETTER' and decl.leaf.endswith('=') else decl.leaf + prop = _PROPERTIES.setdefault(decl.receiver_type_name, {}).get(name) + if prop is None: + prop = _PROPERTIES[decl.receiver_type_name][name] = _Property() + if decl.kind == 'GETTER': + prop.getter = decl + else: + prop.setter = decl + continue + _index(_BY_PACKAGE.setdefault(decl.package, {}), decl) + if decl.is_extension and decl.receiver_type_name: + _index(_BY_RECEIVER.setdefault(decl.receiver_type_name, {}), decl) + segments = decl.package.split('.') + for count in range(1, len(segments) + 1): + _PACKAGES_SEEN.add('.'.join(segments[:count])) + if count > 1: + # Issue #35: a package lists its direct children in `dir()` and serves them + # as attributes. Kotlin names, exactly as the table spells them -- an object + # (`Alignment`) is a package here, because its members are bound under it. + _CHILDREN.setdefault('.'.join(segments[:count - 1]), set()).add(segments[count - 1]) + for gone in [name for name in _TABLE if name not in present]: + del _TABLE[gone] + # A setter whose getter is not in the table is not a property Python can read; the walker + # never emits one, and a hand-written table that does gets nothing rather than half. + for by_name in _PROPERTIES.values(): + for name in [name for name, prop in by_name.items() if prop.getter is None]: + del by_name[name] + # Here rather than at the end of this file: `_PACKAGES_SEEN` is empty until this + # function has run, and a module `PythonProxySource` created before it will never reach + # the finder -- CPython answers from `sys.modules` first. Called on every table change + # so the two halves meet whichever order they arrive in. + adapt_existing_modules() + + + def _drop_adapted(module): + '''Removes what this layer cached in [module], and nothing anybody else put there. + + The proxy layer (`PythonProxySource`) publishes into the same Kotlin-named modules, and + a name it wrote after this layer cached one is **its**, not stale: it delegates back here + at call time. So a name goes only if it still holds the very object this layer put there. + ''' + for name, value in getattr(module, '_kotlin_adapted', {}).items(): + if module.__dict__.get(name) is value: + del module.__dict__[name] + module._kotlin_adapted = {} + + + def _invalidate(): + '''Drops every handle, adapted name and attached method, so the next read resolves again.''' + for decl in _TABLE.values(): + decl.handle = None + decl.binding = None + for module in _MODULES: + _drop_adapted(module) + for cls in _PROXY_TYPES.values(): + for name in cls._kotlin_attached: + type.__delattr__(cls, name) + cls._kotlin_attached = [] + # The host rebinds `_pm_resolve`/`_pm_invoke` per interpreter and, in this repository's + # fixture, per test. Re-reading them costs one attribute lookup per install. + _BOUNDARY.clear() + + + def _index(table, decl): + # Two keys per declaration: the base name, which an overload set shares, and the explicit + # `name__Types` spelling, which is always exactly one declaration. `docs/kotlin-extensions-in- + # python.md` §3.1 -- the explicit name is the caller saying which, and it has to keep working + # whether or not the dispatcher can decide. Both keys are Kotlin names, unchanged. + table.setdefault(decl.base, []).append(decl) + if decl.suffix: + table.setdefault(decl.leaf, []).append(decl) + + + def bound_names(): + '''Every Kotlin fully-qualified name the adapter knows about.''' + return list(_TABLE) + + + def _rows_named(kotlin_package, name): + '''The table rows [name] stands for in [kotlin_package], or `None` -- without reading anything. + + What `python_multiplatform.describe(module, name)` asks first (issue #36): a + `STATIC_GETTER` read runs Kotlin, so a description has to come from the table and never + from the module attribute. An overload set's base name answers every member. + ''' + decls = _BY_PACKAGE.get(kotlin_package, {}).get(name) + if not decls: + return None + return tuple(decl.row for decl in decls) + + + # --------------------------------------------------------------------------- names + + # **Kotlin names, unchanged, everywhere.** A Kotlin declaration is reached under its own name + # and takes keyword arguments by its own parameter names. There used to be a forward rule + # here (`fillMaxWidth` -> `fill_max_width`) and its inverse; both are gone, because a binder + # that renames is deciding what a Python API is called, and that is the business of the + # Python package built on top (`docs/INTENT.md` §2.2, §2.3). + + _KOTLIN_PRIMITIVES = frozenset(( + 'kotlin.Byte', 'kotlin.Short', 'kotlin.Int', 'kotlin.Long', 'kotlin.Float', 'kotlin.Double', + 'kotlin.Boolean', 'kotlin.Char', 'kotlin.String', 'kotlin.Unit', 'kotlin.Any', + )) + + + _NOT_BOUND = object() + + # The `UpcallTable` epoch this layer's table was registered for; stamped by + # `PythonxAdapter.install`. `python_multiplatform.kotlin_function` compares it with the proxy + # layer's own stamp, so a layer installed for an older table never answers for a newer one. + _registered_epoch = -1 + + # `PythonProxySource.PY_OBJECT`: the one declared parameter type that means "hand this call + # the Python object itself". + _PY_OBJECT = 'python.multiplatform.ffi.PyObject' + + + def _read_named(kotlin_name): + '''The current value of one exact `STATIC_GETTER` name, or `_NOT_BOUND`. + + What the proxy layer's module-attribute descriptor asks on every read, for the reason + `_callable_named` gives for a function. + ''' + decl = _TABLE.get(kotlin_name) + if decl is None or decl.kind != 'STATIC_GETTER': + return _NOT_BOUND + return _read_constant(decl) + + + def _callable_named(kotlin_name): + '''The callable this layer serves for one exact Kotlin name, or `None`. + + What the proxy layer's published function asks on every call (`python_multiplatform + .kotlin_function`): when this layer knows the declaration, the call is this layer's. A + `suspend fun` is not, because only the proxy layer's `async def` awaits its result, and + neither is a property read. + ''' + decl = _TABLE.get(kotlin_name) + if decl is None or decl.kind != 'FUNCTION' or decl.is_suspend: + return None + binding = decl.binding + if binding is None: + binding = decl.binding = _Binding(decl) + return binding + + + def _simple_name(qualified): + if not qualified: + return '?' + # A function-typed slot's name is a signature rather than a classifier, so its last + # dotted component is the *return* type: `kotlin.Function3@Composable(…)->kotlin.Unit` + # would print as `Unit`, which names the one part of it a reader does not need. Rendered + # as Kotlin instead, because every message this appears in is telling somebody what they + # should have passed. (`_function_slot` is defined below; the name is resolved when this + # runs, which is after the module is built.) + slot = _function_slot(qualified) + if slot is not None: + composable, args, returns = slot[1], slot[2], slot[3] + return ('@Composable ' if composable else '') + '(' + \ + ', '.join(_simple_name(arg) for arg in args) + ') -> ' + _simple_name(returns) + return qualified.rpartition('.')[2] + + + def _declared_type_name(value): + '''The Kotlin type name an owned OBJECT value declares, from whichever owner produced it. + + Two owners exist, and neither's shape is optional, so this does not assume either one. + + `PythonProxySource`'s `_PmObject` -- the owner a *walked* or KSP-rendered function result + comes back wrapped in -- is deliberately **one shared class for every handle it owns**; + its own KDoc's cost table is why (a per-type subclass was ~2--9x more expensive to + construct and destruct, measured). Being shared, it cannot carry the type name on the + *class*, so `_pm_own` stamps it on the *instance* instead (`_pm_type`). + + A `pythonx` proxy (`_proxy_type`) is the opposite on purpose: it is one class **per** + Kotlin type, because `_attach` needs a distinct class to hang each receiver's extension + methods off (`_BY_RECEIVER` is keyed on it). That is already known at class-definition + time, so it is a *class* attribute (`_kotlin_type_name`), not an instance one. + + Before this, `_coerce` read `type(value)._kotlin_type_name` unconditionally and crashed + -- `AttributeError: type object '_PmObject' has no attribute '_kotlin_type_name'` -- + the first time a walked result (an `_PmObject`) was passed into a `pythonx`-adapted + call, because `_PmObject` never had that attribute and was never going to: giving it one + would mean a class per type, which is the cost `_PmObject` exists to avoid. + ''' + declared = getattr(type(value), '_kotlin_type_name', None) + if declared is not None: + return declared + return getattr(value, '_pm_type', None) + + + # --------------------------------------------------------------------------- packages (§5b) + + # **No namespace is renamed here, and there is no map that could.** A Kotlin package is + # importable under its own name -- `from androidx.compose.foundation.layout import Row` -- + # because a binder that exported `androidx.*` as `pythonx.*` would be deciding what a + # Python package is called, which is the Python package's decision and nobody else's. + # + # `pythonx-compose` is a real distribution with real modules on disk. Its code imports the + # Kotlin surface under the Kotlin name and restructures it into something Pythonic -- + # `Modifier.padding(16).background(...)` as method chaining, `to_dp(x)` where Python cannot + # attach a method to `int` (`docs/design/kotlin-extensions-in-python.md` §4.1). This file used to + # synthesise `pythonx.*` itself, with `__path__ = []` on every module it made, which took + # those names *and* stopped the real package's files from ever being found. Both are gone. + + # --------------------------------------------------------------------------- value classes (§4.4) + + # Empty, and injected only. A value class that may be written as its raw primitive is a + # fact about the library that declares it -- `pythonx-compose` says `Dp` may, and this file + # has no business knowing that Dp exists. `allow_raw_primitive` below is the whole seam. + _VALUE_CLASS_ALLOWLIST = set() + + + def allow_raw_primitive(kotlin_type_name): + '''Extend the allowlist. Manual by design: whether a wrapper *packs* has no bytecode witness.''' + _VALUE_CLASS_ALLOWLIST.add(kotlin_type_name) + + + # ------------------------------------------------------------------- callables (§6) + + _FUNCTION_PREFIX = 'kotlin.Function' + + # What `ArtifactScanner.functionSlotTypeName` appends to a slot whose declared + # `kotlin.FunctionN` was lowered by the Compose plugin into a JVM `FunctionN+2`. Not a legal + # character in a Kotlin fully-qualified name, which is the same trick `${'$'}composer` plays one + # layer up: the name is the flag, and it cannot drift out of step with the slot it describes. + _COMPOSABLE_MARK = '@Composable' + + + # `kotlin.Function3@Composable(androidx.compose.foundation.layout.ColumnScope)->kotlin.Unit`: + # what separates the types the lambda is *invoked with* from the one it has to give back. + # Neither character can occur in a Kotlin fully-qualified name, so the grammar is unambiguous + # for the same reason `@Composable` is. `ArtifactScanner.FUNCTION_RETURNS` is the writer. + _FUNCTION_RETURNS = '->' + + + def _function_slot(type_name): + '''`(jvm_arity, composable, arg_type_names, return_type_name)`, or `None`. + + The arity is the **compiled** one -- what the object will be invoked with -- because that + is what decides which `FunctionN` interface Kotlin has to hand back. For a lowered + composable lambda the last two of those are the `${'$'}composer` and the `${'$'}changed` Compose + appends to a function *type* exactly as it appends them to a function, and are not among + [arg_type_names]: those are the declaration's own, the ones Python is given. + + `None` for anything without a signature payload, which is how the walker spells a slot it + could not fully describe -- a type argument that is a type *parameter* has no name a proxy + could be built over, so the slot stays an ordinary object handle nothing can fill. + ''' + if not type_name or not type_name.startswith(_FUNCTION_PREFIX): + return None + open_at = type_name.find('(') + close_at = type_name.find(')', open_at + 1) if open_at >= 0 else -1 + if open_at < 0 or close_at < 0: + return None + if type_name[close_at + 1:close_at + 1 + len(_FUNCTION_RETURNS)] != _FUNCTION_RETURNS: + return None + head = type_name[len(_FUNCTION_PREFIX):open_at] + composable = head.endswith(_COMPOSABLE_MARK) + if composable: + head = head[:-len(_COMPOSABLE_MARK)] + if not head.isdigit(): + return None + inner = type_name[open_at + 1:close_at] + args = tuple(inner.split(',')) if inner else () + return (int(head), composable, args, type_name[close_at + 1 + len(_FUNCTION_RETURNS):]) + + + def _is_python_callable(value): + '''A Python callable that is not already something the boundary can carry. + + An `int` is excluded because it is the raw boundary's own currency for an object handle, + and a proxy is excluded because it already owns a Kotlin object -- either could in + principle be callable, and neither is what this path is for. + ''' + if getattr(value, '_pm_handle', None) is not None: + return False + if isinstance(value, int): + return False + return callable(value) + + + # How a Kotlin type named in a function slot's signature is marshalled into the invocation. + # The tags are `python.multiplatform.reflection.TypeTag`'s own names, and this list is sent to + # Kotlin so that both sides marshal from one decision rather than two -- see + # `PythonFunction.marshalAn`. + _ARG_TAGS = { + 'kotlin.Byte': 'INT', 'kotlin.Short': 'INT', 'kotlin.Int': 'INT', 'kotlin.Long': 'INT', + 'kotlin.Float': 'FLOAT', 'kotlin.Double': 'FLOAT', + 'kotlin.Boolean': 'BOOLEAN', 'kotlin.String': 'STRING', + } + + + def _arg_tag(kotlin_type_name): + '''How one forwarded argument crosses. Anything not a primitive is a handle. + + Deliberately **not** `_KOTLIN_PRIMITIVES`: that set contains `kotlin.Any` and + `kotlin.Unit`, neither of which has a marshalling rule -- an `Any` argument is whatever + Kotlin put in it and can only travel as a handle, which is what OBJECT means. + ''' + return _ARG_TAGS.get(kotlin_type_name, 'OBJECT') + + + def _positional_capacity(fn, available): + '''How many of [available] positional arguments [fn] will take, or `None` for "not any". + + A Kotlin `ColumnScope.() -> Unit` is written `{ Text(...) }` as often as `{ scope -> }`, + and a `(Float) -> Unit` is written `{ }` when the value is not wanted; a Python author + writing `lambda: Text('hi')` is making the same statement, and it was the *only* thing + they could write until this slot forwarded anything. So the arguments a callable does not + declare are dropped rather than forced on it, and a callable that wants **more** than the + slot supplies is refused -- there is no value to give it, and the failure would otherwise + arrive from inside Compose on a later recomposition. + + Measured by binding rather than by counting `__code__.co_argcount`: a bound method, a + `functools.partial` and a callable object all answer correctly through `signature` and + none of them through the code object. A callable `signature` cannot describe at all (a C + builtin) is handed everything, which is what it would have got before this existed. + ''' + try: + sig = _inspect.signature(fn) + except (TypeError, ValueError): + return available + probe = (None,) * available + for count in range(available, -1, -1): + try: + sig.bind(*probe[:count]) + except TypeError: + continue + return count + return None + + + def _adapt_arguments(values, owners, wanted): + '''The values Kotlin sent, given owners, and cut down to what the callable asked for. + + **Every** value is wrapped before any is dropped, and that order is the whole of the + lifetime rule for a forwarded object: Kotlin roots it in `HandleTable` and does not + release it, because a callback is allowed to keep what it was handed. The proxy's + `__del__` is what gives it back -- so an argument the callable did not declare must still + be given a proxy, which then dies here at the end of this call. Truncating first would + leak one `HandleTable` entry per invocation, and a composition invokes its content again + on every recomposition. + ''' + adapted = [_wrap(value, owner) for value, owner in zip(values, owners)] + return adapted[:wanted] + + + def _callable_thunk(fn, composable, arg_types, wanted): + '''Wraps a Python callable in whatever its slot's invocation convention needs. + + For a **composable** slot that is the composer: the push and the pop are here rather than + on the Kotlin side because the composer stack is this module's, and reaching it from + Kotlin would be two more boundary crossings per invocation -- one to resolve + `push_composer` and one to call it -- for a `try/finally` Python can write directly. + + **The composer is the one this invocation was given**, not the one that was current when + the lambda crossed. Compose invokes a stored content lambda again on later + recompositions, with whatever composer is current then; a thunk that closed over the + creating composer would be pushing a position that no longer exists. + + For a **plain** slot with nothing to adapt -- no arguments, or none that is an object and + none to drop -- the callable is handed over as it is, so the case that already worked + keeps costing exactly what it did. + ''' + owners = tuple(t if _arg_tag(t) == 'OBJECT' else None for t in arg_types) + if composable: + def _thunk(composer, *values): + _COMPOSER.append(composer) + try: + return fn(*_adapt_arguments(values, owners, wanted)) + finally: + _COMPOSER.pop() + return _thunk + if not any(owners) and wanted == len(arg_types): + return fn + def _plain(*values): + return fn(*_adapt_arguments(values, owners, wanted)) + return _plain + + + # Returned by `_coerce` for a callable during a *trial* bind. `_Overloads` binds every + # candidate to find out which one matches, and building a wrapper for a candidate that then + # loses would hand Kotlin a Python reference nothing ever calls -- held until the composition + # ends, because the scope is what releases it. So the wrapper is built only in the strict + # bind, and `_Overloads` re-binds its winner strictly before calling. + _CALLABLE_PENDING = object() + + + def _code_key(code): + '''One code object, described **by value**, so that two compilations of one source agree. + + This is the part `id()` cannot do, and the reason is `PythonComposition`: it `exec`s a + source *string* on every composition pass, and `exec` compiles. A `lambda` written in that + string therefore gets a brand-new code object every pass -- CPython's constant-sharing, + which is what makes two evaluations of one `lambda` statement share a code object, only + holds within a single compilation. So the code object's address changes every frame and + the only thing that does not is what the code *is*. + + `co_code` alone is not it: `lambda: Text('hi')` and `lambda: Text('bye')` compile to + identical bytecode and differ only in `co_consts`. Position alone is not it either, for + the same reason -- two different sources both have a `` line 4. The two together, + plus the names and the argument shape, are. + + Constants are recursed into rather than `repr`ed, because a nested function's constant is + a code object whose `repr` carries its address -- which would make a `content=` that + itself declares a `content=` fail to intern for a reason with nothing to do with what it + does. + ''' + parts = [ + getattr(code, 'co_qualname', None) or code.co_name, + code.co_filename, + str(code.co_firstlineno), + str(code.co_flags), + str(code.co_argcount) + '/' + str(code.co_posonlyargcount) + '/' + + str(code.co_kwonlyargcount), + code.co_code.hex(), + repr(code.co_names), repr(code.co_varnames), + repr(code.co_freevars), repr(code.co_cellvars), + ] + for const in code.co_consts: + # `hasattr(const, 'co_code')` rather than an `isinstance` against `types.CodeType`: + # this module imports nothing it does not have to, and a code object is the only + # constant that answers to it. + parts.append(_code_key(const) if hasattr(const, 'co_code') else repr(const)) + return '\x1f'.join(parts) + + + def _intern_key(value, slot): + '''What makes two crossings **the same callable in the same slot**, or `''` for "cannot say". + + ### The measurement this answers + + `RecompositionAccumulationTest` composes one body twelve times and counted, before this + existed, twelve wrappers, twelve `HandleTable` roots and twelve Python references held -- + for one `content=`. All of it comes back at disposal, so it is not a leak; it is a live UI + growing linearly in frames, and a UI recomposes several times a second. + + ### Why identity is not the criterion + + `content=lambda: Text('hi')` builds a **new function object on every pass**, so `id(value)` + is a fresh number every time and interns nothing at all -- and that is the spelling every + example in this module uses. Two lambdas written at one source position are nonetheless + the same callable in every sense that matters here, and CPython says so structurally: the + code object is stored in the enclosing code's constants and is therefore *one* object for + the life of the module. So the key is built out of what the callable is made of: + + | part | why it is in the key | + |---|---| + | `__code__`, **by value** (`_code_key`) | the body. Its *address* is no good: `PythonComposition` `exec`s a source string, so every pass compiles a new code object | + | `__globals__` | the same source in two modules is two callables | + | `__closure__` cell **contents** | `lambda: Text(n)` for two `n`s does two things | + | `__defaults__`, `__kwdefaults__` | same, spelled differently | + | `__self__` for a bound method | `a.on_click` and `b.on_click` are not interchangeable | + | the slot | a wrapper carries its invocation convention: a composable `content` threads a composer and an `onClick` does not, and one object cannot be both | + + Captured values are compared **by address, not by `==`**, so a capture that changed to an + equal-but-distinct object misses and gets its own wrapper. That is the conservative + direction: a miss costs a wrapper, a wrong hit renders a stale frame. + + ### Why addresses are sound here and would not be in a plain memo + + An `id()` is only unambiguous while its object is alive, and CPython reuses addresses + eagerly. Every key this returns is stored by `PythonCallableScope`, whose entry roots a + wrapper that holds the callable -- which holds its cells, its cell *contents*, its + defaults, its globals and its receiver. Nothing a live key names can have been freed, so + no two distinct callables can collide. A cache that did not hold its keys' objects alive + could not make that argument, which is why this table lives on the Kotlin scope. (The + code object is the exception and is keyed by value; see `_code_key` for why it has to be.) + + The one case where a cell can outlive what the key said it held is a rebound variable in + a shared enclosing frame -- and there the *cached* callable's cell is the same cell, so it + sees the new value too and the two agree anyway. + + ### What returns `''` + + A callable with no `__code__` at all -- a C builtin, a `functools.partial`, an instance + with `__call__` -- and a closure cell that is still empty (a recursive definition being + defined). Those get a wrapper per crossing, which is what everything got before. + ''' + func = value + receiver = '' + inner = getattr(value, '__func__', None) + if inner is not None: + # A bound method is a *fresh object* on every attribute access, so its own `id` is + # useless; the pair that identifies it is the underlying function and the receiver. + receiver = 's' + str(id(getattr(value, '__self__', None))) + func = inner + code = getattr(func, '__code__', None) + if code is None: + return '' + parts = ['c' + _code_key(code), 'g' + str(id(getattr(func, '__globals__', None))), receiver] + closure = getattr(func, '__closure__', None) + if closure: + for cell in closure: + try: + parts.append('z' + str(id(cell.cell_contents))) + except ValueError: + # An empty cell: the callable is not fully defined yet, so nothing here can + # describe what it will do. + return '' + defaults = getattr(func, '__defaults__', None) + if defaults: + parts.extend('d' + str(id(d)) for d in defaults) + kwdefaults = getattr(func, '__kwdefaults__', None) + if kwdefaults: + parts.extend('k' + name + ':' + str(id(kwdefaults[name])) for name in sorted(kwdefaults)) + # `repr` of the slot rather than its arity: two `Function3@Composable`s differ only in the + # receiver type they forward, which is what decides whether an argument is wrapped as a + # `ColumnScope` proxy or a `RowScope` one. + parts.append('#' + repr(slot)) + return '|'.join(parts) + + + def _make_function(value, slot): + '''Hands [value] to Kotlin as a `FunctionN`, and returns the handle of the wrapper. + + The wrapper belongs to the enclosing `PythonCallables` scope, which is the composition's: + Compose stores a `content` in the slot table and calls it on later recompositions, long + after the statement that wrote `content=lambda: ...` dropped Python's last reference to + it. A bare handle is what comes back, which is exactly what an OBJECT slot takes. + + **The scope is asked for an existing wrapper first**, and that lookup is a separate entry + point rather than a flag on `newFunction` so that a hit costs one crossing and nothing + else: `_positional_capacity` runs `inspect.signature`, which is the most expensive thing + on this path, and `_callable_thunk` allocates a closure. A recomposing composition takes + the hit path on every pass after the first, so those are exactly the costs worth not + paying twelve times for one `content=`. + + A **miss** pays for the lookup on top of everything it paid before -- one `_resolve` and + one `invoke` that answer nothing. That is the trade, and no wall-clock figure is claimed + for either side of it (`agent-rules` §11): what is measured here is allocation, in + `RecompositionAccumulationTest`, and the machine that ran it was not idle. + ''' + jvm_arity, composable, arg_types, _return_type = slot + key = _intern_key(value, slot) + if key: + found = _boundary()['invoke'](_resolve('${PythonCallables.FIND_FUNCTION}'), (key,)) + if found: + return found + wanted = _positional_capacity(value, len(arg_types)) + if wanted is None: + raise TypeError( + 'this slot invokes its callable with ' + str(len(arg_types)) + ' argument(s) (' + + ', '.join(_simple_name(t) for t in arg_types) + '), which ' + + getattr(value, '__name__', repr(value)) + ' does not accept' + ) + body = _callable_thunk(value, composable, arg_types, wanted) + return _boundary()['invoke']( + _resolve('${PythonCallables.NEW_FUNCTION}'), + (body, jvm_arity, composable, ','.join(_arg_tag(t) for t in arg_types), key), + ) + + + def _is_value_class_over_primitive(tag, type_name): + # The table cannot say "value class" -- but a parameter whose marshalling tag is a primitive + # while its *declared* type is not a Kotlin primitive is one, and that is enough. `Dp` is + # FLOAT/`androidx.compose.ui.unit.Dp`; `zIndex`'s parameter is FLOAT/`kotlin.Float`. + if type_name is None: + return False + if tag not in ('INT', 'FLOAT', 'BOOLEAN', 'STRING'): + return False + return type_name not in _KOTLIN_PRIMITIVES + + + # --------------------------------------------------------------------------- proxies + + class _ValueProxy: + '''`dp(16)` -- a value class held in Python, unwrapped to its underlying primitive on the way in.''' + + __slots__ = ('kotlin_type_name', 'raw') + + def __init__(self, kotlin_type_name, raw): + self.kotlin_type_name = kotlin_type_name + self.raw = raw + + def __repr__(self): + return _simple_name(self.kotlin_type_name) + '(' + repr(self.raw) + ')' + + + def value_of(kotlin_type_name, raw): + '''Wrap `raw` as the value class `kotlin_type_name`. + + The generic form of what used to be a `dp()` function sitting here. Naming a Compose + type in this file made `pythonx.dp` a name the *binder* owned, which put every other + library one core edit away from the same convenience. A package defines its own + spellings on top of this -- `pythonx-compose` is where `dp` belongs. + ''' + return _ValueProxy(kotlin_type_name, raw) + + + _PROXY_TYPES = {} + _EMPTY_FACTORIES = {} + + + def register_empty(kotlin_type_name, kotlin_factory_name): + '''Where a chain starts. + + `Modifier` as an *expression* is `Modifier.Companion`, an object instance, and the artefact + walker binds functions -- so the empty modifier has no bound name and `pythonx` cannot invent + one. Registering the factory by name is the seam; a proxy type with none refuses the + class-object spelling with a message that says so rather than guessing. + ''' + _EMPTY_FACTORIES[kotlin_type_name] = kotlin_factory_name + + + _MEMBER_RESOLVERS = [] + _MEMBER_ALIASES = {} # (kotlin receiver type, requested name) -> (kotlin member name, keyword map) + _MEMBER_KEYWORDS = {} # (kotlin receiver type, kotlin member name) -> keyword map ({} for none) + + + def add_member_resolver(fn): + '''Registers `fn(kotlin_type_name, requested_name, kotlin_member_names)`. + + It answers `kotlin_name`, `(kotlin_name, keyword_map)` or `None`. Asked for a name the proxy + has **no** Kotlin member of: the first resolver to return a name that is one of + `kotlin_member_names` decides, and that Kotlin member is served. Asked for a Kotlin member + name only when a call to it passes keyword arguments, and then only for its keyword map + (the answer must name that same member). `keyword_map` is `{python_kw: kotlinParam}`, + applied to that member's calls; keywords it does not name pass through unchanged. + The binder renames nothing by itself: with no resolver an unknown name is an `AttributeError` + and a keyword is a Kotlin parameter name. Answers are cached in this registry, never + written onto the proxy class, so `dir()` of a proxy shows Kotlin names only. Registering + the same `fn` twice is a no-op. + ''' + if not callable(fn): + raise TypeError('a member resolver must be callable') + if fn not in _MEMBER_RESOLVERS: + _MEMBER_RESOLVERS.append(fn) + _MEMBER_ALIASES.clear() + _MEMBER_KEYWORDS.clear() + return fn + + + def remove_member_resolver(fn): + '''Unregisters a resolver added by `add_member_resolver`; unknown functions are ignored.''' + if fn in _MEMBER_RESOLVERS: + _MEMBER_RESOLVERS.remove(fn) + _MEMBER_ALIASES.clear() + _MEMBER_KEYWORDS.clear() + + + def _resolver_answer(answer): + '''`(kotlin_name, keyword_map)` out of a resolver's answer; `(None, None)` for "not mine".''' + if isinstance(answer, tuple): + if len(answer) != 2 or not isinstance(answer[0], str) or not hasattr(answer[1], 'items'): + raise TypeError( + 'a member resolver answers kotlin_name, (kotlin_name, keyword_map) or None; got ' + + repr(answer) + ) + return answer[0], dict(answer[1]) + if isinstance(answer, str): + return answer, None + return None, None + + + def _resolve_member(cls, name): + '''`(kotlin_member_name, keyword_map_or_None)` a registered resolver maps [name] to on [cls], or `None`.''' + if not _MEMBER_RESOLVERS: + return None + key = (cls._kotlin_type_name, name) + cached = _MEMBER_ALIASES.get(key) + if cached is not None: + return cached + names = _member_names(cls._kotlin_type_name) + for resolver in tuple(_MEMBER_RESOLVERS): + target, keywords = _resolver_answer(resolver(cls._kotlin_type_name, name, names)) + if target and target in names and not target.startswith('_'): + _MEMBER_ALIASES[key] = (target, keywords) + return _MEMBER_ALIASES[key] + return None + + + def _member_keywords(type_name, name): + '''The keyword map a resolver gives the Kotlin member [name] of [type_name]; `{}` for none. + + Asked only when a call passes keywords and a resolver is registered, so a call written with + Kotlin keywords and no resolver costs what it always did. + ''' + key = (type_name, name) + cached = _MEMBER_KEYWORDS.get(key) + if cached is not None: + return cached + names = _member_names(type_name) + found = {} + for resolver in tuple(_MEMBER_RESOLVERS): + target, keywords = _resolver_answer(resolver(type_name, name, names)) + if target == name and keywords is not None: + found = keywords + break + _MEMBER_KEYWORDS[key] = found + return found + + + def _map_keywords(name, keywords, kwargs): + mapped = {} + for key, value in kwargs.items(): + target = keywords.get(key, key) + if target in mapped: + raise TypeError(name + "() got multiple values for argument '" + target + "'") + mapped[target] = value + return mapped + + + class _BoundMember: + '''An extension applied to a receiver: literally the module-level callable with slot 0 filled. + + `_keywords` is the keyword map a member resolver gave (issue #34): `None` until something + decides -- then a call with keywords asks the resolvers for this Kotlin name's map. + ''' + + __slots__ = ('_fn', '_receiver', '__name__', '_type_name', '_keywords') + + def __init__(self, fn, receiver, name, type_name=None): + self._fn = fn + self._receiver = receiver + self.__name__ = name + self._type_name = type_name + self._keywords = None + + def __call__(self, *args, **kwargs): + if kwargs: + keywords = self._keywords + if keywords is None and _MEMBER_RESOLVERS and self._type_name is not None: + keywords = _member_keywords(self._type_name, self.__name__) + if keywords: + kwargs = _map_keywords(self.__name__, keywords, kwargs) + return self._fn(self._receiver, *args, **kwargs) + + @property + def __signature__(self): + return _signature_of(self._fn.__kotlin_rows__, drop_receiver=True) + + @property + def __kotlin_rows__(self): + return self._fn.__kotlin_rows__ + + + class _Hybrid: + '''`Modifier.padding(16)` and `m.padding(16)`, from one descriptor. + + `docs/archive/pyi-generation-pythonic-stubs.md` §4.3 measured the metaclass alternative failing at run time: a + plain `def` on a metaclass is a *non-data* descriptor, so `type.__getattribute__` searches the + class's own MRO first and `Modifier.padding(16)` binds `16` to `self`. A descriptor in the class + body is found for both spellings and is told which one it is by `obj`. + ''' + + __slots__ = ('_fn', '_name') + + def __init__(self, fn, name): + self._fn = fn + self._name = name + + def __get__(self, obj, owner=None): + if owner is None: + owner = type(obj) + if obj is None: + obj = owner.empty() + return _BoundMember(self._fn, obj, self._name, getattr(owner, '_kotlin_type_name', None)) + + + def _aliased(member, keywords): + '''The member an alias answer served, carrying that answer's keyword map (if it gave one).''' + if keywords is not None and isinstance(member, _BoundMember): + member._keywords = keywords + return member + + + class _ProxyMeta(type): + + def __getattr__(cls, name): + # Only reached for a name the class does not have yet; once `_attach` puts a `_Hybrid` in + # the class dict, ordinary lookup answers and this never runs again for that name. + if name.startswith('_'): + raise AttributeError(name) + if not _attach(cls, name): + resolved = _resolve_member(cls, name) + if resolved is None: + raise AttributeError( + 'no Kotlin extension named ' + name + ' on ' + cls._kotlin_type_name + ) + return _aliased(getattr(cls, resolved[0]), resolved[1]) + return getattr(cls, name) + + def __repr__(cls): + return "" + + + def _served_types(type_name): + '''[type_name], then every type it is a, nearest first -- the order a member is looked up in. + + The ancestry is the one the table carries (`_SUPERTYPES`, read off the jar by the walker), + so a `MutableState` proxy finds what is declared on `State` and a `BitmapPainter` proxy + what extends `Painter`, and nothing here has to know a hierarchy it cannot see. + ''' + return (type_name,) + tuple(_SUPERTYPES.get(type_name, ())) + + + def _member(type_name, name): + '''What the Kotlin name [name] means on a value of [type_name]: a `_Property`, a list of + extension decls, or `None`. The nearest type that has the name wins, and on one type a + property wins over an extension function of the same name -- Kotlin resolves a member + before an extension, and a property is the member here.''' + for candidate in _served_types(type_name): + prop = _PROPERTIES.get(candidate, {}).get(name) + if prop is not None: + return prop + decls = _BY_RECEIVER.get(candidate, {}).get(name) + if decls: + return decls + return None + + + def _member_names(type_name): + '''Every Kotlin member name a value of [type_name] is served under, supertypes included.''' + names = [] + seen = set() + for candidate in _served_types(type_name): + for table in (_PROPERTIES, _BY_RECEIVER): + for name in table.get(candidate, {}): + if name not in seen: + seen.add(name) + names.append(name) + return tuple(names) + + + def _member_rows(type_name, name): + '''The table rows [name] stands for on a value of [type_name], or `None` -- reading nothing. + + What `python_multiplatform.describe_member` asks (issue #54). The same lookup `_attach` + serves by, so a description and the attribute it describes cannot disagree: a property + answers its getter row and, for a `var`, its setter row; an extension answers every + declaration its name stands for, an overload set's base name included. + ''' + found = _member(type_name, name) + if found is None: + return None + if isinstance(found, _Property): + return tuple(decl.row for decl in (found.getter, found.setter) if decl is not None) + return tuple(decl.row for decl in found) + + + def _receiver_handle(receiver, name): + handle = getattr(receiver, '_pm_handle', None) + if handle is None: + raise TypeError(name + ' is read on a Kotlin value, and ' + repr(receiver) + ' holds none') + return handle + + + def _read_property(decl, receiver): + '''One `GETTER` read: the receiver's handle in `args[0]`, nothing else (`CallableKind.GETTER`).''' + return _wrap( + _boundary()['invoke'](decl.bound_handle(), (_receiver_handle(receiver, decl.kotlin_name),)), + decl.return_type_name if decl.return_tag == 'OBJECT' else None, + ) + + + def _write_property(decl, receiver, value): + '''One `SETTER` write: the receiver's handle, then the value, coerced as any argument is.''' + name = decl.kotlin_name[:-1] if decl.kotlin_name.endswith('=') else decl.kotlin_name + tag = decl.param_tags[0] if decl.param_tags else 'OBJECT' + type_name = decl.param_type_names[0] if decl.param_type_names else None + try: + coerced = _coerce(value, tag, type_name, decl, 0, True) + except _Mismatch as mismatch: + raise TypeError(name + ' = ...: ' + str(mismatch)) from None + _boundary()['invoke'](decl.bound_handle(), (_receiver_handle(receiver, name), coerced)) + + + def _property_of(prop, name): + '''A Python `property` over one Kotlin property: read-only for a `val`, writable for a `var`. + + A data descriptor, so it answers on the instance before `__getattr__` is ever asked; every + read and write re-enters Kotlin, because a property's value is the Kotlin object's to + change (`MutableState.value` is the case that motivated this). + ''' + getter = prop.getter + + def fget(self): + return _read_property(getter, self) + + fset = None + if prop.setter is not None: + setter = prop.setter + + def fset(self, value): + _write_property(setter, self, value) + + doc = 'Kotlin property ' + getter.kotlin_name + (' (var)' if fset is not None else ' (val)') + return property(fget, fset, None, doc) + + + def _attach(cls, name): + '''Installs one Kotlin member on [cls] under its Kotlin name, and reports whether there was one. + + An extension function becomes a `_Hybrid` method; a property (issue #38) becomes a Python + `property`. Both are looked up on [cls]'s type and then on each type it is a (`_member`). + ''' + found = _member(cls._kotlin_type_name, name) + if found is None: + return False + if isinstance(found, _Property): + setattr(cls, name, _property_of(found, name)) + else: + setattr(cls, name, _Hybrid(_callable_for(name, found), name)) + cls._kotlin_attached.append(name) + return True + + + def _proxy_type(kotlin_type_name): + '''The Python class that owns a handle to a Kotlin [kotlin_type_name], created once.''' + existing = _PROXY_TYPES.get(kotlin_type_name) + if existing is not None: + return existing + + release = _boundary()['release'] + + def __init__(self, handle, _set=object.__setattr__): + # Past `__setattr__` below: `_pm_handle` is never a Kotlin name, and construction is + # the hot path the proxy's cost table prices. + _set(self, '_pm_handle', handle) + + def __del__(self, _release=release): + # The other half of `HandleTable`'s contract, and the thing + # `docs/design/kotlin-extensions-in-python.md` §3.2 recorded as missing: "three handles leak per + # run of that test, deliberately, because owning them is what §4.1's proxy is for and + # §4.1's proxy does not exist yet." This is that proxy. + handle = getattr(self, '_pm_handle', None) + if handle is not None: + _release(handle) + + def __getattr__(self, name): + if name.startswith('_'): + raise AttributeError(name) + if not _attach(type(self), name): + resolved = _resolve_member(type(self), name) + if resolved is None: + raise AttributeError( + 'no Kotlin extension named ' + name + ' on ' + type(self)._kotlin_type_name + ) + return _aliased(getattr(self, resolved[0]), resolved[1]) + return getattr(self, name) + + def __setattr__(self, name, value, _set=object.__setattr__): + # A Kotlin property is installed on the class lazily, by `_attach`, the first time it is + # *read* (`__getattr__`). A write that comes first would otherwise find no descriptor + # and land in the instance dict -- Kotlin never told, and every later read answered + # from that dict instead of Kotlin (issue #38: `state.value = 7` was "accepted"). + # So a write attaches first; `object.__setattr__` then goes through the `property` + # (its setter, or Python's own refusal for a `val`). A name no Kotlin member has is + # stored on the instance as before. + if not name.startswith('_'): + cls = type(self) + if name not in cls.__dict__: + _attach(cls, name) + _set(self, name, value) + + def __repr__(self): + return '<' + _simple_name(kotlin_type_name) + ' handle=' + repr(self._pm_handle) + '>' + + @classmethod + def empty(cls): + factory = _EMPTY_FACTORIES.get(kotlin_type_name) + if factory is None: + raise TypeError( + 'no empty ' + _simple_name(kotlin_type_name) + ' is bound: call ' + "python_multiplatform.binding.register_empty('" + kotlin_type_name + "', '') " + 'or start the chain from a value Kotlin handed you' + ) + return _wrap(_boundary()['invoke'](_resolve(factory), ()), kotlin_type_name) + + body = { + '__init__': __init__, + '__del__': __del__, + '__getattr__': __getattr__, + '__setattr__': __setattr__, + '__repr__': __repr__, + 'empty': empty, + '_kotlin_type_name': kotlin_type_name, + '_kotlin_attached': [], + '__qualname__': _simple_name(kotlin_type_name), + } + cls = _ProxyMeta(_simple_name(kotlin_type_name), (object,), body) + _PROXY_TYPES[kotlin_type_name] = cls + return cls + + + def _wrap(result, kotlin_type_name): + '''A `TypeTag.OBJECT` result: a handle integer, given an owner. + + A `bool` is not a handle even though it is an `int`: the boundary never sends one for a + Kotlin object, and a `True` read as handle 1 would name some unrelated root. + + A value read as `kotlin.Any` may be a boxed scalar (issue #69); `_from_any` asks. + ''' + if kotlin_type_name is None or isinstance(result, bool) or not isinstance(result, int): + return result + if kotlin_type_name == _KOTLIN_ANY: + return _from_any(result) + return _proxy_type(kotlin_type_name)(result) + + + def _from_any(handle): + '''The handle of a value Kotlin holds as `kotlin.Any`: the Python scalar, or a proxy. + + ### Why this has to ask Kotlin + + A Kotlin `Int` in an `Any` crosses as a handle like every other Kotlin object, and an `int` + is all Python receives either way -- so nothing on this side can tell `MutableState`'s + 7 from the handle of a `Modifier`. Kotlin can: `PythonCallables.unboxScalar` looks the + handle up, and for a boxed `Int`/`Long`/`Short`/`Byte`/`Double`/`Float`/`Boolean`/ + `String`/`Char` it **releases** the root and answers the Python scalar (a new reference, + built by the trampoline). For anything else it answers `None` and the root stays, owned + by the proxy built here exactly as before. + + The cost is one more crossing per `kotlin.Any` read that is not a Python object. A Python + object Kotlin holds (`state.value = root`) comes back as itself, not as an `int`, and + never reaches this function. + ''' + scalar = _boundary()['invoke'](_resolve('${PythonCallables.UNBOX_SCALAR}'), (handle,)) + if scalar is None: + return _proxy_type(_KOTLIN_ANY)(handle) + return scalar + + + # --------------------------------------------------------------------------- calling + + # What an unbounded Kotlin type parameter is read as (issue #38): `mutableStateOf`'s `value`, + # `MutableState.value`. The one OBJECT slot a Python object can fill as itself. + _KOTLIN_ANY = 'kotlin.Any' + + # The tags whose Kotlin side is a reference, so `None` can reach it as `null`. A number or a + # Boolean cannot: `TypeTag.INT` narrows a `Long`, and the walker declines a nullable one. + _NULLABLE_TAGS = ('STRING', 'BYTES', 'OBJECT') + + # Issue #69: which Kotlin integer an `int` written into a `kotlin.Any` slot becomes. `Int` when + # it fits in 32 bits -- what `mutableStateOf(0)` is in Kotlin -- else `Long`; outside 64 bits + # there is no Kotlin integer to box it as, and the write is refused. + _INT_MIN = -(1 << 31) + _INT_MAX = (1 << 31) - 1 + _LONG_MIN = -(1 << 63) + _LONG_MAX = (1 << 63) - 1 + + # What `_coerce` puts in a scalar's slot during a *trial* bind, for `_CALLABLE_PENDING`'s + # reason: boxing crosses into Kotlin and roots the box, which a candidate that then loses + # has no business doing. The winner is re-bound strictly before the call. + _SCALAR_PENDING = object() + + + class _BoxedArgument(int): + '''The handle of a Kotlin box, on its way into one `kotlin.Any` slot of one call. + + An `int` because that is what the slot reads a Kotlin object as (`UpcallTrampoline + .toKotlinObject`); a subclass so that it can give the root back. It lives in the argument + tuple, so it dies -- and releases -- when the call that tuple was built for is over, by + which time Kotlin holds the box by an ordinary reference (the state's field, say) and + needs the root no more. Released earlier, the slot would resolve a stale handle. + ''' + + __slots__ = () + + def __del__(self): + try: + _boundary()['release'](int(self)) + except Exception: + # `__del__` cannot raise usefully, and at interpreter teardown the boundary may + # already be gone; the root then goes with `HandleTable` itself. + pass + + + def _box_scalar(value, strict): + '''A Python scalar for a `kotlin.Any` slot, as the handle of the Kotlin box it means (#69). + + `bool` before `int`, because a `bool` *is* an `int`. A subclass of a scalar type is boxed as + the base type's value (`int(value)`), never sent as itself: an `int` subclass reaching the + slot would be read as a handle. + ''' + if isinstance(value, bool): + entry = '${PythonCallables.BOX_BOOLEAN}' + carried = bool(value) + elif isinstance(value, int): + if _INT_MIN <= value <= _INT_MAX: + entry = '${PythonCallables.BOX_INT}' + elif _LONG_MIN <= value <= _LONG_MAX: + entry = '${PythonCallables.BOX_LONG}' + else: + return _refuse( + strict, + 'an int outside 64 bits cannot be stored in a Kotlin Any?: no Kotlin integer ' + 'holds ' + repr(value), + ) + carried = int(value) + elif isinstance(value, float): + entry = '${PythonCallables.BOX_DOUBLE}' + carried = float(value) + else: + entry = '${PythonCallables.BOX_STRING}' + carried = str(value) + if not strict: + return _SCALAR_PENDING + return _BoxedArgument(_boundary()['invoke'](_resolve(entry), (carried,))) + + + def _coerce(value, tag, type_name, decl, slot, strict): + '''One argument on its way in. Returns the marshalled value, or raises when `strict`.''' + if value is None: + # `None` written for a slot that has no default to fall back on: Kotlin's `null`, which + # a nullable declaration takes (`Icon(..., contentDescription=None)`) and a non-null + # one refuses itself. A defaulted slot never gets here -- `_bind` reads `None` there as + # "leave it to Kotlin's default". + if tag in _NULLABLE_TAGS: + return None + return _refuse(strict, 'None is not a ' + _simple_name(type_name or tag)) + if tag == 'OBJECT' and type_name == _KOTLIN_ANY and not isinstance(value, _ValueProxy): + # `Any?` holds whatever it is given. A proxy is a Kotlin object and goes as its handle. + # A Python scalar is boxed as the Kotlin type it means (issue #69, `_box_scalar`): + # `mutableStateOf(0)` holds a Kotlin `Int`, which Compose and every Kotlin reader + # expects. Sent as itself it could not cross at all -- the boundary reads every int in + # an OBJECT slot as a handle. Any other Python object goes as itself, and Kotlin keeps + # it as a `PyObject` (`UpcallTrampoline.toKotlinObject`) -- which is how a Python root + # function lands in a Compose `MutableState`. + handle = getattr(value, '_pm_handle', None) + if handle is not None: + return handle + if isinstance(value, (int, float, str)): + return _box_scalar(value, strict) + return value + if isinstance(value, _ValueProxy): + if type_name is not None and value.kotlin_type_name != type_name: + return _refuse( + strict, + 'expected ' + _simple_name(type_name) + ' but got ' + + _simple_name(value.kotlin_type_name), + ) + return value.raw + if tag == 'OBJECT' and type_name == _PY_OBJECT: + # A parameter declared as the Python object type takes the Python object itself -- + # a proxy included, which must **not** be unwrapped to the Kotlin object behind it. + # The same rule `PythonProxySource.argValues` applies (`PY_OBJECT`), so a call reaches + # Kotlin with the same argument whichever layer served it. + return value + if tag == 'OBJECT': + # A Kotlin function type is the one OBJECT slot Python can fill with something it + # made itself. Everything else in this branch requires a handle, because a Kotlin + # object is the only thing a Kotlin parameter can hold; a function is the exception + # because Kotlin can be given one that calls back. + slot = _function_slot(type_name) + if slot is not None and _is_python_callable(value): + # A Python callable cannot stand in for a lambda that has to give something back. + # `kotlin.Function1` is the spelling of both `(Float) -> Unit` and + # `(Float) -> Boolean`, and only the return type in the name tells them apart: + # `TypeTag` has one INT for `Byte` through `Long`, so nothing on either side can + # say which boxed type Kotlin will cast the answer to, and guessing would be a + # `ClassCastException` inside Compose rather than a refusal here. + if slot[3] != 'kotlin.Unit': + return _refuse( + strict, + 'a Kotlin lambda returning ' + _simple_name(slot[3]) + ' cannot be ' + 'written in Python yet: the boundary carries one INT for every integral ' + 'width, so the type Kotlin would cast the result to is not recoverable', + ) + if not strict: + return _CALLABLE_PENDING + return _make_function(value, slot) + if slot is not None and getattr(value, '_pm_handle', None) is None: + # A function slot and something that is not callable. Refused with the signature + # rather than falling through to "expected a handle": Kotlin will never hand a + # `FunctionN` back out for Python to pass in again, so a handle is not the answer + # here and saying so would send the caller looking for one. + return _refuse(strict, 'expected a callable of ' + _simple_name(type_name)) + handle = getattr(value, '_pm_handle', None) + if handle is not None: + declared = _declared_type_name(value) + # `declared is None` means the owner could not say what it holds -- an `_PmObject` + # built by a rendered *class*'s `__init__` (no `_pm_own` call, so no `_pm_type`) + # rather than by a function's owned result. That is not a refusal: a bare `int` + # handle a few lines below is already trusted with no type check at all in strict + # mode, so an owned-but-untyped value gets the same trust rather than a stricter + # rule than the boundary's own currency. + # Subtyping, not equality: `BitmapPainter` fills a `Painter` slot. The ancestry + # is the value's own -- carried on the declaration that produced it, read off the + # jar by `ArtifactScanner.nameablePublicSupertypesOf` -- so nothing here has to + # know a hierarchy it cannot see. A value with no ancestry recorded is still + # matched by name alone, which is what every KSP-produced row is. + if type_name is not None and declared is not None and not _is_a(declared, type_name): + return _refuse( + strict, + 'expected ' + _simple_name(type_name) + ' but got ' + _simple_name(declared), + ) + return handle + if isinstance(value, int) and not isinstance(value, bool): + # A bare handle, the raw boundary's own currency -- accepted when the caller already + # said which declaration they meant, and **not** allowed to vote in a dispatch: an + # integer is indistinguishable from a number, so letting it match an OBJECT slot would + # make every arity-matched overload a candidate for `padding(m, 16)`. + if strict: + return value + return _refuse(strict, 'a bare handle cannot select an overload') + return _refuse(strict, 'expected a ' + _simple_name(type_name) + ' handle') + if tag in ('INT', 'FLOAT'): + if isinstance(value, bool) or not isinstance(value, (int, float)): + return _refuse(strict, 'expected a number for ' + _simple_name(type_name or tag)) + if _is_value_class_over_primitive(tag, type_name): + if type_name not in _VALUE_CLASS_ALLOWLIST: + # §4.4: a packed value class decodes a raw number as something else entirely and + # renders wrong without raising. `TextUnit(16)` is `Unspecified`, not 16 of + # anything. Refusing is the only answer that reports itself. + return _refuse( + strict, + 'a raw number is not accepted for ' + _simple_name(type_name) + + ': it is a value class and not on the raw-primitive allowlist, so ' + repr(value) + + ' would be reinterpreted rather than converted', + ) + return float(value) if tag == 'FLOAT' else int(value) + if tag == 'BOOLEAN': + if not isinstance(value, bool): + return _refuse(strict, 'expected a bool') + return value + if tag == 'STRING': + if not isinstance(value, str): + return _refuse(strict, 'expected a str') + return value + if tag == 'BYTES': + if not isinstance(value, (bytes, bytearray)): + return _refuse(strict, 'expected bytes') + return bytes(value) + return value + + + class _Mismatch(Exception): + pass + + + _NO_MATCH = object() + + + def _refuse(strict, message): + if strict: + raise _Mismatch(message) + return _NO_MATCH + + + # ------------------------------------------------------------------- @Composable (§5) + + # One composer per composition, pushed by the single hand-written Kotlin entry point and + # popped when it returns. A list rather than a scalar because a composition can nest -- + # a Python composable that calls another Kotlin container that calls back into Python -- + # and the innermost is the one a call belongs to. + _COMPOSER = [] + + + # Compose's own `BITS_PER_INT`: one `${'$'}default` bit per parameter, 31 to an `int`. Every + # composable measured (281 of them, `ComposableBindingTest`) fits one word, so the second + # word is written by the arithmetic and not by anything that has been observed. + _DEFAULT_BITS_PER_WORD = 31 + + + def push_composer(handle): + '''Called from Kotlin, from inside a composition, with a handle to the live `Composer`. + + This is the one value Python cannot invent: `${'$'}composer` exists only inside a composition, + which is why one hand-written `@Composable` entry point exists at all. Everything else + about calling a composable -- which arguments were written, what mask that implies -- is + arithmetic Python does here. + + **The handle is the caller's to keep alive.** Nothing here retains it, so a caller that + pushes a proxy and drops its last reference has released the composer; the next call + through the boundary then fails with "stale or unknown Kotlin object handle". Kotlin's + entry point holds it for the composition's lifetime by construction, which is the only + caller this is written for. + ''' + # Normalised to the raw boundary currency here rather than at every use: Kotlin pushes an + # integer handle, but a Python caller that got its composer out of a bound declaration + # holds a proxy wrapping one, and the `${'$'}composer` slot is filled without going through + # `_coerce` (there is nothing to decide about it). + _COMPOSER.append(getattr(handle, '_pm_handle', handle)) + return len(_COMPOSER) + + + def pop_composer(): + _COMPOSER.pop() + return len(_COMPOSER) + + + def current_composer(): + if not _COMPOSER: + raise RuntimeError( + 'no composer is in scope: a @Composable can only run inside a composition, and ' + 'nothing has pushed one. Call it from the body Kotlin passed to the composable ' + 'entry point.' + ) + return _COMPOSER[-1] + + + def _absent(tag): + '''What goes in a slot the `${'$'}default` mask says the callee will overwrite. + + It is never read -- the generated prologue of every composable assigns over it before its + first use, which is what the mask *means* -- but it still has to survive the boundary and + the thunk's unboxing. A primitive slot therefore gets a zero of the right shape rather + than `None`, which would reach `Number.intValue()` and raise. + ''' + if tag == 'INT': + return 0 + if tag == 'FLOAT': + return 0.0 + if tag == 'BOOLEAN': + return False + return None + + + def _bind_composable(decl, args, kwargs, strict): + '''`_bind` for a `@Composable`, where omission is a bitmask and not a sentinel. + + ### The encoding, and how it was checked + + `${'$'}default` bit *i* set means "parameter *i* was not passed, use its declared default". + Read out of the callee rather than assumed: `javap -c androidx/compose/material3/TextKt` + shows `Text-fLXpl1I` opening with + + iload ${'$'}default; iconst_2; iand; ifeq +10 + getstatic androidx/compose/ui/Modifier.Companion + astore_1 + + -- `${'$'}default & 2` guarding the assignment to parameter 1 (`modifier`), `& 4` guarding + parameter 2 (`color`), `& 8` parameter 3, and so on with no gaps. Bits are assigned to + *every* parameter in declaration order, including ones that declare no default (`text` + owns bit 0 and nothing ever sets it), so the bit index is the parameter index and needs + no correction. + + ### Except for a receiver, which is the one correction + + `${'$'}default` bits are assigned over **value** parameters, and an extension composable's + receiver is not one. Measured the same way, on `androidx/compose/material3/NavigationBarKt` + -- `RowScope.NavigationBarItem(selected, onClick, icon, modifier = …)` guards its + `modifier` with `${'$'}default & 8`, the **4th** value parameter's bit, although `modifier` + is slot 4 of the binding once the receiver is counted. The receiver is given bit **31** + instead (`& -2147483648` in the prologue), which nothing here ever sets because a receiver + cannot be omitted. + + `${'$'}changed` numbers the other way -- the receiver *is* slot 0 there -- and it does not + matter, because `${'$'}changed` is passed as 0. That is the conservative value: it is a + per-call-site claim about which arguments the *caller* knows to be unchanged, and this + caller knows nothing. The callee then computes it with `composer.changed(...)` itself, + which is the branch its prologue takes when `${'$'}changed & mask == 0`. + ''' + declared = decl.declared_arity() + if len(args) > declared: + return _refuse(strict, 'takes ' + str(declared) + ' arguments, got ' + str(len(args))) + slots = list(args) + [_NO_MATCH] * (decl.arity - len(args)) + # How far a `${'$'}default` bit index sits behind its slot index. See the docstring: 1 for an + # extension composable, 0 otherwise. + bit_offset = 1 if decl.is_extension else 0 + if kwargs: + for key, value in kwargs.items(): + index = -1 + for slot in range(declared): + # `` is deliberately not a Python identifier, so no keyword can name + # it. Every other slot answers to its Kotlin parameter name, exactly. + if decl.param_names[slot] == '': + continue + if decl.param_names[slot] == key: + index = slot + break + if index < 0: + return _refuse(strict, 'has no parameter named ' + key) + if slots[index] is not _NO_MATCH: + return _refuse(strict, 'got two values for ' + key) + slots[index] = value + mask = [0] * len(decl.default_slots) + omitted = 0 + for index in range(declared): + value = slots[index] + # `None` means "Kotlin's default" only where there is one; written for a slot that has + # none it is a value -- `null` -- and `_coerce` decides whether the slot takes it. + if value is _NO_MATCH or value is KOTLIN_DEFAULT or (value is None and decl.omittable(index)): + if not decl.omittable(index): + missing = decl.param_names[index] if decl.param_names else 'argument ' + str(index) + return _refuse(strict, 'no value for ' + missing) + word, bit = divmod(index - bit_offset, _DEFAULT_BITS_PER_WORD) + if word >= len(mask): + return _refuse(strict, 'no ${'$'}default word covers parameter ' + str(index)) + mask[word] |= 1 << bit + slots[index] = _absent(decl.param_tags[index] if decl.param_tags else 'OBJECT') + omitted += 1 + continue + tag = decl.param_tags[index] if decl.param_tags else 'OBJECT' + type_name = decl.param_type_names[index] if decl.param_type_names else None + coerced = _coerce(value, tag, type_name, decl, index, strict) + if coerced is _NO_MATCH: + return _NO_MATCH + slots[index] = coerced + slots[decl.composer_index] = current_composer() + for index in decl.changed_slots: + slots[index] = 0 + for position, index in enumerate(decl.default_slots): + slots[index] = mask[position] + return (tuple(slots), omitted) + + + def _bind(decl, args, kwargs, strict): + '''Maps a Python call onto [decl]'s positional slots. + + Returns `(slots, defaults_used)`, or `_NO_MATCH` when not `strict`; raises `_Mismatch` + when `strict`. + + `defaults_used` is how many slots this call left to Kotlin, and it exists for `_Overloads` + -- see there. It is not a diagnostic. + ''' + if decl.composer_index >= 0: + return _bind_composable(decl, args, kwargs, strict) + if len(args) > decl.arity: + return _refuse(strict, 'takes ' + str(decl.arity) + ' arguments, got ' + str(len(args))) + slots = list(args) + [_NO_MATCH] * (decl.arity - len(args)) + if kwargs: + if not decl.param_names: + return _refuse(strict, 'the producer supplied no parameter names, so it is positional only') + for key, value in kwargs.items(): + index = -1 + for slot, name in enumerate(decl.param_names): + if name != '' and name == key: + index = slot + break + if index < 0: + return _refuse(strict, 'has no parameter named ' + key) + if slots[index] is not _NO_MATCH: + return _refuse(strict, 'got two values for ' + key) + slots[index] = value + defaults_used = 0 + for index, value in enumerate(slots): + omittable = decl.omittable(index) + if value is _NO_MATCH or value is KOTLIN_DEFAULT or (value is None and omittable): + if not omittable: + missing = decl.param_names[index] if decl.param_names else 'argument ' + str(index) + return _refuse(strict, 'no value for ' + missing) + # `None` is the whole mechanism, and it is not a value being passed: the generated + # Kotlin body tests `args[i] == null` and takes a branch whose call expression does + # not mention this parameter at all, so the *compiler* supplies the default. + # `docs/archive/pythonx-adapter-design.md` §4.5 -- metadata carries the flag and never the + # expression, so this is the only place the default value can come from. + # + # Only for a slot that **has** a default. Written for a slot without one, `None` + # is a value -- Kotlin's `null` -- and `_coerce` passes it for a reference tag + # (STRING, BYTES, OBJECT: `Icon(..., contentDescription=None)`) and refuses it for + # a number or a Boolean. `resolveKotlinType` declines a nullable number, Boolean + # and value class outright, so a defaulted nullable `String`/`ByteArray`/object is + # the one case where "omitted" and `null` share a spelling here; that is exact for + # a slot whose Kotlin default *is* `null`, which every such slot measured so far + # has, and it is a refusal rather than a wrong value for one that is not. + slots[index] = None + defaults_used += 1 + continue + tag = decl.param_tags[index] if decl.param_tags else 'OBJECT' + type_name = decl.param_type_names[index] if decl.param_type_names else None + coerced = _coerce(value, tag, type_name, decl, index, strict) + if coerced is _NO_MATCH: + return _NO_MATCH + slots[index] = coerced + return (tuple(slots), defaults_used) + + + class _Binding: + '''One Kotlin declaration, resolved once, callable from Python.''' + + __slots__ = ('_decl', '__name__', '__qualname__') + + def __init__(self, decl): + self._decl = decl + self.__name__ = decl.leaf + self.__qualname__ = decl.kotlin_name + + @property + def __signature__(self): + # Built when asked for -- `inspect.signature` reads it -- and never at adaptation. + return _signature_of((self._decl.row,)) + + @property + def __kotlin_rows__(self): + return (self._decl.row,) + + def __call__(self, *args, **kwargs): + decl = self._decl + try: + bound, _ = _bind(decl, args, kwargs, True) + except _Mismatch as mismatch: + raise TypeError(decl.signature() + ': ' + str(mismatch)) from None + return _wrap( + _boundary()['invoke'](decl.bound_handle(), bound), + decl.return_type_name if decl.return_tag == 'OBJECT' else None, + ) + + + class _Overloads: + '''The dispatcher `docs/design/kotlin-extensions-in-python.md` §3.1 says has to live here. + + The walker refuses to arbitrate between overloads because it has only a name to go on; Python + has the arguments. Selection is on argument count, on keyword names, and on declared type -- + and when that still does not separate them, this refuses and names the candidates rather than + picking one, which is the same rule the walker applies for the same reason. + + **Defaults made almost every call ambiguous, so there is one more rule.** Once + `padding(horizontal =, vertical =)` accepts a single argument, `padding(m, 16)` binds against + it, against `padding(all =)` and against `padding(start =, top =, end =, bottom =)` -- three + candidates for a call that has exactly one obvious meaning. The tie-break is Kotlin's own and + not an invention here: **a candidate that fills no default beats one that does**, and the + comparison extends to a count so that it is a total order. `Modifier.padding(16.dp)` resolves + to `padding(all:)` in Kotlin for the same reason it does here. + + Where the fewest-defaults score is *equal*, nothing has changed: this still refuses and names + the candidates. It also parts company with `kotlinc` in one direction and does so knowingly -- + Kotlin reports `padding()` with no arguments as ambiguous between the two- and four-`Dp` + overloads, where this picks the two-`Dp` one because it fills fewer. Both reach + `padding(0.dp, 0.dp)`; refusing a call every candidate agrees about would be the worse answer. + ''' + + __slots__ = ('_decls', '__name__') + + def __init__(self, name, decls): + self._decls = decls + self.__name__ = name + + @property + def __signature__(self): + # An overload set has no single signature; `describe` lists every candidate's. + return _signature_of(tuple(decl.row for decl in self._decls)) + + @property + def __kotlin_rows__(self): + return tuple(decl.row for decl in self._decls) + + def __call__(self, *args, **kwargs): + matched = [] + for decl in self._decls: + bound = _bind(decl, args, kwargs, False) + if bound is not _NO_MATCH: + matched.append((decl, bound[0], bound[1])) + if matched: + fewest = min(entry[2] for entry in matched) + matched = [entry for entry in matched if entry[2] == fewest] + if len(matched) == 1: + decl = matched[0][0] + # Re-bound strictly, and the trial binding above is discarded. A trial bind is a + # question -- *would* this candidate accept these arguments -- and a coercion + # with a side effect must not answer it: `_coerce` hands a Python callable to + # Kotlin, and doing that for a candidate that then loses would leave a Python + # reference held for the life of the composition with nothing ever calling it. + # `_CALLABLE_PENDING` is what a trial bind puts in such a slot instead, and this + # is why it never reaches the boundary. + bound, _ = _bind(decl, args, kwargs, True) + return _wrap( + _boundary()['invoke'](decl.bound_handle(), bound), + decl.return_type_name if decl.return_tag == 'OBJECT' else None, + ) + candidates = ', '.join(decl.signature() for decl in self._decls) + spellings = ', '.join(decl.leaf for decl in self._decls) + if not matched: + raise TypeError( + 'no overload of ' + self.__name__ + ' accepts these arguments. Candidates: ' + + candidates + '. Call one by name: ' + spellings + ) + raise TypeError( + 'the arguments to ' + self.__name__ + ' match more than one overload (' + + ', '.join(entry[0].signature() for entry in matched) + + '). Nothing arbitrates -- call one by name: ' + spellings + ) + + + def _callable_for(name, decls): + if len(decls) == 1: + return _Binding(decls[0]) + return _Overloads(name, decls) + + + # --------------------------------------------------------------------------- modules (§2.3) + + def _read_constant(decl): + '''The current value of a `kind == 'STATIC_GETTER'` declaration -- invoked, not called. + + The body `_Binding.__call__` would run for a zero-argument call, minus the call: `args` + is always empty (`CallableKind.STATIC_GETTER.hasReceiver` is `False` and the scanner + gives it no parameters either), so there is nothing to `_bind`. + ''' + return _wrap( + _boundary()['invoke'](decl.bound_handle(), ()), + decl.return_type_name if decl.return_tag == 'OBJECT' else None, + ) + + + def _is_function_set(decls): + '''True when every declaration is a callable (`FUNCTION`), none a constant.''' + return all(decl.kind == 'FUNCTION' for decl in decls) + + + def _module_call(self, *args, **kwargs): + '''Calls the function or overload set that shares this module's Kotlin name (issue #78). + + Resolved on every call, from the table, so a table that changed after the module was made + callable is followed and one that no longer holds the function is a TypeError. + ''' + parent, _, leaf = self._kotlin_package.rpartition('.') + decls = _BY_PACKAGE.get(parent, {}).get(leaf) + if not decls or not _is_function_set(decls): + raise TypeError("'" + self.__name__ + "' is a Kotlin object, not a function") + decl = decls[0] + target = _callable_named(decl.kotlin_name) if len(decls) == 1 else None + if target is None: + target = _callable_for(leaf, decls) + return target(*args, **kwargs) + + + def _make_callable(module, kotlin_package): + '''Makes [module] callable when its Kotlin name is also a function in its parent package. + + `__call__` is looked up on the type, so the module is reclassed onto a subclass of its own + type -- the same move the proxy layer's `_pm_module_type` makes, and marked `_pm_owned` + the same way so that layer reuses this type instead of replacing it. Per module, so the + hook exists on exactly the modules whose name is a function. + ''' + parent, _, leaf = kotlin_package.rpartition('.') + decls = _BY_PACKAGE.get(parent, {}).get(leaf) if parent else None + if not decls or not _is_function_set(decls): + return + kind = type(module) + if not getattr(kind, '_pm_owned', False): + kind = type( + '_PmModule_' + module.__name__.replace('.', '_'), + (kind,), + {'_pm_owned': True}, + ) + module.__class__ = kind + kind.__call__ = _module_call + + + def _adapt(kotlin_package, name): + '''The value or callable the Kotlin name [name] means in [kotlin_package], and whether + `_module_getattr` may freeze it into the module dict. + + A `kind == 'STATIC_GETTER'` decl is read rather than called (see `_read_constant`) and + is never cacheable: `ArtifactScanner.constantsOf` binds every public property of an + `object`/companion it can reach, `var` included, and carries nothing that says which are + which. A `var`'s value can change between two reads -- an object holding a mutable + counter, say -- so the generic "resolved once, then a module-dict hit" cache every other + adapted name gets here is refused for this one kind, and every read re-enters Kotlin. + ''' + decls = _BY_PACKAGE.get(kotlin_package, {}).get(name) + if decls and _is_function_set(decls) and kotlin_package + '.' + name in _PACKAGES_SEEN: + # Issue #78: Kotlin has both `TextRange(2)` and `TextRange.Zero`. The name is the + # child module -- whose attributes are the companion's -- and that module is + # callable (`_make_callable`), dispatching to the function or overload set. One + # object answers both, whichever of the two layers or imports reached it first. + _child = _importlib.import_module(kotlin_package + '.' + name) + if getattr(_child, '_kotlin_package', None) != kotlin_package + '.' + name: + _adapt_module(_child, kotlin_package + '.' + name) + return _child, True + if decls: + if len(decls) == 1 and decls[0].kind == 'STATIC_GETTER': + return _read_constant(decls[0]), False + if len(decls) == 1 and decls[0].kind == 'FUNCTION': + # The same object `_callable_named` hands the proxy layer, so a name reaches + # one callable whichever route found it. + return _callable_named(decls[0].kotlin_name) or _Binding(decls[0]), True + return _callable_for(name, decls), True + qualified = kotlin_package + '.' + name + if name[:1].isupper() and (qualified in _BY_RECEIVER or qualified in _PROXY_TYPES): + # A type, not a declaration: `androidx.compose.ui.Modifier` is the receiver proxy. + return _proxy_type(qualified), True + if qualified in _PACKAGES_SEEN: + # Issue #35: a child package or object, imported on first read, so + # `androidx.compose.ui.Alignment.End` needs no `import` of `Alignment` first. The import + # system would bind the same module onto this one after an explicit import anyway. + return _importlib.import_module(qualified), True + return None, False + + + def _module_getattr(module, kotlin_package): + def __getattr__(name): + if name.startswith('__') and name.endswith('__'): + raise AttributeError(name) + adapted, cacheable = _adapt(kotlin_package, name) + if adapted is None: + raise AttributeError( + "module '" + module.__name__ + "' has no attribute '" + name + + "' (nothing named that is exposed from Kotlin package " + kotlin_package + ')' + ) + if not cacheable: + # A live property (see `_adapt`): every read has to re-enter Kotlin, so the name + # is deliberately left out of the module dict and this hook runs again next time. + return adapted + # Adapted once. Every later read is an ordinary module-dict hit and this never runs again + # for this name -- which is why the 551-587 ns figure for a live-property `__getattr__` + # does not price this one. + setattr(module, name, adapted) + module._kotlin_adapted[name] = adapted + return adapted + + return __getattr__ + + + def _module_dir(kotlin_package): + def __dir__(): + names = set(_BY_PACKAGE.get(kotlin_package, {})) + names.update(_CHILDREN.get(kotlin_package, ())) + for type_name in _BY_RECEIVER: + package, _, leaf = type_name.rpartition('.') + if package == kotlin_package: + names.add(leaf) + return sorted(names) + + return __dir__ + + + def _adapt_module(module, kotlin_package): + '''Attach this layer's half to `module`, whoever created it. + + **One module object per Kotlin package, shared.** `PythonProxySource` puts proxy + classes into `sys.modules` under the same Kotlin names this layer serves top-level + declarations and extensions under -- and CPython returns a `sys.modules` hit before it + consults any finder, so whichever of the two got there first used to win and the other + was simply absent. That was invisible only while this layer answered to `pythonx.*` + instead: two names for one package, and each half in a different one. + + **Re-attached rather than skipped when it is already adapted.** A module lives in + `sys.modules` for the life of the interpreter, while a fresh `install()` builds a fresh + table -- so a module adapted by an earlier install closes over dictionaries the current + one has replaced, and every name in it resolves to nothing. This was invisible while + the modules were called `pythonx.*`, because dropping that one root cleared them all; + a module named after its Kotlin package has no such root to drop. + ''' + module.__path__ = getattr(module, '__path__', []) + module._kotlin_package = kotlin_package + # Names adapted by a previous install are stale with it; the fresh `__getattr__` below + # re-resolves each on first use. Only this layer's own -- see `_drop_adapted`. + _drop_adapted(module) + if kotlin_package is None: + # A step on the way to a package that has bindings, carrying none of its own. + return module + _MODULES.append(module) + module.__getattr__ = _module_getattr(module, kotlin_package) + module.__dir__ = _module_dir(kotlin_package) + _make_callable(module, kotlin_package) + return module + + + def adapt_existing_modules(): + '''Adapt every already-imported module a bound package names. + + The finder below only ever sees a name `sys.modules` does not already hold, so a module + `PythonProxySource` created first would never reach it. Called at install time, and + again after any table change, so the two halves meet however they are ordered. + ''' + for name in list(_PACKAGES_SEEN): + module = _sys.modules.get(name) + if module is not None: + _adapt_module(module, name) + + + class _Loader: + + def __init__(self, kotlin_package): + self._kotlin_package = kotlin_package + + def create_module(self, spec): + return None # the default module object is exactly right + + def exec_module(self, module): + _adapt_module(module, self._kotlin_package) + + + class _Finder: + '''The half a module `__getattr__` cannot do. + + `import androidx.compose.material3` fails *before* any attribute is touched, so laziness inside + a module is not enough to make the module lazy. A finder answers the import, and the + `__getattr__` the loader installs answers the names inside it -- two hooks, + `docs/archive/pythonx-adapter-design.md` §2.3. + ''' + + def find_spec(self, fullname, path=None, target=None): + # The name asked for *is* the Kotlin package name. No translation, no prefix, no + # map: `import androidx.compose.foundation.layout` resolves because that package + # has bindings, and `import pythonx` does not resolve here at all -- that name + # belongs to a real distribution whose files are on `sys.path`, and this finder is + # appended to `sys.meta_path` so the ordinary path finder answers first. + # + # A package nothing is bound under is not importable, so a typo is a + # ModuleNotFoundError at the import rather than an AttributeError several lines + # later. `_PACKAGES_SEEN` carries every ancestor of every bound declaration's + # package, so intermediate segments resolve as containers on the way down. + if fullname not in _PACKAGES_SEEN: + return None + return _machinery.ModuleSpec(fullname, _Loader(fullname), is_package=True) + + + def _install_finder(): + for finder in _sys.meta_path: + if isinstance(finder, _Finder): + return + _sys.meta_path.append(_Finder()) + + + _install_finder() + """.trimIndent() + + /** + * Puts [SOURCE] into `sys.modules` as `python_multiplatform.binding`, exactly once per + * interpreter, after the root module it imports from ([KotlinSurface]). + * + * **A binder-owned name, and never `pythonx`.** This used to install a `ModuleType('pythonx')` + * with `__path__ = []`, and CPython answers `import pythonx` from `sys.modules` before it looks + * at `sys.path` -- so the real `pythonx` package (pythonx-compose) could never load once this + * had run. `docs/INTENT.md` §2.3, `AGENTS.md` §12.2. + * + * The module object is built here rather than by an import, because there is no file for an + * import to find (see the class KDoc). Guarded on `sys.modules` rather than on a Kotlin flag: + * this repository's test fixture finalizes and re-initialises the interpreter between tests, + * and a Kotlin flag would then say "installed" about an interpreter that no longer exists. + */ + private val DELIVERY: String = """ + import sys as _px_sys + import types as _px_types + + if '$MODULE_NAME' not in _px_sys.modules: + _px_mod = _px_types.ModuleType('$MODULE_NAME') + _px_sys.modules['$MODULE_NAME'] = _px_mod + _px_sys.modules['${KotlinSurface.MODULE_NAME}'].binding = _px_mod + exec(compile(_px_src, 'python_multiplatform/binding.py', 'exec'), _px_mod.__dict__) + del _px_mod + del _px_src, _px_sys, _px_types + """.trimIndent() + + /** + * Renders the whole installable source: [SOURCE], its delivery, and one row per entry. + * + * A pure function from entries to text, for the reason `PythonProxySource.render` is one -- + * `PythonxTableSourceTest` pins it in `commonTest` with no interpreter anywhere, so the + * generated half is a readable artefact rather than a behaviour. + */ + fun render(entries: List): String { + // The delivery route is an `r"""..."""` Python literal, so a triple double-quote inside + // [SOURCE] would end it early and a trailing backslash would escape its closing quote. + // Both would surface as a SyntaxError with a line number inside a string nobody can see. + require(!SOURCE.contains("\"\"\"")) { "the binding-layer source must not contain a triple double-quote" } + require(!SOURCE.endsWith("\\")) { "the binding-layer source must not end in a backslash" } + return buildString { + appendLine(KotlinSurface.DELIVERY) + appendLine("_px_src = r\"\"\"") + appendLine(SOURCE) + appendLine("\"\"\"") + appendLine(DELIVERY) + appendLine() + appendLine(renderTable(entries)) + } + } + + /** + * The generated half: one row per [ExposedCallable], and nothing else. + * + * Every field here is one `docs/archive/pythonx-adapter-design.md` §2.4 recorded as *missing* from the + * boundary -- parameter names, whether a slot is an extension receiver, the receiver's type, the + * **declared** type of a parameter as opposed to its marshalling tag, and whether a parameter + * has a default. They are on [ExposedCallable] now, and this is what carries them the last step, + * into Python, where §2.4's three consequences are decided: + * + * 1. `paramNames` is what makes `padding(horizontal=8, vertical=4)` possible at all. + * 2. `isExtension` + `receiverTypeName` is what lets an extension become a method on its + * receiver, and therefore what makes the chain work. + * 3. `paramTypeNames` is what lets the allowlist be enforced in Python: a `FLOAT` tag over a + * declared type that is not `kotlin.Float` is a value class over a primitive, which is the + * only machine-checkable form of "this might pack". + * + * A third entry point (`_pm_describe`) on all five bootstraps would have been the alternative. + * This costs no platform code and cannot disagree with the table, because it *is* the table. + * + * 4. `paramHasDefault` is what closes §4.5. It was carried and unused while every generated + * Kotlin body passed every argument; a walked body now carries one call expression per subset + * of its defaulted parameters and picks between them on `args[i] == null` + * (`ArtifactScanner.presenceBranchedCall`), so this column is the list of slots `_bind` may + * fill with that sentinel. Nothing about the row *shape* changed to do it, which is why it + * was worth carrying before anything read it. + */ + fun renderTable(entries: List): String = buildString { + appendLine("import $MODULE_NAME as _px_binding") + appendLine("_px_binding._register_table((") + entries.forEach { entry -> + append(" ") + append(KotlinSurface.row(entry)) + appendLine(",") + } + appendLine("))") + // Eager, and the only eager thing here: a target whose bootstrap publishes no entry points + // must fail at install with the message that says so, rather than at the first attribute + // read several files away. Everything else -- every handle, every proxy type, every module + // -- is resolved on first use. + appendLine("_px_binding._boundary()") + append("del _px_binding") + } + + /** + * Renders the adapter for whatever [UpcallTable] currently holds and `exec`s it. + * + * @return the source that was executed, so a caller can log or inspect exactly what ran. + */ + fun install(rawPrimitiveValueClasses: Set = emptySet()): String { + // The adapter's own Kotlin service, registered by the layer that needs it rather than by + // every consumer's table. Idempotent by `moduleName`, so a second install after a `clear()` + // puts it back and one after a `register` does nothing. See [PythonCallables.Fragment]. + UpcallTable.register(PythonCallables.Fragment) + val source = render(UpcallTable.entries()) + Python3.exec(source) + Python3.exec( + "import $MODULE_NAME as _px_binding\n" + + "_px_binding._registered_epoch = ${UpcallTable.currentEpoch}\n" + + "del _px_binding", + ) + // Applied after the adapter exists, because `allow_raw_primitive` is one of the names it + // defines. Which value classes may be written as a raw primitive is a fact about the + // library that declares them, so it is injected rather than known here. + if (rawPrimitiveValueClasses.isNotEmpty()) { + Python3.exec( + (listOf("import $MODULE_NAME as _px_binding") + rawPrimitiveValueClasses.map { + "_px_binding.allow_raw_primitive(${it.pyStr()})" + }).joinToString("\n") + ) + } + return source + } + + /** A Python string literal for [this], quoted the way the rendered source quotes its own. */ + private fun String.pyStr(): String = "'" + replace("\\", "\\\\").replace("'", "\\'") + "'" +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyBool.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyBool.kt index 9cb328e46..7b23b99b4 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyBool.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyBool.kt @@ -1,24 +1,49 @@ package python.multiplatform.ffi.types.basic import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType import python.multiplatform.ffi.conversion.PyProxy +import python.multiplatform.ffi.exceptions.PyException import python.native.ffi.NativePointer +import python.native.ffi.PyBool_FromLong +import python.native.ffi.PyErr_Occurred +import python.native.ffi.PyObject_IsTrue - -open class PyBool(pointer: NativePointer, borrowed: Boolean): PyObject(pointer, borrowed), PyProxy { +/** + * Wrapper around a Python `bool` object. + * + * In Python, `bool` is a subclass of `int` (`PyType.isSubtypeOf` will report + * that at the Python level). That relationship is deliberately *not* mirrored + * in the Kotlin class hierarchy here (`PyBool` does not extend [PyInt]): + * both types implement [PyProxy] with different type arguments (`Boolean` vs + * `Long`), and Kotlin does not allow a class to implement the same generic + * interface twice with different arguments. Use [PyType.isSubtypeOf] if the + * Python-level relationship needs to be observed. + */ +open class PyBool(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed), PyProxy { companion object { - //fun from(value: Boolean): PyBool { - // return if (value) true else false - //} - // TODO: Implement this method to return a PyBool instance based on the boolean value + /** The `PyType` for `bool` (`builtins.bool`). */ + val TYPE: PyType by lazy { val obj = from(false); val t = obj.Type; obj.close(); t } + + /** Returns the (singleton) `True` or `False` Python object for [value] (`PyBool_FromLong`). */ + fun from(value: Boolean): PyBool { + val ptr = python.multiplatform.ffi.Python3.withPython { PyBool_FromLong(if (value) 1 else 0) } ?: throw PyException.fromCurrentError()!! + return PyBool(ptr, false) + } } override var cachedNativeValue: Boolean? - get() = TODO("Not yet implemented") + get() { + val res = python.multiplatform.ffi.Python3.withPython { PyObject_IsTrue(pointer) } + if (res == -1 && python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } != null) { + throw PyException.fromCurrentError()!! + } + return res == 1 + } set(value) {} override var cachedPyObjectValue: PyObject? - get() = TODO("Not yet implemented") + get() = this set(value) {} } -//fun Boolean.asPyObject(): PyBool = PyBool.from(this) +fun Boolean.asPyObject(): PyBool = PyBool.from(this) diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyBytes.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyBytes.kt new file mode 100644 index 000000000..ff5507f6b --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyBytes.kt @@ -0,0 +1,221 @@ +package python.multiplatform.ffi.types.basic + +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType +import python.multiplatform.ffi.PyTypeChecks +import python.multiplatform.ffi.conversion.PyProxy +import python.multiplatform.ffi.pyErrorOrGeneric +import python.native.ffi.HighOverheadNativeCall +import python.native.ffi.NativePointer +import python.native.ffi.PyByteArray_FromObject +import python.native.ffi.PyObject_CallNoArgs +import python.native.ffi.PyObject_CallObject +import python.native.ffi.PyObject_GetAttrString +import python.native.ffi.PyTuple_New +import python.native.ffi.PyTuple_SetItem +import python.native.ffi.PyUnicode_AsUTF8 +import python.native.ffi.PyUnicode_FromString +import python.native.ffi.Py_DecRef + +/** + * Wrapper around a Python `bytes` object, projecting onto a Kotlin [ByteArray]. + * + * ## Why the projection is a copy, and why that is the whole point + * + * ROADMAP §7b refused `bytes`, `bytearray` and `memoryview` together, because + * the obvious conversion (`PyBytes_AsStringAndSize`, or the buffer protocol) + * hands out a pointer *into the object's own storage*: caching that would + * outlive what it points at, and a `bytearray`'s buffer additionally moves when + * it is resized. That reason is about the pointer, not about the type. A Kotlin + * [ByteArray] is a copy by construction, so it satisfies + * [python.multiplatform.ffi.conversion.isIndependentOfPythonMemory] exactly the + * way the decoded `String` of a `str` does -- which is what §7b itself recorded + * as the open item, and why that predicate already carried a `ByteArray -> true` + * branch before anything produced one. + * + * `memoryview` stays refused, on a reason the copy does not dispose of: it + * carries a format, a shape and strides, so there is no single flat + * [ByteArray] that is its value, and a non-contiguous view cannot produce one + * at all. + * + * ## How the bytes are actually read, and what it costs + * + * Through `bytes.hex()`, not through a buffer pointer. The Stable ABI subset + * this library binds exposes `PyBytes_AsString`, whose `expect` returns a + * `String?` decoded at the platform boundary from a NUL-terminated + * `const char*` -- which truncates at the first `0x00` and mangles every byte + * that is not valid UTF-8, i.e. it cannot represent arbitrary `bytes` at all. + * There is no `PyBytes_AsStringAndSize` binding, and adding one would mean a + * new non-primitive value crossing four platform boundaries (Panama, JNI, and + * two cinterop targets), against `androidMain`'s rule that the JNI boundary + * carries primitives only. + * + * `hex()` is exact for every byte, is pure ASCII (so nothing about the string + * path can corrupt it), and costs **three FFI crossings regardless of length** + * -- `PyObject_GetAttrString` + `PyObject_CallNoArgs` + `PyUnicode_AsUTF8` -- + * rather than the one crossing per byte a `PySequence_Tuple` walk would need. + * What it does cost is four passes over the data: CPython's hex encode, the + * UTF-8 encode/decode at the platform boundary, and [decodeHex] here, against + * the single `memcpy` a direct buffer binding would do. That ratio is measured + * against `str` of the same length (which *does* have a direct binding) in + * `PyValueBytesConversionTest`. + * + * The conversion is therefore paid once per value: [cachedNativeValue] is a + * real field, filled by [PyProxy]'s walk, not a recomputing accessor like + * [PyInt]'s or [PyString]'s -- those cost one cheap FFI call, this does not. + */ +open class PyBytes(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed), PyProxy { + companion object { + /** The `PyType` for `bytes` (`builtins.bytes`). */ + val TYPE: PyType by lazy { val obj = from(ByteArray(0)); val t = obj.Type; obj.close(); t } + + /** + * Builds a new Python `bytes` from [value], via `bytes.fromhex`. + * + * The mirror of the read path, and for the same reason: `PyBytes_FromString` + * takes a Kotlin `String`, so it cannot express an embedded NUL or a + * non-UTF-8 byte. `fromhex` is exact, and costs a fixed number of + * crossings rather than one per byte. + */ + fun from(value: ByteArray): PyBytes = PyBytes(buildPythonBytes(value), borrowed = false) + } + + /** + * Filled by [PyProxy.toKotlinOrNull]'s walk (which routes through + * [python.multiplatform.ffi.types.collections.pyObjectToNative]), then kept. + * `bytes` is immutable, so unlike a `bytearray` the snapshot can never go + * stale. + */ + override var cachedNativeValue: ByteArray? = null + override var cachedPyObjectValue: PyObject? = null +} + +/** + * Wrapper around a Python `bytearray` object, projecting onto a Kotlin [ByteArray]. + * + * Identical to [PyBytes] in every respect but mutability, which is why it is a + * separate type rather than a shared one: `bytearray` is mutable, so its cached + * projection is a **snapshot** in exactly the sense a converted `list` is, and + * [PyProxy.invalidateNativeCache] is the stated way to re-read a `bytearray` + * that has since been mutated. + */ +open class PyByteArray(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed), PyProxy { + companion object { + /** The `PyType` for `bytearray` (`builtins.bytearray`). */ + val TYPE: PyType by lazy { + // Derived from a scratch instance, like every other wrapper's TYPE. Reading + // `PyTypeChecks.bytearrayType`'s own `Type` would answer `type`, not `bytearray`. + val empty = PyBytes.from(ByteArray(0)) + val ptr = try { + python.multiplatform.ffi.Python3.withPython { PyByteArray_FromObject(empty.pointer) } + ?: throw pyErrorOrGeneric("Could not build a scratch bytearray") + } finally { + empty.close() + } + val obj = PyByteArray(ptr, borrowed = false) + try { obj.Type } finally { obj.close() } + } + } + + override var cachedNativeValue: ByteArray? = null + override var cachedPyObjectValue: PyObject? = null +} + +/** `bytes(b'...')` and `bytearray(b'...')` as a Kotlin [ByteArray]; see [PyBytes] for why this goes through `hex()`. */ +fun ByteArray.asPyObject(): PyBytes = PyBytes.from(this) + +/** + * Reads the bytes of a `bytes`/`bytearray` object at [pointer] out into an + * independent Kotlin [ByteArray], via `hex()`. + * + * Marked [HighOverheadNativeCall] not for its crossing count -- three, fixed -- + * but for the four passes over the payload it makes where a direct + * `PyBytes_AsStringAndSize` binding would make one. See [PyBytes]'s class doc + * for why that binding does not exist. + * + * Takes a raw [NativePointer] rather than a [PyObject] deliberately: no wrapper + * is constructed anywhere on this path, so the conversion costs no refcount + * round trip and registers no cleaner. Nothing here adopts [pointer]; the + * caller keeps owning it. + */ +@HighOverheadNativeCall +internal fun readBufferAsByteArray(pointer: NativePointer): ByteArray { + // `hex` is a new reference. + val hexMethod = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(pointer, "hex") } + ?: throw pyErrorOrGeneric("Could not resolve .hex() on this buffer object") + val hexStrPtr = try { + python.multiplatform.ffi.Python3.withPython { PyObject_CallNoArgs(hexMethod) } + ?: throw pyErrorOrGeneric("Calling .hex() on this buffer object failed") + } finally { + python.multiplatform.ffi.Python3.withPython { Py_DecRef(hexMethod) } + } + val hex = try { + // Pure ASCII by construction, so the boundary's UTF-8 decode is lossless and + // NUL-termination cannot truncate it -- which is the whole reason for the detour. + python.multiplatform.ffi.Python3.withPython { PyUnicode_AsUTF8(hexStrPtr) } + ?: throw pyErrorOrGeneric("Could not read the result of .hex()") + } finally { + python.multiplatform.ffi.Python3.withPython { Py_DecRef(hexStrPtr) } + } + return decodeHex(hex) +} + +/** Builds a new Python `bytes` (a new reference) holding exactly [value]; see [PyBytes.from]. */ +private fun buildPythonBytes(value: ByteArray): NativePointer { + // `bytesType` is a borrowed reference to an immortal builtin; `fromhex` is a new one. + val fromHex = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(PyTypeChecks.bytesType, "fromhex") } + ?: throw pyErrorOrGeneric("Could not resolve bytes.fromhex") + try { + val arg = python.multiplatform.ffi.Python3.withPython { PyUnicode_FromString(encodeHex(value)) } + ?: throw pyErrorOrGeneric("Could not build the hex argument for bytes.fromhex") + val args = python.multiplatform.ffi.Python3.withPython { PyTuple_New(1) } + if (args == null) { + // The tuple would have stolen this reference; nothing else holds it. + python.multiplatform.ffi.Python3.withPython { Py_DecRef(arg) } + throw pyErrorOrGeneric("Could not build the argument tuple for bytes.fromhex") + } + try { + // PyTuple_SetItem *steals* `arg`, which is exactly right here: it is a fresh + // reference this function owns and hands over, so there is no incref to pair with + // it (contrast PyComplex.from, whose arguments are still owned by live wrappers). + python.multiplatform.ffi.Python3.withPython { PyTuple_SetItem(args, 0, arg) } + return python.multiplatform.ffi.Python3.withPython { PyObject_CallObject(fromHex, args) } + ?: throw pyErrorOrGeneric("bytes.fromhex() failed") + } finally { + python.multiplatform.ffi.Python3.withPython { Py_DecRef(args) } // releases `arg` with it + } + } finally { + python.multiplatform.ffi.Python3.withPython { Py_DecRef(fromHex) } + } +} + +private const val HEX_DIGITS = "0123456789abcdef" + +private fun encodeHex(value: ByteArray): String { + val out = StringBuilder(value.size * 2) + for (b in value) { + val v = b.toInt() and 0xff + out.append(HEX_DIGITS[v ushr 4]).append(HEX_DIGITS[v and 0x0f]) + } + return out.toString() +} + +private fun decodeHex(hex: String): ByteArray { + // `hex()` always emits two digits per byte; an odd length would mean the string was + // truncated on the way here, which is precisely the failure mode this path exists to avoid. + check(hex.length % 2 == 0) { "hex() returned an odd-length string (${hex.length}); the buffer did not survive the boundary" } + val out = ByteArray(hex.length / 2) + var i = 0 + while (i < out.size) { + out[i] = (((hexDigit(hex[i * 2]) shl 4) or hexDigit(hex[i * 2 + 1])).toByte()) + i++ + } + return out +} + +private fun hexDigit(c: Char): Int = when (c) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> throw IllegalStateException("hex() returned a non-hex character '$c'") +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyComplex.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyComplex.kt index f1c6fc37f..fdab5e1a7 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyComplex.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyComplex.kt @@ -1,9 +1,75 @@ package python.multiplatform.ffi.types.basic import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.exceptions.PyException import python.native.ffi.NativePointer +import python.native.ffi.PyDict_GetItemString +import python.native.ffi.PyEval_GetBuiltins +import python.native.ffi.PyFloat_AsDouble +import python.native.ffi.PyObject_CallObject +import python.native.ffi.PyTuple_New +import python.native.ffi.PyTuple_SetItem +import python.native.ffi.Py_IncRef +import python.native.ffi.Py_DecRef +import python.native.ffi.PyErr_Occurred +/** + * Wrapper around a Python `complex` object. + * + * Kept intentionally minimal (per task scope: "if straightforward") since + * `EmbedAPI` does not expose any `PyComplex_*` Stable ABI entry points -- + * only the generic `PyObject_*`/`PyNumber_*` protocol functions would be + * available to implement this later (e.g. going through `complex(real, imag)` + * via `PyObject_Call` against the builtin, and reading back `.real`/`.imag` + * attributes rather than a dedicated C struct accessor). + */ +open class PyComplex(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed) { + companion object { + /** The `PyType` for `complex` (`builtins.complex`). */ + val TYPE: PyType by lazy { val obj = from(0.0, 0.0); val t = obj.Type; obj.close(); t } -open class PyComplex(pointer: NativePointer, borrowed: Boolean): PyObject(pointer, borrowed) { + /** Constructs a new `complex(real, imag)` object. */ + fun from(real: Double, imag: Double): PyComplex { + val builtins = python.multiplatform.ffi.Python3.withPython { PyEval_GetBuiltins() } ?: throw PyException.fromCurrentError()!! + val complexTypePtr = python.multiplatform.ffi.Python3.withPython { PyDict_GetItemString(builtins, "complex") } ?: throw PyException.fromCurrentError()!! + + val args = python.multiplatform.ffi.Python3.withPython { PyTuple_New(2) } ?: throw PyException.fromCurrentError()!! + val realObj = PyFloat.from(real) + val imagObj = PyFloat.from(imag) + + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_IncRef(realObj.pointer) } + python.multiplatform.ffi.Python3.withPython { PyTuple_SetItem(args, 0, realObj.pointer) } + + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_IncRef(imagObj.pointer) } + python.multiplatform.ffi.Python3.withPython { PyTuple_SetItem(args, 1, imagObj.pointer) } + + val resPtr = python.multiplatform.ffi.Python3.withPython { PyObject_CallObject(complexTypePtr, args) } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(args) } + realObj.close() + imagObj.close() + + if (resPtr == null) throw PyException.fromCurrentError()!! + return PyComplex(resPtr, false) + } + } + val real: Double + get() { + val realAttr = getAttr("real") + val res = python.multiplatform.ffi.Python3.withPython { PyFloat_AsDouble(realAttr.pointer) } + realAttr.close() + if (res == -1.0 && python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } != null) throw PyException.fromCurrentError()!! + return res + } + + val imag: Double + get() { + val imagAttr = getAttr("imag") + val res = python.multiplatform.ffi.Python3.withPython { PyFloat_AsDouble(imagAttr.pointer) } + imagAttr.close() + if (res == -1.0 && python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } != null) throw PyException.fromCurrentError()!! + return res + } } diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyFloat.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyFloat.kt index 4aedc257c..37a685505 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyFloat.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyFloat.kt @@ -1,23 +1,73 @@ package python.multiplatform.ffi.types.basic +import python.multiplatform.ffi.PyCompareOp import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType import python.multiplatform.ffi.conversion.PyProxy +import python.multiplatform.ffi.exceptions.PyException import python.native.ffi.NativePointer +import python.native.ffi.PyErr_Occurred +import python.native.ffi.PyFloat_AsDouble +import python.native.ffi.PyFloat_FromDouble +import python.native.ffi.PyNumber_Add +import python.native.ffi.PyNumber_Multiply +import python.native.ffi.PyNumber_Subtract +import python.native.ffi.PyObject_RichCompareBool - -open class PyFloat(pointer: NativePointer, borrowed: Boolean): PyObject(pointer, borrowed), PyProxy { +/** + * Wrapper around a Python `float` object (a C `double`). + * + * Note: the original stub had this implement `PyProxy` (presumably + * copy-pasted from [PyBool]); corrected to `PyProxy` here since a + * Python float's natural Kotlin projection is a [Double]. + */ +open class PyFloat(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed), PyProxy { companion object { - //fun from(value: Boolean): PyBool { - // return if (value) True else False - //} + /** The `PyType` for `float` (`builtins.float`). */ + val TYPE: PyType by lazy { val obj = from(0.0); val t = obj.Type; obj.close(); t } + + /** Wraps [value] as a new Python `float` object (`PyFloat_FromDouble`). */ + fun from(value: Double): PyFloat { + val ptr = python.multiplatform.ffi.Python3.withPython { PyFloat_FromDouble(value) } ?: throw PyException.fromCurrentError()!! + return PyFloat(ptr, false) + } } - override var cachedNativeValue: Boolean? - get() = TODO("Not yet implemented") + override var cachedNativeValue: Double? + get() { + val res = python.multiplatform.ffi.Python3.withPython { PyFloat_AsDouble(pointer) } + if (res == -1.0 && python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } != null) { + throw PyException.fromCurrentError()!! + } + return res + } set(value) {} override var cachedPyObjectValue: PyObject? - get() = TODO("Not yet implemented") + get() = this set(value) {} + + operator fun plus(other: PyFloat): PyFloat { + val res = python.multiplatform.ffi.Python3.withPython { PyNumber_Add(pointer, other.pointer) } ?: throw PyException.fromCurrentError()!! + return PyFloat(res, false) + } + operator fun minus(other: PyFloat): PyFloat { + val res = python.multiplatform.ffi.Python3.withPython { PyNumber_Subtract(pointer, other.pointer) } ?: throw PyException.fromCurrentError()!! + return PyFloat(res, false) + } + operator fun times(other: PyFloat): PyFloat { + val res = python.multiplatform.ffi.Python3.withPython { PyNumber_Multiply(pointer, other.pointer) } ?: throw PyException.fromCurrentError()!! + return PyFloat(res, false) + } + operator fun compareTo(other: PyFloat): Int { + val resLT = python.multiplatform.ffi.Python3.withPython { PyObject_RichCompareBool(pointer, other.pointer, PyCompareOp.LT.opId) } + if (resLT == -1 && python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } != null) throw PyException.fromCurrentError()!! + if (resLT == 1) return -1 + + val resGT = python.multiplatform.ffi.Python3.withPython { PyObject_RichCompareBool(pointer, other.pointer, PyCompareOp.GT.opId) } + if (resGT == -1 && python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } != null) throw PyException.fromCurrentError()!! + if (resGT == 1) return 1 + return 0 + } } -//fun Boolean.toPyBool(): PyBool = PyBool.from(this) +fun Double.asPyObject(): PyFloat = PyFloat.from(this) diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyInt.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyInt.kt index 05c4789d1..ae9e02727 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyInt.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyInt.kt @@ -1,16 +1,76 @@ package python.multiplatform.ffi.types.basic +import python.multiplatform.ffi.PyCompareOp import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType import python.multiplatform.ffi.conversion.PyProxy +import python.multiplatform.ffi.exceptions.PyException import python.native.ffi.NativePointer +import python.native.ffi.PyErr_Occurred +import python.native.ffi.PyLong_AsLongLong +import python.native.ffi.PyLong_FromLongLong +import python.native.ffi.PyNumber_Add +import python.native.ffi.PyNumber_Multiply +import python.native.ffi.PyNumber_Subtract +import python.native.ffi.PyObject_RichCompareBool +/** + * Wrapper around a Python `int` object. + * + * CPython ints are arbitrary precision, but the Stable ABI surface available + * here (`PyLong_As/FromLongLong`) only bridges the 64-bit range. Values + * outside `Long` range will not round-trip through [toKotlin] -- this is a + * known limitation to revisit (e.g. a `BigInteger`-backed path) rather than + * an oversight. + */ +open class PyInt(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed), PyProxy { + companion object { + /** The `PyType` for `int` (`builtins.int`). */ + val TYPE: PyType by lazy { val obj = from(0L); val t = obj.Type; obj.close(); t } -//open class PyBool(pointer: NativePointer, borrowed: Boolean): PyObject(pointer, borrowed), PyProxy { -// companion object { -// fun from(value: Boolean): PyBool { -// return if (value) True else False -// } -// } -//} -// -//fun Boolean.toPyBool(): PyBool = PyBool.from(this) + /** Wraps [value] as a new Python `int` object (`PyLong_FromLongLong`). */ + fun from(value: Long): PyInt { + val ptr = python.multiplatform.ffi.Python3.withPython { PyLong_FromLongLong(value) } ?: throw PyException.fromCurrentError()!! + return PyInt(ptr, false) + } + } + + override var cachedNativeValue: Long? + get() { + val res = python.multiplatform.ffi.Python3.withPython { PyLong_AsLongLong(pointer) } + if (res == -1L && python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } != null) { + throw PyException.fromCurrentError()!! + } + return res + } + set(value) {} + override var cachedPyObjectValue: PyObject? + get() = this + set(value) {} + + operator fun plus(other: PyInt): PyInt { + val res = python.multiplatform.ffi.Python3.withPython { PyNumber_Add(pointer, other.pointer) } ?: throw PyException.fromCurrentError()!! + return PyInt(res, false) + } + operator fun minus(other: PyInt): PyInt { + val res = python.multiplatform.ffi.Python3.withPython { PyNumber_Subtract(pointer, other.pointer) } ?: throw PyException.fromCurrentError()!! + return PyInt(res, false) + } + operator fun times(other: PyInt): PyInt { + val res = python.multiplatform.ffi.Python3.withPython { PyNumber_Multiply(pointer, other.pointer) } ?: throw PyException.fromCurrentError()!! + return PyInt(res, false) + } + operator fun compareTo(other: PyInt): Int { + val resLT = python.multiplatform.ffi.Python3.withPython { PyObject_RichCompareBool(pointer, other.pointer, PyCompareOp.LT.opId) } + if (resLT == -1 && python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } != null) throw PyException.fromCurrentError()!! + if (resLT == 1) return -1 + + val resGT = python.multiplatform.ffi.Python3.withPython { PyObject_RichCompareBool(pointer, other.pointer, PyCompareOp.GT.opId) } + if (resGT == -1 && python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } != null) throw PyException.fromCurrentError()!! + if (resGT == 1) return 1 + return 0 + } +} + +fun Long.asPyObject(): PyInt = PyInt.from(this) +fun Int.asPyObject(): PyInt = PyInt.from(this.toLong()) diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyNone.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyNone.kt index 05c4789d1..d9802b77c 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyNone.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyNone.kt @@ -1,16 +1,45 @@ package python.multiplatform.ffi.types.basic import python.multiplatform.ffi.PyObject -import python.multiplatform.ffi.conversion.PyProxy +import python.multiplatform.ffi.PyType +import python.multiplatform.ffi.Python3 import python.native.ffi.NativePointer +import python.native.ffi.PyDict_GetItemString +import python.native.ffi.PyEval_GetBuiltins +import python.native.ffi.Py_IncRef +import python.native.ffi.Py_DecRef +/** + * Wrapper around Python's `None` singleton. + * + * Unlike the other basic types, `None` cannot be freshly constructed -- + * there is exactly one instance for the lifetime of the interpreter. The + * Stable ABI subset available in `EmbedAPI` does not expose a direct + * `Py_None` accessor (it is a data symbol, not a function, in the full C + * API), so [get] is expected to obtain the singleton indirectly (e.g. by + * evaluating the literal `None` via [python.multiplatform.ffi.Python3.eval]). + */ +class PyNone private constructor(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed) { + companion object { + /** The `PyType` for `NoneType`. */ + val TYPE: PyType by lazy { get().Type } -//open class PyBool(pointer: NativePointer, borrowed: Boolean): PyObject(pointer, borrowed), PyProxy { -// companion object { -// fun from(value: Boolean): PyBool { -// return if (value) True else False -// } -// } -//} -// -//fun Boolean.toPyBool(): PyBool = PyBool.from(this) + private var _instance: PyNone? = null + + /** Returns the singleton `None` object, initialising it on first access. */ + fun get(): PyNone { + return _instance ?: run { + val builtins = python.multiplatform.ffi.Python3.withPython { PyEval_GetBuiltins() } ?: throw python.multiplatform.ffi.exceptions.PyException.fromCurrentError()!! + val nonePtr = python.multiplatform.ffi.Python3.withPython { PyDict_GetItemString(builtins, "None") } ?: throw python.multiplatform.ffi.exceptions.PyException.fromCurrentError()!! + val pyNone = PyNone(nonePtr, true) + _instance = pyNone + pyNone + } + } + + /** Whether [obj] is Python's `None` singleton (`Py_IsNone` / `obj is None`). */ + fun isNone(obj: PyObject): Boolean { + return obj.pointer.address == get().pointer.address + } + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyString.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyString.kt index 05c4789d1..d0d9030d3 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyString.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/basic/PyString.kt @@ -1,16 +1,62 @@ package python.multiplatform.ffi.types.basic import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType import python.multiplatform.ffi.conversion.PyProxy +import python.multiplatform.ffi.exceptions.PyException import python.native.ffi.NativePointer +import python.native.ffi.PyErr_Occurred +import python.native.ffi.PyUnicode_AsUTF8 +import python.native.ffi.PyUnicode_Concat +import python.native.ffi.PyUnicode_Contains +import python.native.ffi.PyUnicode_FromString +/** Wrapper around a Python `str` object (`PyUnicode_*` family). */ +open class PyString(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed), PyProxy { + companion object { + /** The `PyType` for `str` (`builtins.str`). */ + val TYPE: PyType by lazy { val obj = from(""); val t = obj.Type; obj.close(); t } -//open class PyBool(pointer: NativePointer, borrowed: Boolean): PyObject(pointer, borrowed), PyProxy { -// companion object { -// fun from(value: Boolean): PyBool { -// return if (value) True else False -// } -// } -//} -// -//fun Boolean.toPyBool(): PyBool = PyBool.from(this) + /** Wraps [value] as a new Python `str` object (`PyUnicode_FromString`). */ + fun from(value: String): PyString { + val ptr = python.multiplatform.ffi.Python3.withPython { PyUnicode_FromString(value) } ?: throw PyException.fromCurrentError()!! + return PyString(ptr, false) + } + } + + override var cachedNativeValue: String? + get() { + val res = python.multiplatform.ffi.Python3.withPython { PyUnicode_AsUTF8(pointer) } + if (res == null && python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } != null) { + throw PyException.fromCurrentError()!! + } + return res + } + set(value) {} + override var cachedPyObjectValue: PyObject? + get() = this + set(value) {} + + /** `len(self)`. */ + val length: Int + get() = toKotlin().length + + operator fun plus(other: PyString): PyString { + val res = python.multiplatform.ffi.Python3.withPython { PyUnicode_Concat(pointer, other.pointer) } ?: throw PyException.fromCurrentError()!! + return PyString(res, false) + } + + operator fun get(index: Int): PyString { + return PyString.from(toKotlin()[index].toString()) + } + + operator fun contains(substring: String): Boolean { + val subObj = PyString.from(substring) + val res = python.multiplatform.ffi.Python3.withPython { PyUnicode_Contains(pointer, subObj.pointer) } + subObj.close() + if (res == -1 && python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } != null) throw PyException.fromCurrentError()!! + return res == 1 + } +} + +fun String.asPyObject(): PyString = PyString.from(this) diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyClassMethod.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyClassMethod.kt index 573200f73..ceb77765e 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyClassMethod.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyClassMethod.kt @@ -1,4 +1,18 @@ package python.multiplatform.ffi.types.callables -class PyClassMethod { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.attrAs +import python.native.ffi.NativePointer + +/** + * Wrapper around a Python `classmethod` descriptor object. + * + * Note that the descriptor itself is only reachable through the owning + * class's `__dict__` -- `SomeClass.method` has already run the descriptor + * protocol and yields a bound [PyMethod], not this. + */ +open class PyClassMethod(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed) { + /** `classmethod.__func__`: the underlying function, called with the owning class as its first argument. */ + val function: PyFunction + get() = attrAs("__func__") { PyFunction(it, borrowed = false) } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyFunction.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyFunction.kt index b66d05101..ec5eef2b4 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyFunction.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyFunction.kt @@ -1,4 +1,44 @@ package python.multiplatform.ffi.types.callables -class PyFunction { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.attrAsString +import python.multiplatform.ffi.attrAsStringOrNull +import python.native.ffi.NativePointer + +/** + * Wrapper around a plain Python function object (`def f(...): ...` or a + * `lambda`). Calling it is already covered by the inherited + * [PyObject.invoke]; this adds the function-specific introspection bits. + * + * ### Why these three, and not `__code__` / `__globals__` / `__defaults__` + * + * The full C API reaches a function's code object, globals and defaults + * through `PyFunction_GetCode`, `PyFunction_GetGlobals` and + * `PyFunction_GetDefaults`, which read fields of `PyFunctionObject`. None of + * those is in the limited API, and the struct they read is opaque under + * `Py_LIMITED_API` -- there is no abi3-legal *direct* accessor for them, and + * this project must not synthesise one by assuming a struct layout. + * + * That restricts the route, not the data: all three are ordinary attributes, + * so `getAttr("__code__")` reaches them through `PyObject_GetAttrString` like + * everything else here. They are not surfaced as typed properties because the + * wrapper they would return (`PyCode`) is itself unimplementable through the + * limited API in any richer way than the same attribute lookups -- see + * `types/code/`. + */ +open class PyFunction(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed) { + /** `function.__name__`. */ + val name: String + get() = attrAsString("__name__") + + /** `function.__doc__`, or `null` if undocumented (i.e. `__doc__` is `None`). */ + val doc: String? + get() = attrAsStringOrNull("__doc__") + + /** + * `function.__qualname__`: the dotted path from module scope, e.g. + * `"Outer.method"` for a function defined in a class body. + */ + val qualifiedName: String + get() = attrAsString("__qualname__") +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyMethod.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyMethod.kt index 050a0c721..1f7c729c6 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyMethod.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyMethod.kt @@ -1,4 +1,26 @@ package python.multiplatform.ffi.types.callables -class PyMethod { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.attrAs +import python.native.ffi.NativePointer + +/** + * Wrapper around a Python bound method object (`instance.method`), i.e. a + * function together with the `self` it is bound to. + * + * `PyMethod_Function` and `PyMethod_Self` exist in the full C API but not in + * the limited one, so both accessors go through `PyObject_GetAttrString` on + * the corresponding dunder. Each returns a **new** wrapper owning its own + * reference -- reading `method.instance` twice gives two wrappers over the + * same object, which is the safe shape: neither can free what the other still + * uses. + */ +open class PyMethod(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed) { + /** `method.__self__`: the bound receiver. */ + val instance: PyObject + get() = getAttr("__self__") + + /** `method.__func__`: the underlying unbound function. */ + val function: PyFunction + get() = attrAs("__func__") { PyFunction(it, borrowed = false) } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyStaticMethod.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyStaticMethod.kt index cc56a54f0..8079f40ca 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyStaticMethod.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/callables/PyStaticMethod.kt @@ -1,4 +1,18 @@ package python.multiplatform.ffi.types.callables -class PyStaticMethod { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.attrAs +import python.native.ffi.NativePointer + +/** + * Wrapper around a Python `staticmethod` descriptor object. + * + * As with [PyClassMethod], the descriptor object itself only appears in the + * owning class's `__dict__`; attribute access on the class hands back the + * plain [PyFunction] this wraps. + */ +open class PyStaticMethod(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed) { + /** `staticmethod.__func__`: the underlying plain function. */ + val function: PyFunction + get() = attrAs("__func__") { PyFunction(it, borrowed = false) } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/CollectionSupport.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/CollectionSupport.kt new file mode 100644 index 000000000..53f662d77 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/CollectionSupport.kt @@ -0,0 +1,164 @@ +package python.multiplatform.ffi.types.collections + +import python.multiplatform.ffi.PyCompareOp +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType +import python.multiplatform.ffi.PyTypeChecks +import python.multiplatform.ffi.adoptingNewReference +import python.multiplatform.ffi.exceptions.PyException +import python.multiplatform.ffi.types.basic.PyNone +import python.multiplatform.ffi.types.basic.readBufferAsByteArray +import python.native.ffi.HighOverheadNativeCall +import python.native.ffi.NativePointer +import python.native.ffi.PyFloat_AsDouble +import python.native.ffi.PyLong_AsLongLong +import python.native.ffi.PyObject_RichCompareBool +import python.native.ffi.PyObject_Type +import python.native.ffi.PySequence_Tuple +import python.native.ffi.PyTuple_GetItem +import python.native.ffi.PyTuple_Size +import python.native.ffi.PyUnicode_AsUTF8 +import python.native.ffi.Py_DecRef + +/** + * Small helpers shared by the collection wrappers in this package. + */ + +/** + * `a == b`, via the raw `PyObject_RichCompareBool` FFI call directly (opid + * `Py_EQ` == 2, see [PyCompareOp.EQ]) rather than [PyObject.richCompare]. + * + * The original reason for going direct was that `richCompare` was a `TODO`; it is implemented + * now, and the direct call is kept for a different and better reason. This takes raw + * `NativePointer`s, so the element-equality-dependent operations here (`indexOf`, `retainAll`, + * `contains`, ...) can compare a candidate against every element without constructing a + * [PyObject] wrapper per element -- and each wrapper is a refcount round trip plus a registered + * cleaner. It also avoids the second crossing `richCompare` needs: `PyObject_RichCompare` returns + * a new `PyObject*` that then has to go through `PyObject_IsTrue` and `Py_DecRef`, where + * `PyObject_RichCompareBool` answers in one call. + */ +internal fun pyEquals(a: NativePointer, b: NativePointer): Boolean { + val result = python.multiplatform.ffi.Python3.withPython { PyObject_RichCompareBool(a, b, PyCompareOp.EQ.opId) } + if (result == -1) throw PyException.fromCurrentError() ?: PyException("Comparison failed") + return result == 1 +} + +/** + * Derives the [PyType] of a freshly-built, not-yet-published, exclusively + * owned scratch instance (e.g. an empty `list()`/`dict()` built only to read + * its `type`), then releases that scratch instance -- used by each + * collection wrapper's `TYPE` companion property. + */ +internal fun deriveTypeAndRelease(instancePointer: NativePointer): PyType { + // The scratch instance is released in a finally, not after the call: on the failure path it + // used to be abandoned, and nothing had wrapped it, so it could never be reclaimed. + try { + val typePtr = python.multiplatform.ffi.Python3.withPython { PyObject_Type(instancePointer) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to get PyType of scratch instance") + return typePtr.adoptingNewReference { PyType.getInstance(it) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(instancePointer) } // only needed to read its type + } +} + +/** + * Snapshots an iterable Python object (typically a `set`/`frozenset`, which + * support no indexed access) into a Kotlin `List`, via + * `PySequence_Tuple` + `PyTuple_GetItem`. Used instead of driving a live + * `PyIter_Next` loop so that mutating the *live* container afterwards (e.g. + * `PySet`'s iterator `remove()`) never races with an in-progress raw + * CPython iterator over the same container -- mutating a Python collection + * while iterating it directly is undefined behaviour in CPython. + */ +internal fun snapshotElements(pointer: NativePointer): List { + val tuplePtr = python.multiplatform.ffi.Python3.withPython { PySequence_Tuple(pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to snapshot elements (PySequence_Tuple failed)") + // Nobody adopts the scratch tuple, so its release goes in a finally -- the `!!` in the loop + // is a real throw site, and stranding the tuple there would be unrecoverable. + try { + val count = python.multiplatform.ffi.Python3.withPython { PyTuple_Size(tuplePtr) } + val result = ArrayList(count.toInt()) + for (i in 0 until count) { + // Borrowed reference into tuplePtr, valid only while tuplePtr is alive -- wrap with + // borrowed = true so each element gets its own independent, incref'd reference before + // the scratch tuple below is released. + val itemPtr = python.multiplatform.ffi.Python3.withPython { PyTuple_GetItem(tuplePtr, i) }!! + result.add(PyObject(itemPtr, true)) + } + return result + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(tuplePtr) } // scratch snapshot tuple + } +} + +/** + * Recursively converts a live [PyObject] to a "fully native" Kotlin + * projection ([python.multiplatform.ffi.conversion.ConversionStrategy.NATIVE]). + * + * `None` is checked first via the cheap, zero-FFI-call [PyNone.isNone] + * (a pointer comparison against the already-resolved singleton). Everything + * else is dispatched by fetching [obj]'s type *once* (`PyObject_Type`) and + * comparing the resulting pointer against [PyTypeChecks]'s cached builtin + * type objects -- plain Kotlin `NativePointer` equality, no further FFI + * calls -- rather than the `PyType.name` string dispatch this used before + * [PyTypeChecks] existed (which cost three FFI crossings, `PyObject_Type` + + * `PyType_GetName` + `PyUnicode_AsUTF8`, and a UTF-8 decode, just to read a + * name well enough to compare). This intentionally mirrors `PyLong_Check`'s + * exact-type dispatch semantics, not `isinstance`'s subclass-inclusive ones: + * a value of a *user-defined* subclass of e.g. `list` still falls through to + * the `else` branch below, exactly as the old name-based dispatch did (its + * exact `PyType.name` would have been the subclass's own name, not `"list"`). + * Falls back to `str(obj)` (via [PyObject.toString]) for any type not + * explicitly handled -- see the note on that branch below for why it is + * still the fallback now that + * [python.multiplatform.ffi.conversion.PyContext]'s `autoConvert`/ + * `proxyConvert` exist (ROADMAP §7b closed the eager path). + */ +@OptIn(HighOverheadNativeCall::class) +internal fun pyObjectToNative(obj: PyObject): Any? { + if (PyNone.isNone(obj)) return null + + val typePtr = python.multiplatform.ffi.Python3.withPython { PyObject_Type(obj.pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to get the type of this object") + try { + return when (typePtr) { + // bool is a subtype of int at the C level; PyLong_AsLongLong works on it directly. + PyTypeChecks.boolType -> python.multiplatform.ffi.Python3.withPython { PyLong_AsLongLong(obj.pointer) } != 0L + PyTypeChecks.intType -> python.multiplatform.ffi.Python3.withPython { PyLong_AsLongLong(obj.pointer) } + PyTypeChecks.floatType -> python.multiplatform.ffi.Python3.withPython { PyFloat_AsDouble(obj.pointer) } + PyTypeChecks.strType -> python.multiplatform.ffi.Python3.withPython { PyUnicode_AsUTF8(obj.pointer) } ?: "" + // A ByteArray is a copy, so it is cacheable under the same rule the scalars are -- + // see PyBytes's class doc for why the read goes through `hex()` and what it costs. + // No wrapper is built on this path: readBufferAsByteArray takes the raw pointer. + PyTypeChecks.bytesType, PyTypeChecks.bytearrayType -> readBufferAsByteArray(obj.pointer) + PyTypeChecks.listType -> PyList(obj.pointer, true).toNativeList() + PyTypeChecks.tupleType -> PyTuple(obj.pointer, true).toNativeList() + PyTypeChecks.dictType -> PyDict(obj.pointer, true).toNativeMap() + PyTypeChecks.setType -> PySet(obj.pointer, true).toNativeSet() + PyTypeChecks.frozensetType -> PyFrozenSet(obj.pointer, true).toNativeSet() + // TODO(open design question): what should NATIVE conversion of a user-defined object + // produce? `str(obj)` is lossy and one-way -- `toNativeMap()`/`toNativeList()` on a + // container holding one silently turns it into its repr, which no caller can convert + // back. Deferring to PyContext is *not* the answer that was once assumed here: it is + // implemented now (ROADMAP §7b), and `proxyConvert` returns a `PyValue` wrapping a + // live PyObject, i.e. exactly what NATIVE exists to avoid. Calling it here would also + // invert the layering -- CollectionSupport is below `conversion/`, which calls *into* + // this function for ConversionStrategy.NATIVE. + // + // The question to answer first is which of these the boundary should carry, because + // each has a different lifetime rule (see docs/design/object-lifetime.md): + // (a) a `Map` built from the object's `__dict__`, recursively -- a + // real native projection, but it drops behaviour, cannot represent cycles, and + // needs a visited-set; + // (b) an upcall-table handle (§7), which is native in the sense that matters for + // Kotlin but is a reference, not a value; + // (c) keep `str(obj)` and document NATIVE as builtins-only, making the lossiness + // part of the contract rather than a gap. + // Measure before choosing: (a)'s cost is one `PyObject_GetAttrString` plus a full + // dict walk per object, against `str()`'s single call, on a container of N objects. + else -> obj.toString() + } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(typePtr) } // PyObject_Type: new reference, only needed for the dispatch above + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyDict.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyDict.kt index da5f48802..a1bb4eacf 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyDict.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyDict.kt @@ -1,71 +1,233 @@ package python.multiplatform.ffi.types.collections import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType +import python.multiplatform.ffi.adoptingNewReference import python.multiplatform.ffi.conversion.PyProxy +import python.multiplatform.ffi.exceptions.PyException import python.native.ffi.NativePointer +import python.native.ffi.PyDict_Clear +import python.native.ffi.PyDict_Contains +import python.native.ffi.PyDict_DelItem +import python.native.ffi.PyDict_GetItem +import python.native.ffi.PyDict_Items +import python.native.ffi.PyDict_New +import python.native.ffi.PyDict_SetItem +import python.native.ffi.PyDict_Size +import python.native.ffi.PyDict_Values +import python.native.ffi.PyList_AsTuple +import python.native.ffi.PySequence_Contains +import python.native.ffi.PyTuple_GetItem +import python.native.ffi.PyTuple_Size +import python.native.ffi.Py_DecRef + +/** + * Wrapper around a Python `dict` object, adopting `MutableMap` + * per the mermaid sketch (`MutableMap <|.. PyDict`). + * + * Backed by `PyDict_New`/`PyDict_GetItem`/`PyDict_SetItem`/`PyDict_DelItem`/ + * `PyDict_Contains`/`PyDict_Keys`/`PyDict_Values`/`PyDict_Items`/`PyDict_Clear`/ + * `PyDict_Size` (the last backs [size] directly). + * + * Note: the previous stub declared `PyProxy`, which was almost + * certainly copy-pasted from [python.multiplatform.ffi.types.basic.PyBool] + * and never updated -- corrected here to `PyProxy>`, the + * natural "fully native" projection of a dict. + */ +open class PyDict(pointer: NativePointer, borrowed: Boolean) : + PyObject(pointer, borrowed), PyProxy>, MutableMap { + companion object { + /** The `PyType` for `dict` (`builtins.dict`). */ + val TYPE: PyType by lazy { + val emptyDict = python.multiplatform.ffi.Python3.withPython { PyDict_New() } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to build dict() to derive its type") + deriveTypeAndRelease(emptyDict) + } + + /** Builds a new Python `dict` from [map], preserving key/value object identity. */ + fun fromMap(map: Map): PyDict { + val dictPtr = python.multiplatform.ffi.Python3.withPython { PyDict_New() } ?: throw PyException.fromCurrentError() ?: PyException("Failed to allocate dict") + // The dict is a bare pointer until the wrapper below adopts it, and populating it is + // fallible -- an unhashable key is enough. Dropping it there would leak the dict and, + // with it, every key and value it had already taken a reference to, with no wrapper + // for a cleaner to hang off. See ROADMAP §4. + return dictPtr.adoptingNewReference { + for ((k, v) in map) { + // PyDict_SetItem does not steal references to either key or value. + if (python.multiplatform.ffi.Python3.withPython { PyDict_SetItem(it, k.pointer, v.pointer) } != 0) { + throw PyException.fromCurrentError() ?: PyException("Failed to populate dict") + } + } + PyDict(it, false) + } + } + } + + override var cachedNativeValue: Map? = null + override var cachedPyObjectValue: PyObject? = null + + /** + * A single `(key, value)` snapshot pair. Backs [entries]/[keys]/[values] + * (which are computed, disconnected-from-the-live-dict-order snapshots + * that write back through to `this` [PyDict] on mutation) as well as + * [toNativeMap]. + */ + private class DictEntry( + override val key: PyObject, + initialValue: PyObject, + private val owner: PyDict, + ) : MutableMap.MutableEntry { + override var value: PyObject = initialValue + private set + + override fun setValue(newValue: PyObject): PyObject { + val old = value + owner.put(key, newValue) + value = newValue + return old + } + } + + /** One-shot, consistent `(key, value)` snapshot via `PyDict_Items` (avoids separate `Keys`/`Values` calls drifting out of sync). */ + private fun snapshotEntries(): List { + val itemsPtr = python.multiplatform.ffi.Python3.withPython { PyDict_Items(pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("PyDict_Items failed") + val itemsTuplePtr = python.multiplatform.ffi.Python3.withPython { PyList_AsTuple(itemsPtr) } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(itemsPtr) } // list of (k, v) 2-tuples, new reference, no longer needed once copied + if (itemsTuplePtr == null) throw PyException.fromCurrentError() ?: PyException("Failed to snapshot dict items") + // Nothing adopts the snapshot tuple, so its release belongs in a finally: the loop below + // dereferences three nullable results and would otherwise strand it on any of them. + try { + val count = python.multiplatform.ffi.Python3.withPython { PyTuple_Size(itemsTuplePtr) } + val result = ArrayList(count.toInt()) + for (i in 0 until count) { + val pairPtr = python.multiplatform.ffi.Python3.withPython { PyTuple_GetItem(itemsTuplePtr, i) }!! // borrowed, valid while itemsTuplePtr is alive + val keyPtr = python.multiplatform.ffi.Python3.withPython { PyTuple_GetItem(pairPtr, 0) }!! // borrowed + val valPtr = python.multiplatform.ffi.Python3.withPython { PyTuple_GetItem(pairPtr, 1) }!! // borrowed + result.add(DictEntry(PyObject(keyPtr, true), PyObject(valPtr, true), this)) + } + return result + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(itemsTuplePtr) } + } + } + + /** Snapshot conversion to a plain Kotlin map, recursively converting keys/values ([python.multiplatform.ffi.conversion.ConversionStrategy.NATIVE]). */ + fun toNativeMap(): Map { + val result = LinkedHashMap() + for (e in snapshotEntries()) { + result[pyObjectToNative(e.key)] = pyObjectToNative(e.value) + } + return result + } + + private inner class KeySnapshotSet(private val snapshot: List) : AbstractMutableSet() { + override val size: Int get() = snapshot.size + override fun iterator(): MutableIterator = object : MutableIterator { + var idx = 0 + override fun hasNext() = idx < snapshot.size + override fun next(): PyObject = snapshot[idx++] + override fun remove() { + check(idx > 0) { "next() has not been called, or remove() already called" } + this@PyDict.remove(snapshot[idx - 1]) + } + } + override fun add(element: PyObject): Boolean = throw UnsupportedOperationException("Cannot add a key without a value") + } + + private inner class ValueSnapshotCollection( + private val values: List, + private val keys: List, + ) : AbstractMutableCollection() { + override val size: Int get() = values.size + override fun iterator(): MutableIterator = object : MutableIterator { + var idx = 0 + override fun hasNext() = idx < values.size + override fun next(): PyObject = values[idx++] + override fun remove() { + check(idx > 0) { "next() has not been called, or remove() already called" } + this@PyDict.remove(keys[idx - 1]) + } + } + override fun add(element: PyObject): Boolean = throw UnsupportedOperationException("Cannot add to a dict's value view") + } + + private inner class EntrySnapshotSet( + private val snapshot: List>, + ) : AbstractMutableSet>() { + override val size: Int get() = snapshot.size + override fun iterator(): MutableIterator> = + object : MutableIterator> { + var idx = 0 + override fun hasNext() = idx < snapshot.size + override fun next(): MutableMap.MutableEntry = snapshot[idx++] + override fun remove() { + check(idx > 0) { "next() has not been called, or remove() already called" } + this@PyDict.remove(snapshot[idx - 1].key) + } + } + override fun add(element: MutableMap.MutableEntry): Boolean = + throw UnsupportedOperationException("Cannot add a raw entry; use PyDict.put") + } -open class PyDict(pointer: NativePointer, borrowed: Boolean): PyObject(pointer, borrowed), PyProxy, MutableMap { - override var cachedNativeValue: Boolean? - get() = TODO("Not yet implemented") - set(value) {} - override var cachedPyObjectValue: PyObject? - get() = TODO("Not yet implemented") - set(value) {} override val entries: MutableSet> - get() = TODO("Not yet implemented") + get() = EntrySnapshotSet(snapshotEntries()) override val keys: MutableSet - get() = TODO("Not yet implemented") - override val size: Int - get() = TODO("Not yet implemented") + get() = KeySnapshotSet(snapshotEntries().map { it.key }) override val values: MutableCollection - get() = TODO("Not yet implemented") + get() { + val es = snapshotEntries() + return ValueSnapshotCollection(es.map { it.value }, es.map { it.key }) + } + + override val size: Int + get() = python.multiplatform.ffi.Python3.withPython { PyDict_Size(pointer) }.toInt() override fun clear() { - TODO("Not yet implemented") + python.multiplatform.ffi.Python3.withPython { PyDict_Clear(pointer) } } override fun containsKey(key: PyObject): Boolean { - TODO("Not yet implemented") + val result = python.multiplatform.ffi.Python3.withPython { PyDict_Contains(pointer, key.pointer) } + if (result == -1) throw PyException.fromCurrentError() ?: PyException("Failed to check dict key membership") + return result == 1 } override fun containsValue(value: PyObject): Boolean { - TODO("Not yet implemented") + val valuesPtr = python.multiplatform.ffi.Python3.withPython { PyDict_Values(pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("PyDict_Values failed") + val result = python.multiplatform.ffi.Python3.withPython { PySequence_Contains(valuesPtr, value.pointer) } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(valuesPtr) } // new reference to a list, no longer needed + if (result == -1) throw PyException.fromCurrentError() ?: PyException("Failed to check dict value membership") + return result == 1 } override fun get(key: PyObject): PyObject? { - TODO("Not yet implemented") + // PyDict_GetItem returns a borrowed reference (and, unusually, suppresses errors -- + // null unambiguously means "missing key" here, not "error"). + val item = python.multiplatform.ffi.Python3.withPython { PyDict_GetItem(pointer, key.pointer) } ?: return null + return PyObject(item, true) } - override fun isEmpty(): Boolean { - TODO("Not yet implemented") - } + override fun isEmpty(): Boolean = size == 0 override fun put(key: PyObject, value: PyObject): PyObject? { - TODO("Not yet implemented") + val previous = get(key) + // PyDict_SetItem does not steal references to either key or value. + val rc = python.multiplatform.ffi.Python3.withPython { PyDict_SetItem(pointer, key.pointer, value.pointer) } + if (rc != 0) throw PyException.fromCurrentError() ?: PyException("Failed to set dict item") + return previous } override fun putAll(from: Map) { - TODO("Not yet implemented") + for ((k, v) in from) put(k, v) } override fun remove(key: PyObject): PyObject? { - TODO("Not yet implemented") - } - - fun asNative() { - - } - - fun asPyObject() { - - } - - companion object { - //fun from(value: Boolean): PyBool { - // return if (value) True else False - //} + val previous = get(key) ?: return null + val rc = python.multiplatform.ffi.Python3.withPython { PyDict_DelItem(pointer, key.pointer) } + if (rc != 0) throw PyException.fromCurrentError() ?: PyException("Failed to delete dict item") + return previous } } - -//fun Boolean.asPyObject(): PyBool = PyBool.from(this) diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyFrozenSet.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyFrozenSet.kt index 9a271c4f0..ef8fe5888 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyFrozenSet.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyFrozenSet.kt @@ -1,4 +1,106 @@ package python.multiplatform.ffi.types.collections -class PyFrozenSet { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType +import python.multiplatform.ffi.conversion.PyProxy +import python.multiplatform.ffi.exceptions.PyException +import python.native.ffi.NativePointer +import python.native.ffi.PyFrozenSet_New +import python.native.ffi.PyNumber_And +import python.native.ffi.PyNumber_Or +import python.native.ffi.PyNumber_Subtract +import python.native.ffi.PySet_Contains +import python.native.ffi.PySet_Size +import python.native.ffi.PyTuple_New +import python.native.ffi.PyTuple_SetItem +import python.native.ffi.Py_DecRef +import python.native.ffi.Py_IncRef + +/** + * Wrapper around a Python `frozenset` object. Not present in the mermaid + * sketch at all, but explicitly requested by the task; modelled as the + * read-only counterpart to [PySet] (`Set` rather than + * `MutableSet`), matching how `frozenset` relates to `set` in + * Python itself. + * + * Backed by `PyFrozenSet_New`/`PySet_Contains`/`PySet_Size` (these read-only + * query functions are shared between `set` and `frozenset` in the C API, + * `PySet_Size` taking an "anyset" for exactly that reason), plus + * `PyNumber_Or`/`And`/`Subtract` for the set-algebra operations, same as + * [PySet]. + */ +open class PyFrozenSet(pointer: NativePointer, borrowed: Boolean) : + PyObject(pointer, borrowed), PyProxy>, Set { + + companion object { + /** The `PyType` for `frozenset` (`builtins.frozenset`). */ + val TYPE: PyType by lazy { + val emptyTuple = python.multiplatform.ffi.Python3.withPython { PyTuple_New(0) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to build tuple() scratch buffer") + val emptyFrozenSet = python.multiplatform.ffi.Python3.withPython { PyFrozenSet_New(emptyTuple) } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(emptyTuple) } + if (emptyFrozenSet == null) throw PyException.fromCurrentError() ?: PyException("Failed to build frozenset() to derive its type") + deriveTypeAndRelease(emptyFrozenSet) + } + + /** Builds a new Python `frozenset` containing (references to) [elements]. */ + fun fromSet(elements: Set): PyFrozenSet { + val tuplePtr = python.multiplatform.ffi.Python3.withPython { PyTuple_New(elements.size.toLong()) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to allocate tuple() scratch buffer") + // See PyList.fromList for why the release covers the fill loop. + val setPtr = try { + elements.forEachIndexed { i, el -> + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_IncRef(el.pointer) } // PyTuple_SetItem steals; keep `el`'s own reference valid + python.multiplatform.ffi.Python3.withPython { PyTuple_SetItem(tuplePtr, i.toLong(), el.pointer) } + } + python.multiplatform.ffi.Python3.withPython { PyFrozenSet_New(tuplePtr) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(tuplePtr) } // scratch tuple, no longer needed once copied into the frozenset + } + if (setPtr == null) throw PyException.fromCurrentError() ?: PyException("Failed to build frozenset()") + return PyFrozenSet(setPtr, false) + } + } + + override var cachedNativeValue: Set? = null + override var cachedPyObjectValue: PyObject? = null + + /** Snapshot conversion to a plain Kotlin set, recursively converting elements ([python.multiplatform.ffi.conversion.ConversionStrategy.NATIVE]). */ + fun toNativeSet(): Set = snapshotElements(pointer).mapTo(LinkedHashSet()) { pyObjectToNative(it) } + + /** `self | other`, via `PyNumber_Or`. */ + fun union(other: PyFrozenSet): PyFrozenSet { + val result = python.multiplatform.ffi.Python3.withPython { PyNumber_Or(pointer, other.pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("frozenset union failed") + return PyFrozenSet(result, false) + } + + /** `self & other`, via `PyNumber_And`. */ + fun intersection(other: PyFrozenSet): PyFrozenSet { + val result = python.multiplatform.ffi.Python3.withPython { PyNumber_And(pointer, other.pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("frozenset intersection failed") + return PyFrozenSet(result, false) + } + + /** `self - other`, via `PyNumber_Subtract`. */ + fun difference(other: PyFrozenSet): PyFrozenSet { + val result = python.multiplatform.ffi.Python3.withPython { PyNumber_Subtract(pointer, other.pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("frozenset difference failed") + return PyFrozenSet(result, false) + } + + override val size: Int + get() = python.multiplatform.ffi.Python3.withPython { PySet_Size(pointer) }.toInt() + + override fun isEmpty(): Boolean = size == 0 + + override fun iterator(): Iterator = snapshotElements(pointer).iterator() + + override fun contains(element: PyObject): Boolean { + val result = python.multiplatform.ffi.Python3.withPython { PySet_Contains(pointer, element.pointer) } + if (result == -1) throw PyException.fromCurrentError() ?: PyException("Failed to check frozenset membership") + return result == 1 + } + + override fun containsAll(elements: Collection): Boolean = elements.all { contains(it) } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyList.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyList.kt index 675c0d634..02418afd1 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyList.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyList.kt @@ -1,4 +1,318 @@ package python.multiplatform.ffi.types.collections -class PyList { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType +import python.multiplatform.ffi.conversion.PyProxy +import python.multiplatform.ffi.exceptions.PyException +import python.native.ffi.NativePointer +import python.native.ffi.PyList_Append +import python.native.ffi.PyList_Insert +import python.native.ffi.PyList_Reverse +import python.native.ffi.PyList_Size +import python.native.ffi.PyList_Sort +import python.native.ffi.PyLong_FromLongLong +import python.native.ffi.PyObject_DelItem +import python.native.ffi.PyObject_GetItem +import python.native.ffi.PyObject_SetItem +import python.native.ffi.PySequence_Contains +import python.native.ffi.PySequence_List +import python.native.ffi.PyTuple_New +import python.native.ffi.PyTuple_SetItem +import python.native.ffi.Py_DecRef +import python.native.ffi.Py_IncRef + +/** + * Wrapper around a Python `list` object, adopting `MutableList` per + * the mermaid sketch (`MutableList <|.. PyList`). + * + * The sketch also has a separate `PyIterable` in the hierarchy above + * `PyMutableIterable <|-- PyList`, but Kotlin's `MutableList` already + * extends `Iterable` (transitively via `MutableCollection`/`Collection`), + * and its `iterator()` must return `MutableIterator` -- which + * [python.multiplatform.ffi.types.iteration.PyIterator] does not implement + * (it is a plain, read-only `Iterator`). Implementing both here would be a + * genuine return-type conflict, so `PyIterable` is deliberately not mixed + * into the collection types; it is reserved for wrappers around Python + * objects that are iterable but are not already a Kotlin collection type. + * + * [size] and [add] (`list.insert`) are backed directly by `PyList_Size` and + * `PyList_Insert`. Element access/mutation (`get`/`set`/`removeAt`) still + * goes through the generic sequence/object protocol + * (`PyObject_GetItem`/`SetItem`/`DelItem` with an integer index) rather than + * `PyList_GetItem`/`SetItem` -- those two are C-array-index-only (no + * negative-index support) and `PyList_SetItem` steals the reference it is + * given, which would need different reference-counting discipline here for + * no behavioural gain, since every index this class ever passes is already + * validated non-negative. + */ +open class PyList(pointer: NativePointer, borrowed: Boolean) : + PyObject(pointer, borrowed), PyProxy>, MutableList { + + companion object { + /** The `PyType` for `list` (`builtins.list`). */ + val TYPE: PyType by lazy { + val emptyTuple = python.multiplatform.ffi.Python3.withPython { PyTuple_New(0) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to build tuple() scratch buffer") + val emptyList = python.multiplatform.ffi.Python3.withPython { PySequence_List(emptyTuple) } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(emptyTuple) } + if (emptyList == null) throw PyException.fromCurrentError() ?: PyException("Failed to build list() to derive its type") + deriveTypeAndRelease(emptyList) + } + + /** Builds a new Python `list` containing (references to) [elements], in order. */ + fun fromList(elements: List): PyList { + val tuplePtr = python.multiplatform.ffi.Python3.withPython { PyTuple_New(elements.size.toLong()) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to allocate tuple() scratch buffer") + // The scratch tuple is owned by nobody until it is released here, so the release + // covers the fill loop as well as the conversion. + val listPtr = try { + elements.forEachIndexed { i, el -> + // PyTuple_SetItem steals the reference to its 3rd argument -- incref first so + // `el`'s own, independently-managed reference stays valid afterwards. + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_IncRef(el.pointer) } + python.multiplatform.ffi.Python3.withPython { PyTuple_SetItem(tuplePtr, i.toLong(), el.pointer) } + } + python.multiplatform.ffi.Python3.withPython { PySequence_List(tuplePtr) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(tuplePtr) } // scratch tuple, no longer needed once copied into the list + } + if (listPtr == null) throw PyException.fromCurrentError() ?: PyException("Failed to build list()") + return PyList(listPtr, false) + } + } + + override var cachedNativeValue: List? = null + override var cachedPyObjectValue: PyObject? = null + + /** Snapshot conversion to a plain Kotlin list, recursively converting elements ([python.multiplatform.ffi.conversion.ConversionStrategy.NATIVE]). */ + fun toNativeList(): List = map { pyObjectToNative(it) } + + /** `list.sort()`, backed by `PyList_Sort`. */ + fun sort() { + if (python.multiplatform.ffi.Python3.withPython { PyList_Sort(pointer) } != 0) throw PyException.fromCurrentError() ?: PyException("list.sort() failed") + } + + /** `list.reverse()`, backed by `PyList_Reverse`. */ + fun reverse() { + if (python.multiplatform.ffi.Python3.withPython { PyList_Reverse(pointer) } != 0) throw PyException.fromCurrentError() ?: PyException("list.reverse() failed") + } + + override val size: Int + get() = python.multiplatform.ffi.Python3.withPython { PyList_Size(pointer) }.toInt() + + private fun indexKey(index: Int): NativePointer = + python.multiplatform.ffi.Python3.withPython { PyLong_FromLongLong(index.toLong()) } ?: throw PyException.fromCurrentError() ?: PyException("Failed to build index object") + + override fun get(index: Int): PyObject { + val key = indexKey(index) + val item = python.multiplatform.ffi.Python3.withPython { PyObject_GetItem(pointer, key) } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(key) } // scratch index object, new reference, only needed for the lookup itself + if (item == null) throw PyException.fromCurrentError() ?: PyException("list index out of range: $index") + return PyObject(item, false) // PyObject_GetItem ("o[key]") returns a new reference + } + + override fun set(index: Int, element: PyObject): PyObject { + val previous = get(index) + val key = indexKey(index) + // PyObject_SetItem does not steal a reference to `element`. + val rc = python.multiplatform.ffi.Python3.withPython { PyObject_SetItem(pointer, key, element.pointer) } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(key) } + if (rc != 0) throw PyException.fromCurrentError() ?: PyException("Failed to set list index $index") + return previous + } + + override fun removeAt(index: Int): PyObject { + val previous = get(index) + val key = indexKey(index) + val rc = python.multiplatform.ffi.Python3.withPython { PyObject_DelItem(pointer, key) } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(key) } + if (rc != 0) throw PyException.fromCurrentError() ?: PyException("Failed to remove list index $index") + return previous + } + + override fun add(element: PyObject): Boolean { + // PyList_Append does not steal a reference to `item` (it increfs internally), + // unlike PyList_SetItem/PyTuple_SetItem. + if (python.multiplatform.ffi.Python3.withPython { PyList_Append(pointer, element.pointer) } != 0) { + throw PyException.fromCurrentError() ?: PyException("Failed to append to list") + } + return true + } + + override fun add(index: Int, element: PyObject) { + // PyList_Insert does not steal a reference to `item`, same as PyList_Append. + if (python.multiplatform.ffi.Python3.withPython { PyList_Insert(pointer, index.toLong(), element.pointer) } != 0) { + throw PyException.fromCurrentError() ?: PyException("list.insert($index, ...) failed") + } + } + + override fun addAll(index: Int, elements: Collection): Boolean { + if (elements.isEmpty()) return false + elements.forEachIndexed { i, el -> add(index + i, el) } + return true + } + + override fun addAll(elements: Collection): Boolean { + if (elements.isEmpty()) return false + for (el in elements) add(el) + return true + } + + override fun clear() { + // No PyList_Clear in this ABI subset either; pop from the end via the generic protocol. + while (size > 0) removeAt(size - 1) + } + + override fun isEmpty(): Boolean = size == 0 + + override fun remove(element: PyObject): Boolean { + val idx = indexOf(element) + if (idx == -1) return false + removeAt(idx) + return true + } + + override fun removeAll(elements: Collection): Boolean { + var changed = false + for (e in elements) { + while (remove(e)) changed = true + } + return changed + } + + override fun retainAll(elements: Collection): Boolean { + var changed = false + var i = size - 1 + while (i >= 0) { + val el = get(i) + if (elements.none { pyEquals(it.pointer, el.pointer) }) { + removeAt(i) + changed = true + } + i-- + } + return changed + } + + override fun contains(element: PyObject): Boolean { + val result = python.multiplatform.ffi.Python3.withPython { PySequence_Contains(pointer, element.pointer) } + if (result == -1) throw PyException.fromCurrentError() ?: PyException("Failed to check list membership") + return result == 1 + } + + override fun containsAll(elements: Collection): Boolean = elements.all { contains(it) } + + override fun indexOf(element: PyObject): Int { + for (i in 0 until size) { + if (pyEquals(get(i).pointer, element.pointer)) return i + } + return -1 + } + + override fun lastIndexOf(element: PyObject): Int { + for (i in size - 1 downTo 0) { + if (pyEquals(get(i).pointer, element.pointer)) return i + } + return -1 + } + + /** + * Index-based cursor over `this` list, driven entirely from the Kotlin + * side via [get]/[set]/[add]/[removeAt] rather than a live raw CPython + * iterator -- mutating a Python collection while iterating it directly + * (via `PyIter_Next` on a `list_iterator`) is undefined behaviour in + * CPython, so `remove()`/`add()` here never touch one. + */ + private inner class ListItr(startIndex: Int) : MutableListIterator { + private var cursor = startIndex + private var lastReturned = -1 + + override fun hasNext(): Boolean = cursor < size + override fun hasPrevious(): Boolean = cursor > 0 + override fun nextIndex(): Int = cursor + override fun previousIndex(): Int = cursor - 1 + + override fun next(): PyObject { + if (!hasNext()) throw NoSuchElementException() + val el = get(cursor) + lastReturned = cursor + cursor++ + return el + } + + override fun previous(): PyObject { + if (!hasPrevious()) throw NoSuchElementException() + cursor-- + lastReturned = cursor + return get(cursor) + } + + override fun remove() { + check(lastReturned != -1) { "next()/previous() has not been called, or remove()/add() already called" } + removeAt(lastReturned) + if (lastReturned < cursor) cursor-- + lastReturned = -1 + } + + override fun set(element: PyObject) { + check(lastReturned != -1) { "next()/previous() has not been called, or remove()/add() already called" } + this@PyList.set(lastReturned, element) + } + + override fun add(element: PyObject) { + this@PyList.add(cursor, element) + cursor++ + lastReturned = -1 + } + } + + override fun iterator(): MutableIterator = ListItr(0) + override fun listIterator(): MutableListIterator = ListItr(0) + override fun listIterator(index: Int): MutableListIterator = ListItr(index) + + private class PySubList( + private val list: MutableList, + private val fromIndex: Int, + private var toIndex: Int + ) : AbstractMutableList() { + + init { + if (fromIndex < 0 || toIndex > list.size) { + throw IndexOutOfBoundsException("fromIndex: $fromIndex, toIndex: $toIndex, size: ${list.size}") + } + if (fromIndex > toIndex) { + throw IllegalArgumentException("fromIndex: $fromIndex > toIndex: $toIndex") + } + } + + override val size: Int + get() = toIndex - fromIndex + + override fun get(index: Int): PyObject { + if (index < 0 || index >= size) throw IndexOutOfBoundsException("index: $index, size: $size") + return list[fromIndex + index] + } + + override fun set(index: Int, element: PyObject): PyObject { + if (index < 0 || index >= size) throw IndexOutOfBoundsException("index: $index, size: $size") + return list.set(fromIndex + index, element) + } + + override fun add(index: Int, element: PyObject) { + if (index < 0 || index > size) throw IndexOutOfBoundsException("index: $index, size: $size") + list.add(fromIndex + index, element) + toIndex++ + } + + override fun removeAt(index: Int): PyObject { + if (index < 0 || index >= size) throw IndexOutOfBoundsException("index: $index, size: $size") + val removed = list.removeAt(fromIndex + index) + toIndex-- + return removed + } + } + + override fun subList(fromIndex: Int, toIndex: Int): MutableList { + return PySubList(this, fromIndex, toIndex) + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PySet.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PySet.kt index 1c5b2dbeb..ef1b525bd 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PySet.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PySet.kt @@ -1,4 +1,176 @@ package python.multiplatform.ffi.types.collections -class PySet { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType +import python.multiplatform.ffi.conversion.PyProxy +import python.multiplatform.ffi.exceptions.PyException +import python.native.ffi.NativePointer +import python.native.ffi.PyNumber_And +import python.native.ffi.PyNumber_Or +import python.native.ffi.PyNumber_Subtract +import python.native.ffi.PySet_Add +import python.native.ffi.PySet_Clear +import python.native.ffi.PySet_Contains +import python.native.ffi.PySet_Discard +import python.native.ffi.PySet_New +import python.native.ffi.PySet_Pop +import python.native.ffi.PySet_Size +import python.native.ffi.PyTuple_New +import python.native.ffi.PyTuple_SetItem +import python.native.ffi.Py_DecRef +import python.native.ffi.Py_IncRef + +/** + * Wrapper around a Python `set` object, adopting `MutableSet` per + * the mermaid sketch (`MutableSet <|.. PySet`). See [PyList] for why this + * does not also implement `PyIterable`. + * + * Backed by `PySet_New`/`PySet_Add`/`PySet_Discard`/`PySet_Contains`/ + * `PySet_Pop`/`PySet_Clear`/`PySet_Size` (the last backs [size] directly), + * plus `PyNumber_Or`/`And`/`Subtract` for the + * set-algebra operations (CPython dispatches `|`/`&`/`-` on `set` through + * the generic number protocol) and `PySequence_Tuple` (via + * [snapshotElements]) for iteration, since sets have no indexed access. + */ +open class PySet(pointer: NativePointer, borrowed: Boolean) : + PyObject(pointer, borrowed), PyProxy>, MutableSet { + + companion object { + /** The `PyType` for `set` (`builtins.set`). */ + val TYPE: PyType by lazy { + val emptyTuple = python.multiplatform.ffi.Python3.withPython { PyTuple_New(0) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to build tuple() scratch buffer") + val emptySet = python.multiplatform.ffi.Python3.withPython { PySet_New(emptyTuple) } + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(emptyTuple) } + if (emptySet == null) throw PyException.fromCurrentError() ?: PyException("Failed to build set() to derive its type") + deriveTypeAndRelease(emptySet) + } + + /** Builds a new Python `set` containing (references to) [elements]. */ + fun fromSet(elements: Set): PySet { + // PySet_New's `iterable` parameter is non-nullable here even though the empty-set + // case is allowed at the C level -- build a scratch tuple to feed it either way. + val tuplePtr = python.multiplatform.ffi.Python3.withPython { PyTuple_New(elements.size.toLong()) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to allocate tuple() scratch buffer") + // See PyList.fromList: the scratch tuple's release covers the fill loop too, since + // nothing wraps it and an escape from the loop would strand it. + val setPtr = try { + elements.forEachIndexed { i, el -> + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_IncRef(el.pointer) } // PyTuple_SetItem steals; keep `el`'s own reference valid + python.multiplatform.ffi.Python3.withPython { PyTuple_SetItem(tuplePtr, i.toLong(), el.pointer) } + } + python.multiplatform.ffi.Python3.withPython { PySet_New(tuplePtr) } + } finally { + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_DecRef(tuplePtr) } // scratch tuple, no longer needed once copied into the set + } + if (setPtr == null) throw PyException.fromCurrentError() ?: PyException("Failed to build set()") + return PySet(setPtr, false) + } + } + + override var cachedNativeValue: Set? = null + override var cachedPyObjectValue: PyObject? = null + + /** Snapshot conversion to a plain Kotlin set, recursively converting elements ([python.multiplatform.ffi.conversion.ConversionStrategy.NATIVE]). */ + fun toNativeSet(): Set = snapshotElements(pointer).mapTo(LinkedHashSet()) { pyObjectToNative(it) } + + /** `self | other`, via `PyNumber_Or` (CPython dispatches `set.__or__` through the number protocol). */ + fun union(other: PySet): PySet { + val result = python.multiplatform.ffi.Python3.withPython { PyNumber_Or(pointer, other.pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("set union failed") + return PySet(result, false) + } + + /** `self & other`, via `PyNumber_And`. */ + fun intersection(other: PySet): PySet { + val result = python.multiplatform.ffi.Python3.withPython { PyNumber_And(pointer, other.pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("set intersection failed") + return PySet(result, false) + } + + /** `self - other`, via `PyNumber_Subtract`. */ + fun difference(other: PySet): PySet { + val result = python.multiplatform.ffi.Python3.withPython { PyNumber_Subtract(pointer, other.pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("set difference failed") + return PySet(result, false) + } + + /** Removes and returns an arbitrary element (`PySet_Pop`), throwing if the set is empty. */ + fun pop(): PyObject { + val item = python.multiplatform.ffi.Python3.withPython { PySet_Pop(pointer) } ?: throw PyException.fromCurrentError() ?: PyException("pop from an empty set") + return PyObject(item, false) // PySet_Pop returns a new reference + } + + override val size: Int + get() = python.multiplatform.ffi.Python3.withPython { PySet_Size(pointer) }.toInt() + + override fun isEmpty(): Boolean = size == 0 + + override fun iterator(): MutableIterator { + val snapshot = snapshotElements(pointer) + return object : MutableIterator { + var index = 0 + var lastReturned: PyObject? = null + override fun hasNext(): Boolean = index < snapshot.size + override fun next(): PyObject { + if (!hasNext()) throw NoSuchElementException() + val el = snapshot[index++] + lastReturned = el + return el + } + override fun remove() { + val el = lastReturned ?: throw IllegalStateException("next() has not been called, or remove() already called") + this@PySet.remove(el) + lastReturned = null + } + } + } + + override fun contains(element: PyObject): Boolean { + val result = python.multiplatform.ffi.Python3.withPython { PySet_Contains(pointer, element.pointer) } + if (result == -1) throw PyException.fromCurrentError() ?: PyException("Failed to check set membership") + return result == 1 + } + + override fun containsAll(elements: Collection): Boolean = elements.all { contains(it) } + + override fun add(element: PyObject): Boolean { + if (contains(element)) return false + // PySet_Add does not steal a reference to `key`. + if (python.multiplatform.ffi.Python3.withPython { PySet_Add(pointer, element.pointer) } != 0) throw PyException.fromCurrentError() ?: PyException("set.add() failed") + return true + } + + override fun addAll(elements: Collection): Boolean { + var changed = false + for (e in elements) if (add(e)) changed = true + return changed + } + + override fun remove(element: PyObject): Boolean { + val rc = python.multiplatform.ffi.Python3.withPython { PySet_Discard(pointer, element.pointer) } + if (rc == -1) throw PyException.fromCurrentError() ?: PyException("set.discard() failed") + return rc == 1 + } + + override fun removeAll(elements: Collection): Boolean { + var changed = false + for (e in elements) if (remove(e)) changed = true + return changed + } + + override fun retainAll(elements: Collection): Boolean { + var changed = false + for (e in snapshotElements(pointer)) { + if (elements.none { pyEquals(it.pointer, e.pointer) }) { + remove(e) + changed = true + } + } + return changed + } + + override fun clear() { + if (python.multiplatform.ffi.Python3.withPython { PySet_Clear(pointer) } != 0) throw PyException.fromCurrentError() ?: PyException("set.clear() failed") + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyTuple.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyTuple.kt index 8dfc10d34..0cbbd8e44 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyTuple.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/collections/PyTuple.kt @@ -1,4 +1,145 @@ package python.multiplatform.ffi.types.collections -class PyTuple { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.PyType +import python.multiplatform.ffi.adoptingNewReference +import python.multiplatform.ffi.conversion.PyProxy +import python.multiplatform.ffi.exceptions.PyException +import python.multiplatform.ffi.types.iteration.PyIterator +import python.native.ffi.NativePointer +import python.native.ffi.PyObject_GetIter +import python.native.ffi.PySequence_Contains +import python.native.ffi.PyTuple_GetItem +import python.native.ffi.PyTuple_GetSlice +import python.native.ffi.PyTuple_New +import python.native.ffi.PyTuple_SetItem +import python.native.ffi.PyTuple_Size +import python.native.ffi.Py_IncRef + +/** + * Wrapper around a Python `tuple` object, adopting the read-only + * `List` per the mermaid sketch (`List <|.. PyTuple`); tuples are + * immutable in Python so `MutableList` would be misleading. + * + * Backed directly by `PyTuple_New`/`PyTuple_GetItem`/`PyTuple_Size` -- + * unlike [PyList], the Stable ABI subset here does have dedicated + * tuple-specific entry points for construction and element access. + * `PyTuple_SetItem` also exists, but per CPython's own contract it is only + * safe to use while building a tuple that has not yet been exposed/published + * anywhere else -- it is exposed here only for [fromList]'s internal use, + * not as a public mutator (tuples stay immutable from the outside). + */ +open class PyTuple(pointer: NativePointer, borrowed: Boolean) : + PyObject(pointer, borrowed), PyProxy>, List { + + companion object { + /** The `PyType` for `tuple` (`builtins.tuple`). */ + val TYPE: PyType by lazy { + val emptyTuple = python.multiplatform.ffi.Python3.withPython { PyTuple_New(0) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to build tuple() to derive its type") + deriveTypeAndRelease(emptyTuple) + } + + /** Builds a new Python `tuple` containing (references to) [elements], in order. */ + fun fromList(elements: List): PyTuple { + val ptr = python.multiplatform.ffi.Python3.withPython { PyTuple_New(elements.size.toLong()) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to allocate tuple") + // Nothing owns the tuple until the wrapper on the last line adopts it, so an escape + // from the fill loop has to release it here or it is lost for good. + return ptr.adoptingNewReference { + elements.forEachIndexed { i, el -> + // PyTuple_SetItem steals the reference to its 3rd argument -- incref first so + // `el`'s own, independently-managed reference stays valid afterwards. + python.multiplatform.ffi.Python3.withPython { python.native.ffi.Py_IncRef(el.pointer) } + python.multiplatform.ffi.Python3.withPython { PyTuple_SetItem(it, i.toLong(), el.pointer) } + } + PyTuple(it, false) // PyTuple_New already returned a new/owned reference + } + } + } + + override var cachedNativeValue: List? = null + override var cachedPyObjectValue: PyObject? = null + + /** Snapshot conversion to a plain Kotlin list, recursively converting elements ([python.multiplatform.ffi.conversion.ConversionStrategy.NATIVE]). */ + fun toNativeList(): List { + val count = python.multiplatform.ffi.Python3.withPython { PyTuple_Size(pointer) } + val result = ArrayList(count.toInt()) + for (i in 0 until count) { + // Borrowed reference, valid while `pointer` (this tuple) is alive; wrap it with its + // own incref'd reference before handing it to the recursive converter. + val itemPtr = python.multiplatform.ffi.Python3.withPython { PyTuple_GetItem(pointer, i) }!! + result.add(pyObjectToNative(PyObject(itemPtr, true))) + } + return result + } + + override val size: Int + get() = python.multiplatform.ffi.Python3.withPython { PyTuple_Size(pointer) }.toInt() + + override fun get(index: Int): PyObject { + val itemPtr = python.multiplatform.ffi.Python3.withPython { PyTuple_GetItem(pointer, index.toLong()) } + ?: throw PyException.fromCurrentError() ?: PyException("tuple index out of range: $index") + // Borrowed reference -- incref so this new wrapper owns its own, independent reference. + return PyObject(itemPtr, true) + } + + override fun isEmpty(): Boolean = size == 0 + + override fun iterator(): Iterator { + val iterPtr = python.multiplatform.ffi.Python3.withPython { PyObject_GetIter(pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to get iterator for tuple") + return PyIterator(iterPtr, false) // PyObject_GetIter returns a new reference + } + + override fun contains(element: PyObject): Boolean { + val result = python.multiplatform.ffi.Python3.withPython { PySequence_Contains(pointer, element.pointer) } + if (result == -1) throw PyException.fromCurrentError() ?: PyException("Failed to check tuple membership") + return result == 1 + } + + override fun containsAll(elements: Collection): Boolean = elements.all { contains(it) } + + override fun indexOf(element: PyObject): Int { + val count = python.multiplatform.ffi.Python3.withPython { PyTuple_Size(pointer) } + for (i in 0 until count) { + val itemPtr = python.multiplatform.ffi.Python3.withPython { PyTuple_GetItem(pointer, i) }!! + if (pyEquals(itemPtr, element.pointer)) return i.toInt() + } + return -1 + } + + override fun lastIndexOf(element: PyObject): Int { + val count = python.multiplatform.ffi.Python3.withPython { PyTuple_Size(pointer) } + for (i in count - 1 downTo 0) { + val itemPtr = python.multiplatform.ffi.Python3.withPython { PyTuple_GetItem(pointer, i) }!! + if (pyEquals(itemPtr, element.pointer)) return i.toInt() + } + return -1 + } + + private inner class TupleIterator(startIndex: Int) : ListIterator { + private var cursor = startIndex + override fun hasNext(): Boolean = cursor < size + override fun hasPrevious(): Boolean = cursor > 0 + override fun nextIndex(): Int = cursor + override fun previousIndex(): Int = cursor - 1 + override fun next(): PyObject { + if (!hasNext()) throw NoSuchElementException() + return get(cursor++) + } + override fun previous(): PyObject { + if (!hasPrevious()) throw NoSuchElementException() + return get(--cursor) + } + } + + override fun listIterator(): ListIterator = TupleIterator(0) + override fun listIterator(index: Int): ListIterator = TupleIterator(index) + + override fun subList(fromIndex: Int, toIndex: Int): List { + val slicePtr = python.multiplatform.ffi.Python3.withPython { PyTuple_GetSlice(pointer, fromIndex.toLong(), toIndex.toLong()) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to slice tuple[$fromIndex:$toIndex]") + return PyTuple(slicePtr, false) // PyTuple_GetSlice returns a new reference + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/iteration/PyIterable.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/iteration/PyIterable.kt new file mode 100644 index 000000000..abfd869f9 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/iteration/PyIterable.kt @@ -0,0 +1,18 @@ +package python.multiplatform.ffi.types.iteration + +import python.multiplatform.ffi.PyObject + +/** + * Base contract for wrapper types around Python objects that support the + * iterable protocol (`__iter__`, i.e. `PyObject_GetIter`). + * + * The mermaid sketch models this after `java.lang.Iterable` + * (`forEach(Consumer)`, `spliterator()`), but this is a Kotlin Multiplatform + * `commonMain` type -- `java.util.function.Consumer` and + * `java.util.Spliterator` are JVM-only and not available here. Kotlin's own + * [Iterable] already provides `forEach` as a common extension function built + * purely on top of [iterator], so nothing is lost by not redeclaring it. + */ +interface PyIterable : Iterable { + override fun iterator(): PyIterator +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/iteration/PyIterator.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/iteration/PyIterator.kt index 04fc1732a..c37ac704d 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/iteration/PyIterator.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/iteration/PyIterator.kt @@ -1,4 +1,55 @@ package python.multiplatform.ffi.types.iteration -class PyIterator { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.exceptions.PyException +import python.native.ffi.NativePointer +import python.native.ffi.PyErr_Occurred +import python.native.ffi.PyIter_Next + +/** + * Wraps a Python iterator object (anything `iter()` can return, i.e. an + * object exposing `__next__`) as a Kotlin [Iterator]. + * + * The mermaid sketch also lists a `remove()` method on this interface, but + * that has no counterpart in Python's iterator protocol (there is no + * generic "remove the element I just yielded" operation -- mutation, where + * supported at all, happens through the underlying container, e.g. + * [python.multiplatform.ffi.types.collections.PyList.removeAt]). It is + * intentionally omitted here rather than faked. + */ +class PyIterator(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed), Iterator { + + /** One-slot lookahead buffer filled by [PyIter_Next]; `null` once exhausted (and not mid-[hasNext] check). */ + private var lookahead: PyObject? = null + private var exhausted = false + + /** Backed by `PyIter_Check` + a one-slot lookahead buffer filled by `PyIter_Next`. */ + override fun hasNext(): Boolean { + if (exhausted) return false + if (lookahead != null) return true + + val next = python.multiplatform.ffi.Python3.withPython { PyIter_Next(pointer) } + if (next == null) { + // python.multiplatform.ffi.Python3.withPython { PyIter_Next() } returning NULL is ambiguous by itself -- it means either "exhausted" + // or "an error occurred while producing the next element". Disambiguate via the + // error indicator rather than assuming exhaustion. + val error = python.multiplatform.ffi.Python3.withPython { PyErr_Occurred() } + if (error != null) { + throw PyException.fromCurrentError() ?: PyException("Iterator raised an error") + } + exhausted = true + return false + } + + lookahead = PyObject(next, false) // PyIter_Next returns a new reference + return true + } + + /** Backed by `PyIter_Next`; throws [NoSuchElementException] once the iterator is exhausted. */ + override fun next(): PyObject { + if (!hasNext()) throw NoSuchElementException("Iterator is exhausted") + val result = lookahead!! + lookahead = null + return result + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/modules/PyModule.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/modules/PyModule.kt index 20ccbb5a1..6c29ebf78 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/modules/PyModule.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/modules/PyModule.kt @@ -1,11 +1,148 @@ package python.multiplatform.ffi.types.modules +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.exceptions.PyException +import python.multiplatform.ffi.pyErrorOrGeneric +import python.multiplatform.ffi.types.basic.PyNone +import python.multiplatform.ffi.types.collections.PyDict +import python.multiplatform.ffi.types.iteration.PyIterator +import python.native.ffi.NativePointer +import python.native.ffi.PyErr_Clear +import python.native.ffi.PyModule_GetDict +import python.native.ffi.PyModule_GetFilenameObject +import python.native.ffi.PyModule_GetName +import python.native.ffi.PyObject_GetIter +import python.native.ffi.PyObject_Length +import python.native.ffi.PyObject_Type -class PyModule { - //val builtins: Builtins -} +/** + * Wrapper around a Python module object (whatever + * [python.multiplatform.ffi.Python3.import] / `PyImport_ImportModule` + * returns). + * + * Attribute access (`module.attr`) is already covered by the inherited + * [PyObject.getAttr]/[PyObject.setAttr]; `name`/`dict`/`file` are instead + * backed directly by `PyModule_GetName`/`PyModule_GetDict`/ + * `PyModule_GetFilenameObject` -- one FFI crossing each (`PyModule_GetName` + * needs none at all beyond that, since it hands back an already-decoded + * `const char*` rather than a `PyObject*` requiring a further + * `PyUnicode_AsUTF8` call) instead of the two-crossing generic attribute + * path (`PyObject_GetAttrString` + a `PyUnicode_AsUTF8`/`toString()` decode). + * `doc` still goes through `getAttrOrNull("__doc__")`: there is no + * `PyModule_GetDoc`-equivalent direct accessor in this ABI subset. + */ +open class PyModule(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed) { + + /** `module.__name__`, backed directly by `PyModule_GetName`. */ + val name: String + get() = python.multiplatform.ffi.Python3.withPython { PyModule_GetName(pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to get the module's __name__") + + /** `module.__doc__`, or `null` if the module has none. */ + val doc: String? + get() { + val docObj = getAttrOrNull("__doc__") ?: return null + if (PyNone.isNone(docObj)) { + return null + } + return docObj.toString() + } + + /** `module.__file__`, or `null` for built-in/frozen modules. */ + val file: String? + get() { + // PyModule_GetFilenameObject: new reference on success; NULL + AttributeError set + // if the module has no __file__ (e.g. built-in/frozen modules) -- that failure mode + // is expected here, so clear the indicator rather than propagate it as an exception. + val filePtr = python.multiplatform.ffi.Python3.withPython { PyModule_GetFilenameObject(pointer) } ?: run { + python.multiplatform.ffi.Python3.withPython { PyErr_Clear() } + return null + } + val fileObj = PyObject(filePtr, false) + if (PyNone.isNone(fileObj)) { + return null + } + return fileObj.toString() + } + /** `module.__dict__`: the module's namespace, backed directly by `PyModule_GetDict`. */ + val dict: PyDict + get() { + // PyModule_GetDict returns a *borrowed* reference; borrowed = true here makes this + // PyDict wrapper take its own, independent, incref'd reference to it. + val dictPtr = python.multiplatform.ffi.Python3.withPython { PyModule_GetDict(pointer) } + ?: throw PyException.fromCurrentError() ?: PyException("Failed to get the module's __dict__") + return PyDict(dictPtr, true) + } + /** + * Looks up [name] in this module's namespace, throwing [PyException] if + * it is not defined -- equivalent to [PyObject.getAttr] but named to + * match how one thinks about "a symbol from a module". + */ + @Throws(PyException::class) + fun get(name: String): PyObject = getAttr(name) +} + +/** + * Thin, curated view over the `builtins` module (the implicit namespace that + * backs unqualified names like `len`, `print`, `type` in Python source). + * + * Left minimal on purpose: everything it would expose is already reachable + * generically via [PyModule.get] on the `builtins` module returned by + * [python.multiplatform.ffi.Python3.import]; this only exists as a + * convenience surface for the handful of builtins the library itself leans + * on internally (e.g. `len`, `iter`, `type` when implementing the bodies of + * the collection/iterator wrappers). + */ class Builtins( + /** + * The `builtins` module this view was constructed over. + * + * Public rather than private because none of the three functions below + * actually goes through it (see the note on each), so anything else a + * caller wants from `builtins` still has to be reachable: + * `builtins.module.get("sorted")`. + */ + val module: PyModule +) { + /** + * `len(obj)`. + * + * Implemented as `PyObject_Length` rather than as a call to the `len` + * builtin. They are the same operation -- CPython's `builtins.len` is a + * thin wrapper over exactly this -- but going direct costs one FFI + * crossing and skips the Python-level call, the argument tuple and the + * intermediate `int` object the call would otherwise produce. + */ + fun len(obj: PyObject): Int { + val length = python.multiplatform.ffi.Python3.withPython { PyObject_Length(obj.pointer) } + // PyObject_Length returns -1 with the error indicator set for anything without __len__. + if (length < 0) throw pyErrorOrGeneric("len() failed") + return length.toInt() + } -) + /** `iter(obj)`. Uses `PyObject_GetIter` for the same reason [len] uses `PyObject_Length`. */ + fun iter(obj: PyObject): PyIterator { + // PyObject_GetIter returns a new reference, which the PyIterator adopts. + val iterPointer = python.multiplatform.ffi.Python3.withPython { PyObject_GetIter(obj.pointer) } + ?: throw pyErrorOrGeneric("iter() failed") + return PyIterator(iterPointer, borrowed = false) + } + + /** + * `type(obj)`. + * + * Returns a fresh wrapper owning its own reference, so the caller may + * close it. [PyObject.Type] answers the same question with a cached, + * interned [python.multiplatform.ffi.PyType] instead -- prefer that one + * unless an independently-owned wrapper is actually wanted, and do not + * close what it hands back. + */ + fun type(obj: PyObject): PyObject { + // PyObject_Type returns a new reference. + val typePointer = python.multiplatform.ffi.Python3.withPython { PyObject_Type(obj.pointer) } + ?: throw pyErrorOrGeneric("type() failed") + return PyObject(typePointer, borrowed = false) + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PyEllipsis.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PyEllipsis.kt index d11cc9302..6f4c381c8 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PyEllipsis.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PyEllipsis.kt @@ -1,4 +1,45 @@ package python.multiplatform.ffi.types.utilities -class PyEllipsis { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pyErrorOrGeneric +import python.native.ffi.NativePointer +import python.native.ffi.PyDict_GetItemString +import python.native.ffi.PyEval_GetBuiltins + +/** + * Wrapper around Python's `Ellipsis` (`...`) singleton. See + * [python.multiplatform.ffi.types.basic.PyNone] for the same + * singleton-access caveat. + * + * `Py_Ellipsis` is a *data* symbol, not a function, so it is not something + * this project's function-oriented FFI layer can reach: the Stable ABI + * guarantees the symbol exists, but `EmbedAPI` only declares entry points. + * The name is however bound in the builtins namespace, so a single dictionary + * lookup gets at the very same object -- CPython creates exactly one + * `Ellipsis` per interpreter, and `builtins.Ellipsis` *is* it, not a copy. + * That is what makes the identity comparison in [isEllipsis] meaningful. + */ +class PyEllipsis private constructor(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed) { + companion object { + private var _instance: PyEllipsis? = null + + /** Returns the singleton `Ellipsis` object, initialising it on first access. */ + fun get(): PyEllipsis { + return _instance ?: run { + // PyEval_GetBuiltins and PyDict_GetItemString both return BORROWED references, + // so the wrapper is built with borrowed = true and takes one of its own. + val builtins = Python3.withPython { PyEval_GetBuiltins() } + ?: throw pyErrorOrGeneric("The builtins namespace is unavailable") + val ellipsisPointer = Python3.withPython { PyDict_GetItemString(builtins, "Ellipsis") } + ?: throw pyErrorOrGeneric("builtins.Ellipsis is not available") + val ellipsis = PyEllipsis(ellipsisPointer, borrowed = true) + _instance = ellipsis + ellipsis + } + } + + /** Whether [obj] is Python's `Ellipsis` singleton (`obj is ...`). */ + fun isEllipsis(obj: PyObject): Boolean = obj.pointer == get().pointer + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PyRange.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PyRange.kt index d03c41ec9..e9636cb90 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PyRange.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PyRange.kt @@ -1,4 +1,65 @@ package python.multiplatform.ffi.types.utilities -class PyRange { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.attrAsInt +import python.multiplatform.ffi.callBuiltinStealing +import python.multiplatform.ffi.newIntsOrNone +import python.multiplatform.ffi.pyErrorOrGeneric +import python.multiplatform.ffi.types.iteration.PyIterable +import python.multiplatform.ffi.types.iteration.PyIterator +import python.native.ffi.NativePointer +import python.native.ffi.PyObject_GetIter +import python.native.ffi.PyObject_Length + +/** + * Wrapper around a Python `range` object. + * + * There is no `PyRange_*` family in the C API at all (let alone the limited + * one) -- `range` is an ordinary builtin type with no dedicated entry points + * -- so construction goes through `builtins.range(...)` and the three bounds + * are read back as the plain attributes they are. + */ +open class PyRange(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed), PyIterable { + companion object { + /** + * Builds a new `range(start, stop, step)`. + * + * A zero [step] is rejected by Python itself with `ValueError`, which + * surfaces here as a [python.multiplatform.ffi.exceptions.PyException] + * rather than being pre-checked in Kotlin -- one source of truth for + * the rule. + */ + fun of(start: Int, stop: Int, step: Int = 1): PyRange = Python3.withPython { + // callBuiltinStealing consumes the three new int references on every path. + PyRange(callBuiltinStealing("range", newIntsOrNone(start, stop, step)), borrowed = false) + } + } + + val start: Int get() = attrAsInt("start") + val stop: Int get() = attrAsInt("stop") + val step: Int get() = attrAsInt("step") + + /** + * `len(range(...))`. + * + * `range` computes its length arithmetically rather than iterating, so + * this is one FFI crossing regardless of how wide the range is. A range + * longer than `Int.MAX_VALUE` cannot be reported through this `Int` + * return type; `len()` on such a range raises `OverflowError` in Python + * too, so the ceiling is not new here. + */ + val size: Int + get() { + val length = Python3.withPython { PyObject_Length(pointer) } + if (length < 0) throw pyErrorOrGeneric("Failed to compute len() of this range") + return length.toInt() + } + + override fun iterator(): PyIterator { + // PyObject_GetIter returns a new reference, which the PyIterator adopts. + val iterPointer = Python3.withPython { PyObject_GetIter(pointer) } + ?: throw pyErrorOrGeneric("Failed to obtain an iterator over this range") + return PyIterator(iterPointer, borrowed = false) + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PySlice.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PySlice.kt index b0fd57f2b..808001dd0 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PySlice.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/types/utilities/PySlice.kt @@ -1,4 +1,33 @@ package python.multiplatform.ffi.types.utilities -class PySlice { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.attrAsIntOrNull +import python.multiplatform.ffi.callBuiltinStealing +import python.multiplatform.ffi.newIntsOrNone +import python.native.ffi.NativePointer + +/** + * Wrapper around a Python `slice` object (`start:stop:step`). + * + * `PySlice_New` and `PySlice_Unpack` are both outside the limited API and are + * not declared in this project's `EmbedAPI` surface either, so a slice is + * built by calling `builtins.slice(...)` and its three components are read + * back as the attributes they are. Each component is `None` rather than + * absent when omitted, which is why the accessors are nullable: `None` maps + * to `null`, and only `null` -- a non-integer, non-`None` component is still + * an error. + */ +open class PySlice(pointer: NativePointer, borrowed: Boolean) : PyObject(pointer, borrowed) { + companion object { + /** Builds a new `slice(start, stop, step)`; any of the three may be `null` (Python's `None`). */ + fun of(start: Int? = null, stop: Int? = null, step: Int? = null): PySlice = Python3.withPython { + // callBuiltinStealing consumes the three new references on every path. + PySlice(callBuiltinStealing("slice", newIntsOrNone(start, stop, step)), borrowed = false) + } + } + + val start: Int? get() = attrAsIntOrNull("start") + val stop: Int? get() = attrAsIntOrNull("stop") + val step: Int? get() = attrAsIntOrNull("step") +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/AsyncUpcall.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/AsyncUpcall.kt new file mode 100644 index 000000000..481069cc2 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/AsyncUpcall.kt @@ -0,0 +1,301 @@ +package python.multiplatform.ffi.upcall + +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.exceptions.PyException +import python.multiplatform.ffi.types.basic.PyBool +import python.multiplatform.ffi.types.basic.PyInt +import python.multiplatform.ffi.types.basic.PyString +import python.multiplatform.ffi.withGIL +import python.multiplatform.reflection.ExposedCallable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.ObjectReference +import python.multiplatform.reflection.TypeTag +import python.native.ffi.Py_IncRef +import python.native.ffi.toNativePointer +import python.native.ffi.toRawValue + +/** + * The delivery half of `docs/design/upcall.md`: how the outcome of a Kotlin coroutine that + * could not finish inside the C frame reaches the Python code waiting for it. + * + * [PendingCall] is the half that starts the coroutine and parks its outcome; this is the half that + * attaches to its `onCompleted` seam. The design doc picks candidate **(C)** -- `asyncio` -- as the + * surface, implemented by candidate **(B)** -- a downcall from the completing thread -- because + * `loop.call_soon_threadsafe` *is* a downcall. Nothing here needs a binding that was not already in + * `EmbedAPI`; `AsyncCompletionProbeTest` measured that before any of this was written. + * + * ### Two returns, and the fast one is the common one + * + * `suspend` is a signature, not a promise to suspend. A body that never reaches a suspension point + * is already complete when `PendingCall.start` returns (measured: + * `PendingCallTest.aBlockThatNeverSuspendsIsAlreadyCompleteBeforeStartReturns`), and that call gets + * the real value marshalled through the ordinary path -- **no `Future`, no event loop, no + * `asyncio` import**. Only a call that actually suspended pays for one. + * + * The cost of that choice is that the boundary returns two different Python types for one Kotlin + * declaration, so the Python-side proxy has to be an `async def` that awaits its result only when + * the result is awaitable. [PythonProxySource] generates exactly that. + * + * ### Cancellation + * + * `Future.cancel()` settles the `Future` immediately, so a completion can arrive for a call nobody + * is waiting for any more. Delivering it would raise `InvalidStateError` inside a loop callback; + * [resolve] documents what that was measured to cost and what is done about it. The Kotlin side of + * cancellation -- what [PendingCall.cancel] can and cannot do -- is on [PendingCall]. + * + * It used to tell the Kotlin coroutine nothing at all until it finished. [armCancellationNotice] is + * the other direction: the `Future` carries a handle on the [PendingCall], and its own done callback + * hands the cancellation back over the same `(long) -> int` shape `_pm_release` uses. A body that + * checks `ensureActive()` therefore stops at its next checkpoint rather than at the end. Where the + * host has not bound `_pm_cancel`, nothing is armed and nothing is rooted, and the behaviour is the + * one that existed before -- cancellation observed at completion by [resolve]'s `done()` check. + * + * ### What requires the application to be async + * + * The slow path needs a **running** loop on the calling thread, because the upcall arrives from + * inside a coroutine and `asyncio.get_running_loop()` is what names the loop that coroutine is on. + * With nothing running there is nowhere to deliver to, and this fails loudly rather than handing + * back something that would never resolve. The coroutine has already been started by then -- it had + * to be, since whether it suspends is only knowable after starting it -- so its eventual resumption + * is stranded. That is a real cost of candidate (C) and it is not hidden. + * + * ### Reference conventions + * + * - The `Future` handed to Python is a **new reference**, like every other trampoline result: the + * Kotlin wrapper keeps the one `create_future` gave it and Python gets an increment of its own. + * - The wrapper stays reachable because the completion lambda captures it, the lambda is held by + * the [PendingCall], and the [PendingCall] is held by whatever holds the suspended continuation. + * When the completion fires, [PendingCall] drops the listener and both wrappers become + * collectable, at which point their cleaners return the references. + * - Nothing here holds a *borrowed* pointer across a GIL release. + * + * ### Threading + * + * The completion arrives on whatever thread resumed the coroutine, which is generally **not** the + * thread that took the upcall, so it takes its own GIL scope before touching anything + * (`commonMain/README.md`: "Every C API call needs the GIL"). [withGIL] is the right scope here + * rather than [UpcallTrampoline]'s unconditional `PyGILState_Ensure`: this is not an entry point + * from C, so the thread's nesting depth is its own honest record. A completer thread CPython has + * never seen has depth 0 and takes a real `PyGILState_Ensure`; a completion delivered from inside a + * later upcall runs under the depth that upcall's `attached` already set, and correctly skips. + * + * Nothing is thrown out of the completion. [PendingCall.deliver] catches into + * [PendingCall.deliveryFailure], which matters because on the fast-completing path the listener can + * run inside the C frame with nothing above it to unwind into. + */ +internal object AsyncUpcall { + + /** + * Turns what a suspending entry's generated body returned into the `PyObject *` the C frame has + * to hand back. + * + * @param entry the table entry, read for [ExposedCallable.returnType] -- the *declared* return + * type, which is what both the fast path and `set_result` marshal with. + * @param result must be the [PendingCall] the generated body produced. + * @return a **new** reference: either the marshalled value or an `asyncio.Future`. + * @throws Throwable whatever the coroutine failed with, if it failed before suspending. The + * caller ([UpcallTrampoline.invoke]) is already inside the `catch` that turns that into + * Python's error indicator, so a fast-path failure raises at the call site exactly as a + * synchronous one does. + */ + fun deliver(entry: ExposedCallable, result: Any?): Long { + val call = result as? PendingCall + ?: throw IllegalStateException( + "${entry.name} is marked suspending but its body returned ${result ?: "null"} " + + "instead of a PendingCall" + ) + + // The fast path, checked before anything imports asyncio: no loop is consulted, so an + // exposed `suspend fun` that never suspends works in a program with no event loop at all. + if (call.isDone) { + call.failure?.let { throw it } + return UpcallTrampoline.marshalResult(entry.returnType, call.value) + } + + val loop = runningLoop() + val future = loop.getAttr("create_future").invoke() + val tag = entry.returnType + // Resolved here, on the upcall thread, rather than inside the completion: the completing + // thread may be one CPython has never seen, and this keeps its GIL scope down to the calls + // that have to be there. `PythonProxySource` installs it if nothing else has. + val settle = PythonProxySource.settleFunction() + val handle = armCancellationNotice(call, future) + // Registering after the future exists: a completion that lands between the isDone check + // above and this line fires the listener inline, on this thread, with the GIL already held + // -- which is exactly what the listener is written to tolerate. + call.onCompleted { completed -> resolve(loop, future, settle, tag, completed, handle) } + + // The trampoline's contract: the result is a new reference. The wrapper keeps its own, + // because the completion still needs it. + Py_IncRef(future.pointer) + return future.pointer.toRawValue() + } + + /** + * `asyncio.get_running_loop()` -- the loop belonging to the coroutine this upcall came out of. + * + * Not `get_event_loop()`: that one will happily create or return a loop nobody is running, + * which would queue the completion somewhere no one ever drains. Not cached either, because the + * module object would outlive an interpreter that gets finalized and this is the slow path by + * construction -- a call that genuinely suspended is not counting attribute lookups. + */ + private fun runningLoop(): PyObject = + Python3.import("asyncio").getAttr("get_running_loop").invoke() + + /** + * Gives Python a way to tell [call] it has been abandoned, and returns the handle that costs. + * + * `docs/design/upcall.md` §5.6 could only offer cancellation observed *at completion*, + * because nothing carried the identity of a running [PendingCall] across to Python. This is the + * thing it said was missing: a [HandleTable] handle rides on the `Future` -- captured by the + * done callback `_pm_watch` attaches, not stored as an attribute, so nothing depends on whether + * `asyncio.Future` tolerates having attributes set on it -- and `_pm_cancel` brings it back. + * + * ### Lifetime + * + * The handle is a GC root; [HandleTable] reclaims nothing on its own. Two things give it back, + * and they are deliberately not the same thing: + * + * - **`_pm_watch`'s done callback**, on every way the `Future` can settle. This is the one that + * covers a body that never cooperates: the call is cancelled, the coroutine runs on forever, + * and Kotlin's completion path never gets a chance to release anything. + * - **[resolve]**, when the coroutine finishes. This covers a `Future` whose loop stopped + * before it could run the callback, and it is what makes the common outcome self-sufficient. + * + * Either may run first and neither has to know about the other, because a handle table release + * is generational: the second one finds a slot whose generation has moved on and does nothing, + * even if the slot has since been reissued to someone else. + * + * What is still not reclaimed is a call whose `Future` is never settled *and* whose coroutine + * never finishes. That call has leaked its continuation as well, and no handle scheme can be + * the thing that notices. + * + * @return the handle to release at completion, or `null` when no notice was armed -- either + * because the host has not bound `_pm_cancel`/`_pm_release`, or because arming it failed. + * Nothing is rooted in that case. + */ + private fun armCancellationNotice(call: PendingCall, future: PyObject): ObjectReference? { + val watch = PythonProxySource.watchFunctionOrNull() ?: return null + val handle = HandleTable.register(call) + return try { + watch.invoke(future, PyInt.from(handle.raw)) + handle + } catch (t: Throwable) { + // `add_done_callback` on a Future that is somehow already settled runs the callback via + // call_soon rather than raising, so this is the unusual path -- but a root that nothing + // will ever be asked to give back is worse than no notice at all. + HandleTable.release(handle) + null + } + } + + /** + * Hands one completed [call]'s outcome to [future], from whichever thread finished it. + * + * `call_soon_threadsafe` rather than `set_result` directly: a `Future` may only be resolved + * from its own loop's thread, and this is the documented hand-off. It is also the only part of + * candidate (C) that is not simply candidate (B) -- and it is itself a downcall, which is the + * design doc's point. + * + * ### Delivering to a `Future` that has stopped waiting + * + * `Future.cancel()` is complete the instant Python calls it: the awaiting coroutine already has + * its `CancelledError` and the `Future` is settled forever. The Kotlin coroutine knew nothing + * about that and finished anyway, so its outcome arrives at a `Future` that will not take it, + * and `set_result` on a settled `Future` raises `InvalidStateError`. + * + * **Measured, before this was guarded** (`AsyncUpcallCancellationTest`): the raise lands inside + * the loop callback, where asyncio hands it to `call_exception_handler` -- one + * `InvalidStateError: invalid state` reported against an application that did nothing wrong and + * can do nothing about it, since the value being reported was abandoned deliberately. It did + * *not* leak the error indicator and did *not* damage the next call; both were checked, because + * this repository has twice been bitten by an indicator left set by one call and charged to + * another. So the cost was noise, not corruption -- but noise nobody can act on. + * + * Two things are done about it, and the second is the one that actually holds. Scheduling is + * skipped when the `Future` is already settled, which is the cheap common case; and what gets + * scheduled is `_pm_settle`, which re-checks `done()` **inside the loop callback**. Only the + * second is a guarantee: the check here and the callback are separated by a hand-off to another + * thread, and the cancellation can land in between. + */ + private fun resolve( + loop: PyObject, + future: PyObject, + settle: PyObject, + tag: TypeTag, + call: PendingCall, + handle: ObjectReference?, + ) { + withGIL { + try { + // A loop that has been closed cannot be handed anything; `call_soon_threadsafe` + // would raise, and that exception has nowhere useful to go from here. Checking is + // not a guarantee -- the loop can close between here and the call -- so the raise + // is still possible, and PendingCall.deliver parks it in `deliveryFailure`. + if (loop.getAttr("is_closed").invoke().toString() == "True") return@withGIL + + // Already settled: cancelled, or resolved by something else. Nothing to deliver, + // and recording it on the call keeps `isCancelled` honest for anyone who reads it + // after the fact. This is a shortcut, not the guard -- see the class-level note + // above. It is also the fallback for a host with no `_pm_cancel` binding, which is + // the only place cancellation is observed at all on such a target. + if (future.getAttr("done").invoke().toString() == "True") { + call.cancel() + return@withGIL + } + + // A body that cooperated with `PendingCall.cancel` unwinds with + // CancellationException. Turning that into a `set_exception` would be answering a + // question nobody is still asking -- and the Future it would answer is the settled + // one that caused the cancellation. Dropped here rather than in the guard so no + // Python exception object is built for it at all. + if (call.isCancelled) return@withGIL + + val callSoonThreadsafe = loop.getAttr("call_soon_threadsafe") + val failure = call.failure + if (failure != null) { + callSoonThreadsafe.invoke(settle, future, PyBool.from(false), pythonExceptionFor(failure)) + return@withGIL + } + + // Marshalling can fail on its own (a tag the value does not match, an allocation + // that did not come back). Turning that into a rejection rather than letting it + // escape is what keeps the awaiting coroutine from hanging forever on a Future + // nobody resolved. + val marshalled = try { + val raw = UpcallTrampoline.marshalResult(tag, call.value) + PyObject( + raw.toNativePointer() ?: error("marshalling ${call.value} produced NULL"), + borrowed = false, + ) + } catch (t: Throwable) { + callSoonThreadsafe.invoke(settle, future, PyBool.from(false), pythonExceptionFor(t)) + return@withGIL + } + callSoonThreadsafe.invoke(settle, future, PyBool.from(true), marshalled) + } finally { + // The completion half of the handle's lifetime; see [armCancellationNotice]. Under + // the GIL, which is the rule every HandleTable mutation lives under, and in a + // `finally` so a delivery that raised does not turn into a permanent root. A + // release the Future's done callback already performed is a no-op. + if (handle != null) HandleTable.release(handle) + } + } + } + + /** + * The Python exception instance `set_exception` needs. + * + * A [PyException] carries the original Python exception it was built from, so a Python error + * that crossed into Kotlin and back out again reaches the awaiting coroutine as *itself*, + * traceback and type intact, rather than as a `RuntimeError` wrapping its message. Anything + * else becomes a `RuntimeError` with the Kotlin message, which is the same mapping + * [UpcallTrampoline]'s synchronous failure path uses. + */ + private fun pythonExceptionFor(failure: Throwable): PyObject { + if (failure is PyException) failure.value?.let { return it } + val message = failure.message ?: failure::class.simpleName ?: "the exposed suspending function failed" + return UpcallTrampoline.runtimeErrorClass().invoke(PyString.from(message)) + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt new file mode 100644 index 000000000..812ebacad --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt @@ -0,0 +1,589 @@ +package python.multiplatform.ffi.upcall + +import python.multiplatform.reflection.ExposedCallable + +/** + * `python_multiplatform` -- the binder's own root module, and the one thing both installers share. + * + * ### Why it exists + * + * A Kotlin-named module (`androidx.compose.material3`, `kotlin.text`, a consumer's own package) + * carries **Kotlin's own surface**: Kotlin declaration names, keyword arguments by **Kotlin + * parameter names**, Kotlin defaults for what a call leaves out. Nothing is renamed -- no + * snake_case, no `pythonx` -- because a Kotlin name in Python means the original Kotlin + * (`docs/INTENT.md` §2.2). Making that surface Pythonic is the business of a real Python package + * built on top of it (pythonx-compose, §2.3), and this module is what such a package reads to do + * it by rule rather than by a wrapper per declaration. + * + * Two installers put functions into those modules -- [PythonProxySource] renders every table entry + * eagerly; [python.multiplatform.ffi.pythonx.PythonxAdapter] adapts names lazily, adds overload + * dispatch, receiver proxies and value-class rules -- and each used to describe a declaration in + * its own way. The pieces that must not differ between them live here, once: + * + * | name | what | + * |---|---| + * | `KOTLIN_DEFAULT` | the `inspect.Parameter.default` of a parameter whose Kotlin declaration has a default. Passing it (or leaving the parameter out) means "Kotlin's own default" | + * | `describe(fn)` | the declaration(s) behind a binder-made callable: a tuple of dicts, one per overload (see [SOURCE]) | + * | `describe(module, name)` | the declaration(s) a bound name of a Kotlin-named module stands for, read from the table and never by reading the name | + * | `describe_member(type, name)` | the declaration(s) a member name of a Kotlin type stands for on its proxy -- an extension function or a property, supertypes included -- read from the table | + * | `kotlin_function(raw, row)` | what [PythonProxySource] wraps each rendered function in: keyword arguments by Kotlin name, omitted defaults, and the metadata above | + * | `signature_of(rows)` | the `inspect.Signature` for one declaration (`(*args, **kwargs)` for an overload set) | + * + * ### The public contract, for a Pythonic layer + * + * Every function the binder puts on a Kotlin-named module -- whichever installer put it there -- + * answers `inspect.signature(fn)` with the **Kotlin** parameter names, in declaration order: + * + * - an extension receiver is a positional-only parameter named `receiver`; + * - a parameter with a Kotlin default has `default is python_multiplatform.KOTLIN_DEFAULT`; + * - a required parameter that follows a defaulted one (Compose's trailing `content`) is + * keyword-only, because Python has no other spelling for "required after optional"; + * - each annotation is the declared Kotlin type name as a string (`'androidx.compose.ui.unit.Dp'`); + * - the synthetic `$composer`/`$changed`/`$default` slots of a `@Composable` never appear. + * + * An overload set (a base name the binding layer dispatches, `padding` for `padding__Dp`, + * `padding__Dp_Dp`, ...) has no single signature and answers `(*args, **kwargs)`. + * + * `python_multiplatform.describe(fn)` answers for both, as a tuple with one dict per declaration: + * + * {'name': 'androidx.compose.material3.Checkbox', # the bound Kotlin name (overload suffix included) + * 'kind': 'FUNCTION', 'suspend': False, + * 'receiver': None, # the extension receiver's Kotlin type, or None + * 'returns': 'kotlin.Unit', # the declared return type, or None + * 'composable': True, # has a $composer slot + * 'content': None, # name of a trailing @Composable lambda parameter + * 'parameters': ( + * {'name': 'checked', 'type': 'kotlin.Boolean', 'tag': 'BOOLEAN', + * 'has_default': False, 'value_class': False, 'composable_lambda': False}, + * ...)} + * + * `python_multiplatform.describe(module, name)` describes **any** bound name of a Kotlin-named + * module -- a function, an overload set's base name, an explicit `name__Types` spelling, and a + * named constant (`kind: 'STATIC_GETTER'`, `returns` its declared type, no parameters) -- **without + * evaluating it**: a constant's Kotlin getter is never invoked. One declaration answers one dict; + * an overload set's base name answers the tuple `describe(fn)` gives for it. A name that is not a + * bound declaration (a child package, a typo) raises `AttributeError`; `describe(fn)` with one + * argument is unchanged. So `describe(androidx.compose.ui.Alignment, 'End')['returns']` is + * `'androidx.compose.ui.Alignment.Horizontal'`. + * + * ### Members of a type: `describe_member(kotlin_type_name, kotlin_member_name)` + * + * What a member resolver needs to build a keyword map, and what `describe(module, name)` cannot + * give it: an extension is declared in *its* package, which is not the receiver's, so the receiver's + * type is the only key a resolver holds (issue #54). The answer has `describe()`'s shape -- a tuple of + * dicts, one per declaration -- and is exactly what the proxy of that type serves under that name: + * + * - an **extension function** answers every declaration the name stands for on that type -- for an + * overload set's base name every candidate, for an explicit `name__Types` spelling that one; + * `describe_member('androidx.compose.ui.Modifier', 'padding')` lists the `padding` overloads with + * their Kotlin parameter names; + * - a **property** (issue #38) answers its getter (`kind: 'GETTER'`, no parameters, `returns` its + * type) and, for a `var`, its setter (`kind: 'SETTER'`, one parameter, the value written). A member + * property and an extension property look the same here; `receiver` is the type it is read on; + * - a name the type does not have is looked up on each type the table says it **is a**, nearest + * first -- the same lookup the proxy uses, so a description cannot disagree with the attribute; + * - nothing is invoked: no getter runs, no handle is resolved; + * - a name served on neither the type nor any supertype, or any name while the binding layer is not + * installed (it is what serves members on a proxy), raises `AttributeError`. + * + * ### Modules list and serve their children + * + * A Kotlin-named module's `dir()` lists its bound declarations, the receiver types under it, and + * its **direct child packages and objects** (an object whose members are bound, `Alignment`, is a + * package here). Each under its Kotlin name. Reading a child as an attribute imports it, so + * `androidx.compose.ui.Alignment.End` works after `import androidx` alone; a name nothing is bound + * under stays an `AttributeError`. + * + * `value_class` is true for a parameter whose marshalling tag is a primitive while its declared + * type is not a Kotlin primitive (`Dp`, `Color`, `TextUnit`) -- the only machine-checkable form of + * "this is a value class". `composable_lambda` is true for a `@Composable` function-typed slot. + * + * ### Member resolvers: the one hook for names on a proxy + * + * A proxy the binder returns (`Modifier.padding(16)` is one) serves its Kotlin members under their + * Kotlin names only. A Pythonic package built on the binder can say what else a member may be called: + * + * python_multiplatform.binding.add_member_resolver(fn) + * python_multiplatform.binding.remove_member_resolver(fn) + * + * fn(kotlin_type_name, requested_name, kotlin_member_names) + * -> kotlin_name | (kotlin_name, keyword_map) | None + * + * - `fn` is asked only when the proxy has **no** Kotlin member named `requested_name`; a Kotlin name + * never reaches it. `kotlin_member_names` is a tuple of the Kotlin member names the type has. + * - The first resolver (in registration order) returning a name that is in `kotlin_member_names` wins, + * and that Kotlin member is served. Any other answer (`None`, an unknown name, a name starting with + * `_`) means "not mine"; with no resolver answering, the result is the usual `AttributeError`. + * - **Keyword maps.** An answer may be `(kotlin_name, keyword_map)`, `keyword_map` being + * `{python_kw: kotlinParam}`: it is applied when that member is called, and keywords it does not + * name pass through as Kotlin parameter names (an unknown one is still the binding layer's + * `TypeError`, which lists the Kotlin parameters). For a member the proxy *does* have under its + * Kotlin name (`padding`), resolvers are asked only when a call to it passes keyword arguments, and + * only for its keyword map: an answer counts when it is `(that same name, keyword_map)`; a plain + * name or `None` means no map. A call with no keywords never asks. A tuple of any other shape is a + * `TypeError`. + * - The binder renames nothing itself: with no resolver registered behaviour is exactly the + * Kotlin-names-only behaviour above. + * - Answers are cached in the resolver registry (cleared when a resolver is added or removed, and + * when a table is registered), never written onto a proxy class: `dir()` of a proxy and the class + * `__dict__` show Kotlin names only. Removing a resolver removes its aliases. + * - Registering the same function twice is a no-op. Resolvers are process-wide, like the binding layer. + * + * ### `__signature__` is lazy + * + * [PythonProxySource] renders every entry eagerly, and building an `inspect.Signature` for each of + * a Compose-sized table at install would be paid by every test and every app start for an answer + * almost nobody asks for. `inspect.signature` follows `__wrapped__` to the first object that has a + * `__signature__`, so each published function's `__wrapped__` is a small carrier whose + * `__signature__` is built on first use. (A *callable* `__signature__` is not portable: the embedded + * CPython rejected one with "unexpected object in __signature__ attribute".) Read it through + * `inspect.signature`, not by reading an attribute directly. + * + * ### Where it lives, and why it is not `pythonx` + * + * A binder-owned name. `pythonx` is a real package on disk (pythonx-compose); a module the binder + * puts into `sys.modules` under that name would be answered before `sys.path` is ever consulted, + * and the real package's files could not load (`AGENTS.md` §12.2). + */ +object KotlinSurface { + + /** The root module's name in `sys.modules`. */ + const val MODULE_NAME: String = "python_multiplatform" + + /** + * The hand-written Python. Delivered inside an `r"""..."""` literal, so it must contain no + * triple double-quote and must not end in a backslash; docstrings are `'''` for that reason. + */ + val SOURCE: String = """ + # GENERATED-FREE: hand-written Python. See KotlinSurface.kt. + # + # `python_multiplatform` -- the binder's root module. Kotlin-named modules carry Kotlin's own + # surface; this module describes it, so that a Pythonic package can be built on top by rule. + + import inspect as _inspect + import keyword as _keyword + import sys as _sys + + _pm_root = True + + # Where the binding layer (`PythonxAdapter`) lives once it is installed. + BINDING_MODULE = 'python_multiplatform.binding' + + # The `UpcallTable` epoch the proxy layer last rendered for (`PythonProxySource.install` + # stamps it). The binding layer carries its own (`_registered_epoch`); see `kotlin_function` + # for how the two decide which layer answers a call. + _proxy_epoch = -1 + + + class _KotlinDefault: + '''The `default` of a parameter whose Kotlin declaration has one. + + Leaving the parameter out, or passing this object, both mean "Kotlin's own default" -- + the value is never sent to Kotlin, which supplies it itself. + ''' + + __slots__ = () + + def __repr__(self): + return '' + + def __copy__(self): + return self + + def __deepcopy__(self, _memo): + return self + + def __reduce__(self): + return 'KOTLIN_DEFAULT' + + + KOTLIN_DEFAULT = _KotlinDefault() + + + # One table row, as `KotlinSurface.row` renders it and `PythonxAdapter.renderTable` sends it. + _NAME, _ARITY, _KIND, _SUSPEND, _NAMES, _TAGS, _TYPES, _RTAG, _RTYPE, _EXT, _RECV, _DEFAULTS = range(12) + + _RECEIVER = '' + _PROPERTY_KINDS = ('GETTER', 'SETTER') + _COMPOSER = '${'$'}composer' + _FUNCTION_PREFIX = 'kotlin.Function' + _COMPOSABLE_MARK = '@Composable' + _SUPERTYPE_SEPARATOR = '<:' + _PRIMITIVE_TAGS = ('INT', 'FLOAT', 'BOOLEAN', 'STRING') + _KOTLIN_PRIMITIVES = frozenset(( + 'kotlin.Byte', 'kotlin.Short', 'kotlin.Int', 'kotlin.Long', 'kotlin.Float', 'kotlin.Double', + 'kotlin.Boolean', 'kotlin.Char', 'kotlin.String', 'kotlin.Unit', 'kotlin.Any', + )) + + + def _field(seq, index): + return seq[index] if seq and index < len(seq) else None + + + def _is_synthetic(name): + # `${'$'}composer`, `${'$'}changed`, `${'$'}default`: the Compose compiler's own slots. A dollar is + # not a Python identifier character, so no caller can ever have meant one of these. + return name.startswith('${'$'}') + + + def _is_composable_lambda(type_name): + if not type_name or not type_name.startswith(_FUNCTION_PREFIX): + return False + return _COMPOSABLE_MARK in type_name.partition('(')[0] + + + def _declared_return(type_name): + if not type_name: + return None + return type_name.split(_SUPERTYPE_SEPARATOR)[0] + + + def _describe_row(row): + names = row[_NAMES] or () + tags = row[_TAGS] or () + types = row[_TYPES] or () + defaults = row[_DEFAULTS] or () + parameters = [] + for index in range(row[_ARITY]): + name = _field(names, index) + if name is not None and (name == _RECEIVER or _is_synthetic(name)): + continue + type_name = _field(types, index) + tag = _field(tags, index) + parameters.append({ + 'name': name, + 'type': type_name, + 'tag': tag, + 'has_default': bool(_field(defaults, index)), + 'value_class': bool( + type_name and tag in _PRIMITIVE_TAGS and type_name not in _KOTLIN_PRIMITIVES + ), + 'composable_lambda': _is_composable_lambda(type_name), + }) + last = parameters[-1] if parameters else None + return { + 'name': row[_NAME], + 'kind': row[_KIND], + 'suspend': bool(row[_SUSPEND]), + # A property's receiver is not one of its slots (`CallableKind.GETTER`), but it is + # still the type the property is read on -- the one fact a reader of a property needs. + 'receiver': row[_RECV] if row[_EXT] or row[_KIND] in _PROPERTY_KINDS else None, + 'returns': _declared_return(row[_RTYPE]), + 'composable': _COMPOSER in names, + 'content': last['name'] if last is not None and last['composable_lambda'] else None, + 'parameters': tuple(parameters), + } + + + _NO_NAME = object() + + + def describe(fn, name=_NO_NAME): + '''The Kotlin declaration(s) behind [fn]: a tuple of dicts, one per overload. + + [fn] is anything the binder put on a Kotlin-named module -- a function the proxy layer + rendered, a callable or overload set the binding layer adapted, or an extension already + applied to its receiver (`Modifier.padding`). See `KotlinSurface` for the dict's keys. + + `describe(module, name)` describes the bound Kotlin name [name] of a Kotlin-named + [module] without reading it -- a `STATIC_GETTER` (`Alignment.End`) included, whose getter + is never run. One declaration answers one dict; an overload set's base name answers the + same tuple `describe(fn)` gives for it. + ''' + if name is not _NO_NAME: + return _describe_named(fn, name) + rows = getattr(fn, '__kotlin_rows__', None) + if rows is None: + raise TypeError(repr(fn) + ' is not a Kotlin declaration the binder exposed') + return tuple(_describe_row(row) for row in rows) + + + def _describe_named(module, name): + if not isinstance(module, type(_sys)): + raise TypeError('describe(module, name) takes a Kotlin-named module, not ' + repr(module)) + if not isinstance(name, str): + raise TypeError('describe(module, name): name must be a str') + rows = None + binding = _sys.modules.get(BINDING_MODULE) + if binding is not None: + rows = binding._rows_named(module.__name__, name) + if rows is None: + # The proxy layer's own, read without touching the attribute: a static property's row + # is kept on the module's type (`PythonProxySource._pm_kotlin_rows`), and a rendered + # function is found by `getattr_static`, which runs no descriptor -- so a constant's + # Kotlin getter is never invoked here. + row = (getattr(type(module), '_pm_kotlin_rows', None) or {}).get(name) + if row is not None: + rows = (row,) + else: + rows = getattr(_inspect.getattr_static(module, name, None), '__kotlin_rows__', None) + if not rows: + raise AttributeError( + "module '" + module.__name__ + "' has no bound Kotlin declaration named '" + name + "'" + ) + described = tuple(_describe_row(row) for row in rows) + return described[0] if len(described) == 1 else described + + + def describe_member(kotlin_type_name, kotlin_member_name): + '''The Kotlin declaration(s) the member [kotlin_member_name] of [kotlin_type_name] stands for. + + A tuple of dicts, `describe()`'s shape: every overload of an extension function, or a + property's getter (and setter, for a `var`). Looked up on the type and then on every type + the table says it is a, nearest first -- exactly what a proxy of that type serves under + that name. Invokes nothing. `AttributeError` for a name the type is not served under. + ''' + if not isinstance(kotlin_type_name, str) or not isinstance(kotlin_member_name, str): + raise TypeError('describe_member(kotlin_type_name, kotlin_member_name) takes two str') + binding = _sys.modules.get(BINDING_MODULE) + rows = binding._member_rows(kotlin_type_name, kotlin_member_name) if binding is not None else None + if not rows: + raise AttributeError( + "Kotlin type '" + kotlin_type_name + "' has no member named '" + kotlin_member_name + "'" + + ('' if binding is not None else ' (the binding layer, which serves members, is not installed)') + ) + return tuple(_describe_row(row) for row in rows) + + + _GENERIC = _inspect.Signature([ + _inspect.Parameter('args', _inspect.Parameter.VAR_POSITIONAL), + _inspect.Parameter('kwargs', _inspect.Parameter.VAR_KEYWORD), + ]) + + + def _annotation(type_name): + return type_name if type_name else _inspect.Parameter.empty + + + def signature_of(rows, drop_receiver=False): + '''The `inspect.Signature` of one declaration; `(*args, **kwargs)` for anything else. + + An overload set has no single signature, and a declaration whose Kotlin parameter name + Python cannot spell (`in`, `is`, `from` ...) cannot be written as one -- its keyword is + still accepted through `**{...}`, and `describe` still carries the true name. + ''' + if len(rows) != 1: + return _GENERIC + row = rows[0] + names = row[_NAMES] or () + types = row[_TYPES] or () + defaults = row[_DEFAULTS] or () + P = _inspect.Parameter + params = [] + if not names: + for index in range(row[_ARITY]): + params.append(P('a' + str(index), P.POSITIONAL_ONLY, annotation=_annotation(_field(types, index)))) + if drop_receiver and params: + params = params[1:] + return _inspect.Signature(params, return_annotation=_annotation(_declared_return(row[_RTYPE]))) + receiver_name = 'receiver' if 'receiver' not in names else '_receiver' + kind = P.POSITIONAL_OR_KEYWORD + seen_default = False + for index, name in enumerate(names): + if _is_synthetic(name): + continue + annotation = _annotation(_field(types, index)) + if name == _RECEIVER: + if not drop_receiver: + params.append(P(receiver_name, P.POSITIONAL_ONLY, annotation=annotation)) + continue + if not name.isidentifier() or _keyword.iskeyword(name): + return _GENERIC + has_default = bool(_field(defaults, index)) + if not has_default and seen_default: + kind = P.KEYWORD_ONLY + seen_default = seen_default or has_default + params.append(P( + name, kind, + default=KOTLIN_DEFAULT if has_default else P.empty, + annotation=annotation, + )) + return _inspect.Signature(params, return_annotation=_annotation(_declared_return(row[_RTYPE]))) + + + class _SignatureCarrier: + '''What a published function's `__wrapped__` points at: the raw function, plus a signature + built on first use. + + `inspect.signature` unwraps `__wrapped__` until it reaches an object that has a + `__signature__`, and asks that. Building an `inspect.Signature` for every entry of a + Compose-sized table at install would be paid by every start for an answer almost nobody + asks for; this defers it to the first `inspect.signature` call. Calling it calls the raw + function, so anything that follows `__wrapped__` still reaches a working callable. + ''' + + __slots__ = ('_raw', '_rows', '_sig') + + def __init__(self, raw, rows): + self._raw = raw + self._rows = rows + self._sig = None + + def __call__(self, *args): + return self._raw(*args) + + @property + def __signature__(self): + if self._sig is None: + self._sig = signature_of(self._rows) + return self._sig + + + class _Missing: + __slots__ = () + + + _MISSING = _Missing() + + + def bind_slots(row, args, kwargs): + '''Positional and Kotlin-named keyword arguments -> one value per slot of [row]. + + A defaulted slot the call left out (or filled with `KOTLIN_DEFAULT`) becomes `None`: the + walked Kotlin body tests `args[i] == null` and takes a call expression that does not + mention the parameter, so the compiler supplies the default + (`ArtifactScanner.presenceBranchedCall`). This is the binding the proxy layer does on its + own; the binding layer (`PythonxAdapter`) does the same and adds coercion on top. + ''' + name = row[_NAME] + arity = row[_ARITY] + names = row[_NAMES] or () + defaults = row[_DEFAULTS] or () + if _COMPOSER in names: + raise TypeError( + name + ' is a @Composable: its ${'$'}composer slot is filled by the binding layer ' + + '(python_multiplatform.binding, installed by PythonxAdapter.install), which is not installed' + ) + if len(args) > arity: + raise TypeError(name + '() takes ' + str(arity) + ' arguments, got ' + str(len(args))) + slots = list(args) + [_MISSING] * (arity - len(args)) + for key, value in kwargs.items(): + index = -1 + if key != _RECEIVER and not _is_synthetic(key): + for slot, declared in enumerate(names): + if declared == key: + index = slot + break + if index < 0: + raise TypeError(name + "() got an unexpected keyword argument '" + key + "'") + if slots[index] is not _MISSING: + raise TypeError(name + "() got multiple values for argument '" + key + "'") + slots[index] = value + for index, value in enumerate(slots): + if value is _MISSING or value is KOTLIN_DEFAULT: + if _field(defaults, index): + slots[index] = None + continue + missing = _field(names, index) or ('a' + str(index)) + raise TypeError(name + "() missing required argument '" + missing + "'") + return tuple(slots) + + + def kotlin_function(raw, row): + '''What `PythonProxySource` publishes for each rendered function entry. + + [raw] is the rendered positional function (`a0..aN`, the raw boundary's own calling + convention, unchanged). The callable returned here is the module's one owner of the name: + + - when the binding layer is installed **for a table at least as new as the one this + function was rendered for**, and knows this declaration, the call is its -- keyword + arguments, defaults, coercion, value-class rules, receiver proxies. Decided at call + time from the two `UpcallTable` epochs the installers stamped, so for one table the + answer is the same whichever installer ran first; across tables the newer install + wins, and neither layer's handles from an older table are ever used; + - otherwise a call that fills every slot positionally goes straight to [raw], and any + other call is mapped onto the slots by Kotlin parameter name first (`bind_slots`). + ''' + name = row[_NAME] + arity = row[_ARITY] + composable = _COMPOSER in (row[_NAMES] or ()) + has_defaults = any(row[_DEFAULTS] or ()) + modules = _sys.modules + default = KOTLIN_DEFAULT + + def kotlin(*args, **kwargs): + binding = modules.get(BINDING_MODULE) + if binding is not None and binding._registered_epoch >= _proxy_epoch: + delegate = binding._callable_named(name) + if delegate is not None: + return delegate(*args, **kwargs) + if not kwargs and len(args) == arity and not composable: + if not has_defaults: + return raw(*args) + for value in args: + if value is default: + break + else: + return raw(*args) + return raw(*bind_slots(row, args, kwargs)) + + kotlin.__wrapped__ = _SignatureCarrier(raw, (row,)) + kotlin.__kotlin_rows__ = (row,) + if _inspect.iscoroutinefunction(raw): + # A `suspend fun`: [raw] is an `async def`, and this hands back the coroutine it + # builds, so it answers `inspect.iscoroutinefunction` the way [raw] does. + _inspect.markcoroutinefunction(kotlin) + return kotlin + """.trimIndent() + + /** + * Installs [SOURCE] as `sys.modules['python_multiplatform']`, once per interpreter. + * + * Guarded on `sys.modules` rather than on a Kotlin flag, for the reason `PythonxAdapter`'s + * delivery gives: a test fixture that re-initialises the interpreter would otherwise be told + * "installed" about an interpreter that no longer exists. + * + * `__path__` makes it a package, so `python_multiplatform.binding` can be a submodule of it. + */ + val DELIVERY: String + get() = buildString { + require(!SOURCE.contains("\"\"\"")) { "the python_multiplatform source must not contain a triple double-quote" } + require(!SOURCE.endsWith("\\")) { "the python_multiplatform source must not end in a backslash" } + appendLine("import sys as _pms_sys") + appendLine("import types as _pms_types") + appendLine("if not getattr(_pms_sys.modules.get('$MODULE_NAME'), '_pm_root', False):") + appendLine(" _pms_src = r\"\"\"") + appendLine(SOURCE) + appendLine("\"\"\"") + appendLine(" _pms_mod = _pms_sys.modules.get('$MODULE_NAME') or _pms_types.ModuleType('$MODULE_NAME')") + appendLine(" if not hasattr(_pms_mod, '__path__'):") + appendLine(" _pms_mod.__path__ = []") + appendLine(" _pms_sys.modules['$MODULE_NAME'] = _pms_mod") + appendLine(" exec(compile(_pms_src, '$MODULE_NAME/__init__.py', 'exec'), _pms_mod.__dict__)") + appendLine(" del _pms_src, _pms_mod") + append("del _pms_sys, _pms_types") + } + + /** + * One table row as a Python tuple literal -- the shape `_register_table` and + * `kotlin_function` both read. Shared so the two installers cannot describe one entry + * differently. + */ + fun row(entry: ExposedCallable): String = buildString { + append("(") + append(entry.name.quoted()) + append(", ${entry.arity}") + append(", ${entry.kind.name.quoted()}") + append(", ${entry.isSuspend.py()}") + append(", ${entry.paramNames.map { it.quoted() }.tuple()}") + append(", ${entry.paramTypes.map { it.name.quoted() }.tuple()}") + append(", ${entry.paramTypeNames.map { it.quoted() }.tuple()}") + append(", ${entry.returnType.name.quoted()}") + append(", ${entry.returnTypeName?.quoted() ?: "None"}") + append(", ${entry.isExtension.py()}") + append(", ${entry.receiverTypeName?.quoted() ?: "None"}") + append(", ${entry.paramHasDefault.map { it.py() }.tuple()}") + append(")") + } + + private fun Boolean.py(): String = if (this) "True" else "False" + + private fun List.tuple(): String = when (size) { + 0 -> "()" + 1 -> "(${this[0]},)" + else -> "(${joinToString(", ")})" + } + + private fun String.quoted(): String = + "'" + replace("\\", "\\\\").replace("'", "\\'") + "'" +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PendingCall.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PendingCall.kt new file mode 100644 index 000000000..799344f1e --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PendingCall.kt @@ -0,0 +1,258 @@ +package python.multiplatform.ffi.upcall + +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.coroutineContext +import kotlin.coroutines.startCoroutine + +/** + * One Kotlin coroutine started from a frame that had to return without it, and the place its + * outcome waits until something asks for it. + * + * This is the half of `docs/design/upcall.md` that all three candidate conventions share. + * The problem the doc states is structural: CPython calls a C function pointer, that C frame has + * to hand back a `PyObject *` before it returns, and a suspension has nothing to hand back. + * `runBlocking` "solves" it by re-seizing the very thread that would have been freed -- on Android + * that thread is one CPython created, so blocking it stops the interpreter that is waiting on the + * answer. What is left is to return *something else* and deliver the value later, and every way of + * doing that begins here. + * + * ### What this deliberately does not do + * + * It does not deliver anything to Python. [onCompleted] is the single seam a delivery convention + * attaches to, and [AsyncUpcall] is the one that does -- it resolves an `asyncio.Future` from the + * completing thread. Keeping that out of here is not indecision: this half is pure Kotlin, so it + * is identical on all five targets and testable with no interpreter at all (`PendingCallTest` is + * in `commonTest` for that reason), while the delivery half is entirely C API calls. + * + * ### No `kotlinx.coroutines` + * + * `startCoroutine` and [Continuation] are `kotlin-stdlib`, common source set, every target. The + * library has no coroutines dependency and must not grow one for this: the `suspend fun` being + * exposed is the *user's*, and whatever dispatcher it uses is already on their classpath. Starting + * it takes nothing more than the stdlib. + * + * ### No dispatcher, and the fast path that falls out of it + * + * The coroutine's context is this object and nothing else -- no dispatcher, no [Continuation] + * interceptor of any kind (see [cancel] for why the context is not simply empty). So the body runs + * on the calling thread until it actually suspends. So a + * `suspend fun` that never reaches a real suspension point -- which is most of them, most of the + * time -- is **already complete when [start] returns**, and the boundary can hand Python the real + * value with no async convention involved at all. `PendingCallTest` pins that. + * + * The converse is the cost: the body's first slice runs inside the C frame, holding the GIL, so an + * exposed `suspend fun` that does its blocking work *before* its first suspension point blocks the + * interpreter exactly as a non-suspending one would. `suspend` is a signature, not a promise. + * + * ### Threading + * + * Not synchronised, and it does not need to be **provided the rule the rest of the boundary + * already lives under is kept**: every mutation happens on a thread holding the GIL + * (`commonMain/README.md`, and [python.multiplatform.reflection.HandleTable]'s own threading + * note). That is not an extra burden here -- a completion that wants to reach Python has to take + * the GIL to do anything at all, so the scope it needs for that already covers this. What is *not* + * safe is resuming an exposed call's continuation without one and expecting the fields below to be + * visible to the next upcall; the GIL is the barrier. + * + * Nothing here allocates a handle. A `PendingCall` is an ordinary Kotlin object, so + * [python.multiplatform.reflection.HandleTable] already gives it a generational handle and + * [UpcallTrampoline.releaseObject] already gives Python the way to drop it. + */ +class PendingCall private constructor() : CoroutineContext.Element { + + /** + * Makes this reachable from inside the coroutine as `coroutineContext[PendingCall]`, which is + * what [ensureActive] and [currentPendingCall] read. Modelled on `kotlinx.coroutines`' `Job`, + * whose companion is its own key for the same reason. + */ + override val key: CoroutineContext.Key get() = PendingCall + + /** True once the coroutine has finished, whether with a value or with a [failure]. */ + var isDone: Boolean = false + private set + + /** The result, or `null` -- which is also a legitimate result. Read [isDone] first. */ + var value: Any? = null + private set + + /** What the coroutine threw, or `null`. Never rethrown from here; see [start]. */ + var failure: Throwable? = null + private set + + /** + * What the [onCompleted] listener threw, if it threw. + * + * Kept rather than propagated for the same reason [failure] is: the listener runs on the + * completing thread, which on the delivery path has C above it, and a Kotlin exception + * crossing that frame terminates the process. A convention that cares can read this. + */ + var deliveryFailure: Throwable? = null + private set + + /** + * True once someone on the Python side has stopped waiting for this -- see [cancel]. + * + * Independent of [isDone]: a cancelled call is normally still running, and a call that + * completes after being cancelled is both done and cancelled. + */ + var isCancelled: Boolean = false + private set + + private var listener: ((PendingCall) -> Unit)? = null + + /** + * Records that whoever was waiting for this call has stopped, and returns whether that was news. + * + * ### What this is not + * + * **It does not stop the coroutine.** It cannot, and the reason is structural rather than a + * missing feature. [start] hands its own [Continuation] to `startCoroutine` as the coroutine's + * *completion*, which is resumed exactly once, when the whole body is finished. The + * continuation that represents the body's current suspension point is a different object + * entirely, and it belongs to whoever created the suspension -- the user's + * `suspendCoroutine`, or their dispatcher, or `kotlinx.coroutines` machinery this library has + * no dependency on. Nothing here ever sees it, and the stdlib offers no way to reach it. + * + * Resuming a continuation twice is undefined, so there is no back door either. Forcible + * cancellation is what a `Job` tree is *for*, and building one is `kotlinx.coroutines`' + * job; `docs/design/upcall.md` §5.4 states the no-dependency rule this obeys. + * + * ### What it is + * + * A cancellation flag published where a *cooperating* body can read it. Because the coroutine + * runs with this object as its [CoroutineContext], the body reaches it as + * `coroutineContext[PendingCall]`, and [ensureActive] is the one-line form: + * + * suspend fun slowSum(n: Long): Long { + * var total = 0L + * for (i in 0 until n) { ensureActive(); total += step(i) } + * return total + * } + * + * That is the same bargain `kotlinx.coroutines` makes -- cancellation is cooperative there too, + * and a body that never suspends and never checks is uncancellable there as well. The + * difference is only that `kotlinx.coroutines`' own suspending functions do the checking for + * you, and there are none here. + * + * A body that does not cooperate keeps running to completion, and its outcome is simply + * dropped: [AsyncUpcall] refuses to deliver a result to a `Future` that is already settled. + * That is the guarantee this half is actually responsible for. + * + * ### When Kotlin hears about it + * + * At the moment Python calls `Future.cancel()`, near enough: the `Future` the slow path hands + * out carries a handle on this object, and its `add_done_callback` routes back through + * [UpcallTrampoline.cancelCall] on the loop's next turn (`AsyncUpcall.armCancellationNotice`). + * Before that existed, this flag was only ever set by the delivery path -- i.e. when the + * coroutine had already finished, which is exactly when the news stops being worth anything. + * + * @return `true` if this call was live and is now cancelled; `false` if it had already + * completed (there was nothing left to stop) or was already cancelled. + */ + fun cancel(): Boolean { + if (isDone || isCancelled) return false + isCancelled = true + return true + } + + /** + * Registers the one thing that gets told when this completes. + * + * Fires inline if the call is already done, which is the common case for a body that never + * suspended -- so a caller does not have to branch on [isDone] before attaching. + * + * @throws IllegalStateException if a listener is already waiting. A pending call belongs to + * exactly one Python-side waiter (one `Future`, one callback); silently replacing the first + * would strand it forever. + */ + fun onCompleted(action: (PendingCall) -> Unit) { + if (isDone) { + deliver(action) + return + } + check(listener == null) { "this PendingCall already has a completion listener" } + listener = action + } + + private fun complete(result: Result) { + // A Continuation cannot legitimately be resumed twice, but a second completion must not + // overwrite the first one's outcome if a generator ever gets it wrong. + if (isDone) return + result.fold({ value = it }, { failure = it }) + isDone = true + val waiting = listener + listener = null + if (waiting != null) deliver(waiting) + } + + private fun deliver(action: (PendingCall) -> Unit) { + try { + action(this) + } catch (t: Throwable) { + deliveryFailure = t + } + } + + override fun toString(): String = when { + !isDone -> if (isCancelled) "PendingCall(cancelled, still running)" else "PendingCall(pending)" + failure != null -> "PendingCall(failed: ${failure?.message})" + else -> "PendingCall(done: $value)" + } + + /** The key `coroutineContext[PendingCall]` resolves through, and the home of [start]. */ + companion object : CoroutineContext.Key { + + /** + * Starts [block] and returns immediately, complete or not. + * + * Nothing is thrown out of here. [block]'s own failure is parked in [failure] the same way + * its value would be parked in [value], because the caller is a C frame with no unwinding + * above it -- the same rule [UpcallTrampoline] states for itself. + * + * The coroutine's context is the [PendingCall] itself rather than `EmptyCoroutineContext`, + * which is what makes [ensureActive] reachable from inside [block]. It adds no dispatcher + * and intercepts nothing, so the fast path this class exists for is untouched: an element + * that is not a `ContinuationInterceptor` cannot change where the body runs. + */ + fun start(block: suspend () -> Any?): PendingCall { + val call = PendingCall() + val completion = Continuation(call) { result -> call.complete(result) } + try { + block.startCoroutine(completion) + } catch (t: Throwable) { + // startCoroutine routes the body's exceptions to `completion`, so reaching here + // means the machinery itself failed. `complete` is a no-op if it already ran. + call.complete(Result.failure(t)) + } + return call + } + } +} + +/** + * The [PendingCall] the calling coroutine was started by, or `null` if it was not started by one. + * + * `null` is the ordinary answer for a `suspend fun` called from anywhere other than the boundary -- + * a unit test, or one exposed function calling another directly. Exposed code that wants to be + * cancellable has to tolerate that, which is why [ensureActive] treats it as "not cancelled" + * rather than as an error. + */ +suspend fun currentPendingCall(): PendingCall? = coroutineContext[PendingCall] + +/** + * Throws [CancellationException] if the Python side has stopped waiting for this call. + * + * The cooperation point [PendingCall.cancel] describes: an exposed `suspend fun` calls this + * wherever it could usefully stop, and a call whose `Future` was cancelled unwinds instead of + * running to the end for a result nobody will read. + * + * The throw is recorded in [PendingCall.failure] like any other failure, and goes no further -- + * [AsyncUpcall] does not deliver it, because the `Future` it would be delivered to is the settled + * one that caused the cancellation in the first place. + */ +suspend fun ensureActive() { + val call = coroutineContext[PendingCall] ?: return + if (call.isCancelled) throw CancellationException("the Python side cancelled this call") +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt new file mode 100644 index 000000000..b17fd4184 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt @@ -0,0 +1,1338 @@ +package python.multiplatform.ffi.upcall + +import python.multiplatform.ffi.ProxyTypeFactory +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.reflection.CallableKind +import python.multiplatform.reflection.ClassLookup +import python.multiplatform.reflection.ExposedCallable +import python.multiplatform.reflection.ReflectedClass +import python.multiplatform.reflection.ReflectedClassKind +import python.multiplatform.reflection.TypeTag +import python.multiplatform.reflection.UpcallTable + +/** + * The Python half of `docs/design/upcall.md` §5.5's second gap: the `async def` proxy that + * makes `await kotlin_fn(x)` read the same whether the Kotlin body suspended or not. + * + * Until this existed, `AsyncUpcallDeliveryTest` wrote that proxy by hand (`_await_kotlin`) and the + * design doc said so. The shape is not optional and not a convenience: `AsyncUpcall.deliver` returns + * **two different Python types for one Kotlin declaration** -- the real value when the coroutine had + * already finished, an `asyncio.Future` when it had not -- because the fast path must not be made to + * pay for an event loop it does not need. Something has to reconcile that, and an `async def` that + * awaits its result only when the result is awaitable is the only place it can be done without + * giving the fast path a `Future` too. + * + * ### Where this is generated, and why not at build time + * + * The two candidates were a `.py` file emitted by KSP and shipped as a resource, and Python source + * rendered at run time and `exec`'d. This is the second, for three reasons: + * + * 1. **There is no resource path to put a `.py` on that works everywhere.** Kotlin/Native has no + * `getResourceAsStream`, and the interpreter's `sys.path` on iOS, androidNative and wasm points + * at the per-platform CPython trees under `src/nativeInterop/cinterop/lib/...`. `Python3.exec` + * already works on all five. + * + * This reason used to end "...is a per-platform packaging problem this repository has not solved + * for anything", and that half is no longer true. + * [python.multiplatform.env.PythonPayload] puts a directory on `sys.path` before the first + * import, and desktop and Android have a real packaging step feeding it -- a jar resource that + * is extracted, an APK asset that is unpacked. What still holds is the part this reason rests + * on: there is no such step on **iOS** (it needs an Xcode build phase nobody has written), + * androidNative or wasm, so a file shipped that way would exist on two platforms out of five. + * Reason 2 below would hold even if all five had one. + * 2. **KSP does not know what will be installed.** A fragment is per Kotlin module; the table is + * assembled at run time by `UpcallTable.install`, and an application may install a subset (every + * test in this repository does). Build-time Python would describe a table that may never exist, + * and would have to be re-aggregated across klibs at run time anyway -- which is this, with less + * information. + * 3. **`ExposedCallable` already carries everything.** [ExposedCallable.isSuspend], + * [ExposedCallable.arity] and [ExposedCallable.kind] are exactly the inputs, and they are on the + * entry KSP already emits. Emitting Python from KSP as well would duplicate that knowledge into a + * second generator that can disagree with the first. + * + * What is kept from the build-time option is the part that actually mattered: [render] is a **pure + * function from entries to source text**, so it is pinned by `PythonProxySourceTest` in `commonTest` + * with no interpreter anywhere, and the generated module is a readable artefact rather than a + * behaviour. [install] is the only part that needs CPython, and it does nothing but `exec` what + * [render] returned. + * + * ### GraalVM native image + * + * `AGENTS.md` requires upcalls to survive a closed-world native image, which is why §7 chose + * build-time tables over run-time reflection. Nothing here reintroduces that risk: this generates a + * *Python* string from data that is already in a statically-emitted Kotlin table. There is no + * reflection, no class loading and no dynamic Kotlin code -- the closed world stays closed. + * (Unverified here: no native image was built for this change.) + * + * ### Instance surfaces: [CallableKind.METHOD], [CallableKind.GETTER], [CallableKind.SETTER] + * + * These need a receiver, which a bare module function has nowhere to put. [render] takes the + * matching [ReflectedClass] descriptors and, for each one, emits a plain Python `class` whose + * `__init__` (if a `CONSTRUCTOR` entry exists) calls through to Kotlin and stashes the resulting + * handle in `self._pm_handle`, and whose methods and properties pass that handle as `args[0]` -- + * exactly the shape `pm_invoke` already expects for a receiver-carrying entry. + * + * #### Who gives the handle back + * + * A [python.multiplatform.reflection.HandleTable] entry is a **strong Kotlin reference that the + * Python side owns**, and that table's own class doc names the counterpart precisely: *"the proxy's + * `tp_dealloc` calling `release` is the entire lifetime contract."* Until [renderClass] emitted a + * `__del__`, nothing kept it. `GeneratedProxyCostTest` measured the consequence without looking for + * it -- its constructor row builds ten thousand `Counter(10)`s and discards every one, and the run + * ended with **78 002 live handles**, i.e. every Kotlin object Python had finished with was still + * rooted. `ProxyHandleLifetimeTest` is the direct statement of it, and it fails on desktop *and* on + * the iOS simulator identically (64 registered, 64 still live after `gc.collect()`): this was never + * a per-platform problem, because the generated Python is `commonMain`'s. + * + * `__del__` is `tp_finalize`, which `tp_dealloc` runs -- so it is literally the mechanism that doc + * names, not a stand-in for it. The objection to it is historical: before PEP 442 (CPython 3.4) an + * object with `__del__` in a reference cycle was never finalised at all and went to `gc.garbage`. + * That has not been true for a decade, and [ProxyHandleLifetimeTest] pins it rather than trusting + * it -- it builds a cycle through the proxy on purpose, drops it, collects, and requires the + * handles back. + * + * The alternatives are all *more* robust in one specific way and cost between two and nine times + * as much, measured on CPython 3.13 as construct-plus-destruct, net of a plain object of the same + * shape: + * + * | alternative | net | why not | + * |---|---|---| + * | `__del__` (**this**) | **+66 ns** | -- | + * | a per-instance holder object whose own `__del__` releases | +158 ns | immune to a subclass shadowing `__del__`, at the price of a second allocation per proxy and a second name in the instance dict | + * | `weakref.ref(self, cb)` in a module-level registry | +260 ns | the same immunity, plus a registry entry to insert and remove per proxy | + * | `weakref.finalize(self, _pm_release, h)` | +555 ns | the same again, and it costs about as much as the entire boundary crossing it is protecting -- the constructor row is ~610 ns net, so this alone would roughly double it | + * + * The one thing `__del__` does not survive is a **subclass that defines its own `__del__` and does + * not call `super().__del__()`** -- measured, not assumed: the handle is silently never released. + * That is accepted because a subclass that overrides `__init__` without calling `super().__init__()` + * already breaks the same object more thoroughly (there is no `_pm_handle` at all), so the generated + * class already depends on a subclass cooperating with it, and paying 2.4x on every construction to + * harden one half of that dependency buys little. + * + * The release is looked up **once, at class-definition time**, and carried as a default argument; + * see `_pm_releaser` for why reading it out of globals inside `__del__` is not the same thing. + * + * This deliberately does **not** go through [python.multiplatform.ffi.ProxyTypeFactory]'s + * `PyType_FromSpec` type. That type exists for one thing: giving a Kotlin-held Python reference a + * `tp_traverse`/`tp_clear`/`tp_dealloc` slot so CPython's cyclic collector can see through it + * (`CycleCollectionTest`). It carries no `tp_call`, `tp_methods` or `tp_getset` -- nothing that + * would let Python *call into* Kotlin -- and nothing in this codebase wires a `TypeTag.OBJECT` + * return into an instance of it; [UpcallTrampoline.marshalResult] hands back a bare + * [python.multiplatform.reflection.HandleTable] integer today. A receiver in this design is + * therefore just that integer, and a plain Python class holding one in an attribute is sufficient + * -- no C type, no new slot, no new platform code. + * + * ### Static surfaces: [CallableKind.STATIC_GETTER], [CallableKind.STATIC_SETTER], and a + * companion's [CallableKind.FUNCTION] + * + * A static property is an **attribute**, not a callable: rendering it as `libraryVersion()` would + * make the Python surface disagree with the Kotlin declaration, and rendering it as a plain + * assignment at install time would freeze a `var` at whatever it held then. So it has to be a + * descriptor, and the only question is what object carries it. There are two answers because there + * are two owners: + * + * - **A static member of a class that gets a Python `class`** (a `companion object` member, which + * `FragmentScanner` folds into the owner's name) goes on a **metaclass** rendered beside the + * class. A `property` in the class body would answer `instance.x`, not `Foo.x`; a descriptor + * answers for the object whose *type* carries it, so for the class object itself to read and + * write, the descriptor must live on the class's type. This also reproduces Kotlin's own rule + * for free -- a companion member is reached through the class and **not** through an instance, + * and a metaclass attribute is invisible to instances for exactly the same reason. + * - **Everything else static** -- a top-level property, and the members of a `ReflectedClassKind` + * this stage does not render as a class ([ReflectedClassKind.OBJECT], [ReflectedClassKind.ENUM]) + * -- becomes a live attribute of the module its name points at. That is not a second design so + * much as the same one applied to the module: the module object is reclassed onto a `ModuleType` + * subclass generated **for that module alone**, and the descriptor goes on it, exactly as a + * class's static goes on that class's metaclass. Per module rather than one shared subclass + * because the descriptor is named after the Kotlin declaration, and a shared type would answer + * `demo.calc.tally` on every other proxy module too. + * + * This was a `__getattr__`/`__setattr__` pair on one shared `_PmModule` subclass first, which is + * correct and is roughly fifty times more expensive per read. `__getattr__` is the *fallback* + * hook: it runs only once normal lookup has failed, and on a module "failed" means + * `module_getattro` has already built and raised the formatted "module has no attribute" + * `AttributeError` for the hook to catch and discard. `GeneratedProxyCostTest` priced that at + * 551-587 ns per read on desktop, net of the boundary call underneath it -- against 6-14 ns for + * the descriptor, and against 8-28 ns for the metaclass path doing the same job for a class. + * A data descriptor is consulted ahead of the instance dict in both directions, so it also + * removes the need for a `__setattr__` hook: a `property` with no usable `fset` refuses the + * assignment on its own. + * + * Putting an `object`'s properties on the module rather than on a class of its own is what keeps + * `Registry.ping()` and `Registry.size` resolving against the same Python object: `ping` is a + * [CallableKind.FUNCTION] entry named `..Registry.ping` and has always gone through the module + * path, so its sibling property has to follow it there. + * + * #### A companion **function** goes on the metaclass too + * + * `FragmentScanner.companionEntries` emits a companion function as a [CallableKind.FUNCTION] named + * `pkg.Owner.fn` -- the same shape an `object`'s function has, and indistinguishable from it by + * name alone. Sent down the module path it landed in a module `pkg.Owner`, which [renderClass] then + * overwrote on the parent module with the class of that name. **Observed, in a real interpreter:** + * `Counter.make(3)` raised `type object 'Counter' has no attribute 'make'` while a + * `sys.modules['proxycls.Counter']` that still held `make` was left behind -- one Kotlin name with + * two contradictory Python answers, only one of which anything pointed at. + * + * The judgement is that **one Python name cannot be both a module and a class**, so one of the two + * has to move, and it is the function: the class is the thing an instance surface needs. That puts + * it on the metaclass beside the static properties, which also gets Kotlin's own rule for free. + * Rejected: + * + * | alternative | why not | + * |---|---| + * | a `staticmethod`/`classmethod` in the **class body** | reachable through an *instance* (`Counter(1).make(3)` resolves), which is a shape Kotlin does not have -- and it would contradict the sibling static *property*, which is on the metaclass precisely so instances cannot see it. The two halves of one companion would then disagree about what a companion is | + * | merging the module into the class (the class absorbs the module's attributes) | the collision is only ever between a class and a module named after that class, i.e. between a class and its own companion. Building a general module/class merge for one case buys nothing the metaclass does not, and gives `pkg.Owner` a `__getattr__` that has to arbitrate between two namespaces | + * | reversing the publication order so the module wins | the same bug pointed the other way: `Counter(1)` would stop working, and an instance surface is what the class rendering exists for. It also leaves the loser in `sys.modules`/on the parent module rather than removing it | + * + * An `object`'s function is deliberately **not** moved: a [ReflectedClassKind.OBJECT] is not + * rendered as a Python class, so nothing overwrites its module and `Registry.ping()` and + * `Registry.size` keep resolving against the same module object. + * + * #### What this cost, and the alternatives it was chosen over + * + * The price is one extra type object per class that has static members, and one more shape in the + * generated source for a reader to account for. It is paid only where there is something to put on + * it -- a class with no statics still renders as a bare `class Foo:`. The alternatives: + * + * | alternative | why not | + * |---|---| + * | accessor pair (`Foo.get_count()` / `Foo.set_count(v)`) | no metaclass, but it is the same disagreement between the Python surface and the Kotlin declaration that ruled out rendering the property as a callable in the first place. Rejecting it for a `val` and accepting it for a companion `val` would be arbitrary | + * | module-level accessors (`fixture.library.WithCompanion_count`) | mangles the name, and `WithCompanion` is already taken as a module by the companion's *functions*, so the two halves of one companion would live in different places | + * | a C type with `tp_getset` via [python.multiplatform.ffi.ProxyTypeFactory] | new platform C on five targets, for something a pure-Python descriptor already does. The same argument the instance section above makes against that type | + * + * The setter half follows `docs/design/binding-policy.md` rather than restating it: a `val`, or a `var` + * whose setter is `private`/`protected`/`internal`, simply has no `STATIC_SETTER` entry, so this + * renders a `property` with no `fset` on a class, and on a module one whose `fset` raises with the + * Kotlin declaration's name in the message. The assignment fails with `AttributeError` instead of + * silently binding a plain attribute that would shadow the Kotlin declaration for every later read. + * + * ### A `TypeTag.OBJECT` **result**, and who owns it + * + * This used to be the whole of the section below: a `TypeTag.OBJECT` value returned from an + * ordinary [CallableKind.FUNCTION] crossed as the bare handle integer, and *"that int is the + * caller's to release"*, because an integer has nothing to hang a `__del__` off. The path that made + * the cost of it visible is `docs/design/kotlin-extensions-in-python.md` §3.2's Compose chain -- + * `Modifier.padding(16.dp).size(24.dp)`, assembled from Python out of Compose's own jars, where + * every link is exactly that shape. §6 records the consequence: **one leaked root per intermediate + * link**, and Compose modifiers are written long, so the leak grows with how idiomatic the calling + * code is. `WalkedArtifactComposeModifierTest` leaked three per run and said so in its own KDoc. + * + * What that needed is a *Python object* to hand back instead, since that is the only thing a + * finaliser can live on. [OWNED_HANDLE] renders it: `_PmObject`, an owner carrying the handle in a + * slot and giving it back in `__del__`. `_pm_own` puts a result into one and `_pm_unwrap` takes it + * back out on the way in, so `size(padding(m, 16.0), 24.0)` still passes a handle to Kotlin and + * chaining costs one `isinstance` per OBJECT argument. Every rendered class derives from the same + * owner, so there is one notion of "a Python object holding a Kotlin root" rather than two. + * + * #### Where a class with a metaclass gets that base from + * + * A class with static members already has a metaclass, and the two requirements do not both fit in + * a base list: Python has no syntax for a base *after* a keyword, so the only spelling available is + * `class Foo(_PmObject, metaclass=_pm_t_1):` -- which separates the class from the metaclass a + * reader is pairing it with, and did more than that. `ksp-fixtures:app` locates a companion by + * matching `class (_pm_t_\d+)\(type\):` lazily through to `class Foo\(metaclass=\1\):`, and once + * that terminator no longer existed the match could not complete: `java.util.regex`'s lazy loop + * recurses once per character, so it scanned the rest of a 30 KB module and raised + * **StackOverflowError** instead of returning no match. Two fixture tests failed with a stack + * overflow that said nothing about rendering. + * + * So the base comes from the metaclass instead -- `__new__ = _pm_owned_new`, one line at the top of + * the metaclass body, appending `_PmObject` to `bases` unless something there already is one. The + * class statement stays adjacent to its metaclass and says only what is specific to it, a class + * with no statics still names its base outright (`class Counter(_PmObject):`), and a Python + * subclass of a rendered class inherits the metaclass without collecting a duplicate base. + * + * **It is gated on the producer having named the return type**, and [ownedTypeOf] is where the + * reason is written out: `OBJECT` means two different things in the result direction and the tag + * alone cannot separate them. The walker and KSP both name their return types, and a name that matches a + * rendered class is wrapped in that class (`_pm_classes`, #94); any other is a generic `_PmObject`. + * + * ### What is still not rendered + * + * | kind | why not | + * |---|---| + * | a `TypeTag.OBJECT` result read through a **module attribute** (a top-level or `object` property) | `_pm_static_property` is one shared descriptor for every module attribute and does not see the entry, so owning there means either a `_pm_own` call on the read path of *every* top-level `val` -- a row `GeneratedProxyCostTest` measures at 6-14 ns, which this would multiply -- or a second copy of the descriptor. Neither is worth building for a case no producer reaches: the walker emits no properties, and a KSP top-level `val` of a class type is not wrapped here | + * + * These are skipped silently *here* because the skip is a property of this stage, not a policy + * decision -- `docs/design/binding-policy.md` already decided they are exposed, and they remain reachable + * through the raw boundary. What is missing is only the sugar. + */ +object PythonProxySource { + + /** The name [AsyncUpcall] resolves out of `__main__` to settle a `Future` safely. */ + internal const val SETTLE_FUNCTION = "_pm_settle" + + /** The name [AsyncUpcall] resolves out of `__main__` to arm a `Future`'s cancellation notice. */ + internal const val WATCH_FUNCTION = "_pm_watch" + + /** The `(long) -> int` entry point `_pm_watch` routes a cancellation to; per-platform. */ + internal const val CANCEL_ENTRY_POINT = "_pm_cancel" + + /** The `(long) -> int` entry point that gives a [python.multiplatform.reflection.HandleTable] + * handle back; already bound by every target's bootstrap, and reused here rather than + * duplicated -- the handle `_pm_watch` carries is an ordinary object handle. */ + internal const val RELEASE_ENTRY_POINT = "_pm_release" + + /** Where names with no package of their own land; `docs/design/upcall.md` §Runtime. */ + const val DEFAULT_ROOT_MODULE: String = "kotlin" + + /** + * The part that does not depend on any table: the guarded settle, the module-tree builder, and + * name resolution. + * + * Installed on its own by [ensureSupportInstalled] because [AsyncUpcall] needs `_pm_settle` + * whether or not anybody ever generated a proxy -- the delivery path exists independently of the + * sugar on top of it. + */ + val support: String by lazy { KotlinSurface.DELIVERY + "\n" + SUPPORT_BODY } + + /** + * [support] after the root module: the guarded settle, the module-tree builder, name + * resolution, and the one name each rendered function is published through. + */ + private val SUPPORT_BODY: String = """ + # GENERATED by python-multiplatform. Do not edit. + import sys as _pm_sys + import types as _pm_types + # Every rendered function is published through this: Kotlin parameter names as keywords, + # Kotlin defaults for what a call leaves out, and the signature as public metadata. See + # `KotlinSurface`. + from python_multiplatform import kotlin_function as _pm_kotlin_function + + + def _pm_settle(_fut, _ok, _payload): + # Why this exists rather than scheduling `fut.set_result` directly: the Future can be + # cancelled at any point, including between the moment Kotlin checks it and the moment + # this callback runs, and `set_result` on a settled Future raises InvalidStateError. + # Raised inside a loop callback that goes to `call_exception_handler`, which logs a + # failure the application never asked for and cannot act on -- the value it describes + # was abandoned on purpose. Measured before this guard existed: + # AsyncUpcallCancellationTest saw exactly one 'InvalidStateError: invalid state'. + if _fut.done(): + return + if _ok: + _fut.set_result(_payload) + else: + _fut.set_exception(_payload) + + + def _pm_watch(_fut, _handle): + # Early cancellation notice, and the only scheduled release of the handle that carries + # it. Both halves are one callback because `add_done_callback` fires on *every* way a + # Future can settle -- cancelled, resolved, or rejected -- which is exactly the set of + # moments at which Kotlin either needs to be told something or no longer needs the + # handle. + # + # Without this, the only point at which Kotlin could observe a cancellation was when its + # coroutine finished and the delivery found the Future already settled: correct, and far + # too late for a body that was still checking `ensureActive()` in a loop. + # + # Scheduled, not immediate: `Future.cancel()` settles the Future synchronously but hands + # its callbacks to `call_soon`, so the notice arrives on the loop's next turn. That is + # still arbitrarily earlier than completion, which is the whole claim. + def _pm_done(_f, _h=_handle): + try: + if _f.cancelled(): + _pm_cancel(_h) + finally: + # Unconditional, and safe to be beaten to it: a handle table release is + # generational, so a second one -- from Kotlin's own completion path -- is a + # no-op rather than a slot handed to somebody else. + _pm_release(_h) + _fut.add_done_callback(_pm_done) + + + def _pm_module(_name): + # Exposed names are Kotlin fully-qualified names, so `fixture.library.greet` should be + # reachable as `from fixture.library import greet`. Injecting into sys.modules is what + # makes that work with no import hook: CPython's import machinery returns a sys.modules + # hit for the full dotted name before it looks at any finder. + _m = _pm_sys.modules.get(_name) + if _m is None: + _m = _pm_types.ModuleType(_name) + _pm_sys.modules[_name] = _m + if '.' in _name: + _parent, _, _leaf = _name.rpartition('.') + setattr(_pm_module(_parent), _leaf, _m) + return _m + + + def _pm_lookup(_name): + # NOT `_pm_bind`, which is what this used to be called. Three of the five bootstraps + # publish a `_pm_bind` of their own meaning handle -> callable, and defining this over + # the top of it destroyed the host's on the way past -- silently, because desktop is + # the only target that had ever run this file and desktop publishes no `_pm_bind`. + # + # `bytes`, not `str`: desktop reaches its resolver through + # `ctypes.CFUNCTYPE(c_long, c_char_p)` and `c_char_p` refuses a `str` outright, so this + # is the only spelling that can work everywhere. Every other bootstrap accepts both. + _h = _pm_resolve(_name.encode('utf-8')) + if _h == -1: + raise AttributeError('no exposed Kotlin declaration named ' + _name) + return _h + + + def _pm_no_release(_handle): + # What a host that bound no `_pm_release` gets: the behaviour that existed before a + # proxy released anything at all -- a leak -- rather than a NameError raised inside + # `__del__`, which CPython prints to stderr as "Exception ignored" and which nothing + # can act on. Same policy as `_pm_watch`'s: a missing per-platform binding costs the + # feature it enables and nothing else. + return 0 + + + def _pm_releaser(): + # The host's `(long) -> int` release, resolved **once**, at class-definition time, and + # carried into `__del__` as a default argument. Two reasons, and it is the second that + # forces it: + # + # 1. A default argument is a LOAD_FAST, not a LOAD_GLOBAL, on a path that runs once per + # proxy that dies. + # 2. A module's globals are set to None during interpreter finalisation, and `__del__` + # still runs after that for everything still alive. A `_pm_release` read out of + # globals at that moment is None; the call raises TypeError, and CPython prints + # "Exception ignored in: " once per surviving proxy, to stderr, + # where nobody asked for it. A captured reference cannot be unbound underneath it. + return globals().get('_pm_release', _pm_no_release) + + + def _pm_module_type(_mod): + # The type a module's Kotlin-backed attributes hang off, created once per module. + # + # A Kotlin top-level `val`/`var` is a module *attribute* in Python, and it has to stay + # live in both directions: a read has to call Kotlin's getter (a plain assignment at + # install time would freeze a `var` at whatever it held then), and an assignment has to + # reach Kotlin's setter rather than rebinding the name in the module dict. Attribute + # hooks are looked up on the type and never on the instance, so whatever answers has to + # live on a type and the module object has to be reclassed onto it. Assigning + # `__class__` is the documented way to do that to a module and is what PEP 562's own + # rationale describes. + # + # Per module, not one shared subclass, because what gets put on it below is a + # descriptor *named after the Kotlin declaration* -- on a shared type, `demo.calc.tally` + # would answer on every other proxy module too. This is the same price the metaclass + # already pays for a class's static members (one extra type object, and only where + # there is something to put on it), and it buys the same thing. + _t = type(_mod) + if getattr(_t, '_pm_owned', False): + return _t + _t = type( + '_PmModule_' + _mod.__name__.replace('.', '_'), + (_pm_types.ModuleType,), + {'_pm_owned': True}, + ) + _mod.__class__ = _t + return _t + + + def _pm_kotlin_rows(_mod, _name, _row): + # The table row of the static property `_pm_static_property` just put on [_mod]'s type, + # kept beside it on that type (`_pm_kotlin_rows`, name -> row) so that + # `python_multiplatform.describe(module, name)` can describe a constant without reading + # it (issue #36). Beside the descriptor rather than on it: the read path stays a plain + # `property`, which is what `GeneratedProxyCostTest` prices. + _t = _pm_module_type(_mod) + _rows = _t.__dict__.get('_pm_kotlin_rows') + if _rows is None: + _rows = {} + _t._pm_kotlin_rows = _rows + _rows[_name] = _row + + + def _pm_static_property(_mod, _name, _get, _set): + # A `property` -- a *data* descriptor -- rather than a `__getattr__`/`__setattr__` pair + # on a shared subclass, which is what this used to be. Both are correct; they are not + # close on cost, because of where CPython looks first. + # + # `__getattr__` is the *fallback* hook: it is consulted only after normal attribute + # lookup has already failed, and on a module "already failed" means `module_getattro` + # has run to completion and raised -- building the fully formatted "module 'x' has no + # attribute 'y'" AttributeError, `__spec__` inspection and all -- for the hook to catch + # and discard. Every single read pays for one raised-and-thrown-away exception. A data + # descriptor is consulted *first* instead, ahead of the instance dict, in both + # directions, so nothing is ever raised and no hook is ever entered. + # + # Measured, on this repository's `GeneratedProxyCostTest` (desktop, net of the raw + # boundary the read wraps): 551-587 ns per read through the `__getattr__` hook against + # 6-14 ns through the descriptor. PEP 562's module-dict `__getattr__` was measured too + # and is a middle answer, not this one -- it skips the exception but still runs the + # generic-lookup miss and a Python-level dispatch (214 ns against 569 ns and 53 ns in a + # standalone probe of the three shapes). + # + # Everything that is *not* a Kotlin declaration -- the rendered functions and classes, + # and anything the application assigns later -- is untouched by this: it has no + # descriptor of its own, so it goes through the ordinary instance-dict path exactly as + # it did on a plain module. + _t = _pm_module_type(_mod) + + # One owner per name, decided at read time: when the binding layer is installed and + # knows this declaration, the read is its (`python_multiplatform.kotlin_function` makes + # the same choice for a function). A descriptor on the module's type is consulted before + # the module dict and before the binding layer's `__getattr__`, so without this a handle + # rendered by an earlier install would answer instead -- stale once the table changed. + def _pm_fget(_self, _h=_get, _k=_mod.__name__ + '.' + _name, _ms=_pm_sys.modules, + _root=_pm_sys.modules['python_multiplatform']): + _b = _ms.get('python_multiplatform.binding') + if _b is not None and _b._registered_epoch >= _root._proxy_epoch: + _r = _b._read_named(_k) + if _r is not _b._NOT_BOUND: + return _r + return _pm_invoke(_h, ()) + + if _set is None: + # `docs/design/binding-policy.md`: a `val`, or a `var` whose setter is not public API, has + # no setter entry. The assignment has to fail -- letting it through would bind a + # plain module attribute that shadows the Kotlin declaration for every later read, + # silently and only in Python. A `property` with no `fset` already raises + # AttributeError, but with a message about a Python property; this says which + # Kotlin declaration it is, which is the whole of what the reader needs. + def _pm_no_setter(_self, _v, _n=_name): + raise AttributeError( + 'the Kotlin declaration behind ' + _self.__name__ + '.' + _n + + ' has no exposed setter' + ) + + setattr(_t, _name, property(_pm_fget, _pm_no_setter)) + return + + def _pm_fset(_self, _v, _h=_set): + _pm_invoke(_h, (_v,)) + + setattr(_t, _name, property(_pm_fget, _pm_fset)) + """.trimIndent() + + /** + * Renders the proxy module for [entries] and [classes]. + * + * @param entries what the table holds; [UpcallTable.entries] is the usual source. + * @param classes the class descriptors whose [CallableKind.METHOD]/[CallableKind.GETTER]/ + * [CallableKind.SETTER]/[CallableKind.CONSTRUCTOR] entries in [entries] should get a Python + * class rather than being left unreachable; [ClassLookup.all] is the usual source. Only + * [ReflectedClassKind.CLASS] and [ReflectedClassKind.INTERFACE] are rendered -- an `object`'s + * members are already [CallableKind.FUNCTION]/[CallableKind.STATIC_GETTER] under the + * object's name (`FragmentScanner`'s `staticFunctionEntry`), so they go through the ordinary + * function and module-attribute paths with no receiver, and an `ENUM`'s instances come from + * [CallableKind.STATIC_GETTER] entries that land as module attributes for the same reason. + * Passing a class here also *claims* its [ReflectedClass.memberNames]: a static member of a + * rendered class goes on that class's metaclass instead of onto a module. + * @param rootModule where a name with no dot in it goes. Kotlin's default package produces + * such names, and they have nowhere else to live. + * @return Python source. Deterministic, and safe to `exec` more than once -- every statement is + * an assignment, a `def`, or a `class`. + */ + fun render( + entries: List, + classes: List = emptyList(), + rootModule: String = DEFAULT_ROOT_MODULE, + ): String { + val byName = entries.associateBy { it.name } + val renderableClasses = classes.filter { + it.kind == ReflectedClassKind.CLASS || it.kind == ReflectedClassKind.INTERFACE + } + // A static member of a class that gets a Python `class` of its own belongs on that class's + // metaclass, not on a module named after the class -- the two would collide, since + // `renderClass` publishes the class under exactly that name. Everything else static (a + // top-level property or function, and an `object`'s or an `enum`'s, neither of which is + // rendered as a Python class) is a module attribute, which is where its siblings went. + val claimedByClasses = renderableClasses.flatMapTo(HashSet()) { it.memberNames } + // A companion function is a FUNCTION named `pkg.Owner.fn`, so the module path below would + // publish it into a *module* `pkg.Owner` -- which `renderClass` then overwrites with the + // class of that name. Observed, not inferred: `Counter.make` raised + // "type object 'Counter' has no attribute 'make'" while a half-populated + // `sys.modules['proxycls.Counter']` held it, an answer nothing pointed at any more. + val functions = entries.filter { + it.kind == CallableKind.FUNCTION && it.name !in claimedByClasses + } + val moduleStatics = entries.filter { + it.kind == CallableKind.STATIC_GETTER && it.name !in claimedByClasses + } + + return buildString { + appendLine(support) + appendLine() + if (functions.isEmpty() && renderableClasses.isEmpty() && moduleStatics.isEmpty()) { + // Not an error: a table can legitimately hold nothing this stage can render (only + // instance members with no class descriptor, say). Saying so in the generated + // source beats emitting an empty file that reads like a generator failure. + appendLine("# no CallableKind.FUNCTION entries or proxy classes to render") + return@buildString + } + appendLine(ENTRY_POINT_GUARD) + appendLine() + appendLine(OWNED_HANDLE) + appendLine() + var index = 0 + functions.forEach { entry -> + appendLine(renderOne(index, entry, rootModule)) + index++ + } + moduleStatics.forEach { getter -> + val (source, nextIndex) = renderModuleStatic(index, getter, byName, rootModule) + appendLine(source) + index = nextIndex + } + renderableClasses.forEach { cls -> + val (source, nextIndex) = renderClass(index, cls, byName, rootModule) + appendLine(source) + index = nextIndex + } + appendLine(ADAPT_TRAILER) + } + } + + /** + * Hands every module this render created to the binding layer, if it is installed. + * + * `_pm_module` builds a module straight into `sys.modules`, which skips the binding layer's + * finder -- so a Kotlin package first created *here*, after the binding layer was installed, + * would never get the lazy half (overload sets under their base name, receiver types). The + * binding layer adapts whatever already exists when *it* is installed; this is the same call + * from the other side, so the two meet whichever order they arrive in. + */ + private val ADAPT_TRAILER = """ + _pm_binding = _pm_sys.modules.get('python_multiplatform.binding') + if _pm_binding is not None: + _pm_binding.adapt_existing_modules() + del _pm_binding + """.trimIndent() + + /** + * Renders the proxies for whatever [UpcallTable] and [ClassLookup] currently hold and `exec`s + * them. + * + * ### What the caller owes, exactly + * + * Two names in `__main__`, and nothing else: + * + * | name | shape | + * |---|---| + * | `_pm_resolve` | `(name: bytes) -> handle`, `-1` for a name nothing claims | + * | `_pm_invoke` | `(handle, args: tuple) -> result` | + * + * `bytes` rather than `str` because desktop reaches its resolver through + * `ctypes.CFUNCTYPE(c_long, c_char_p)` and `c_char_p` refuses a `str` outright, so it is the + * only spelling that can be written once and work everywhere; the `PyMethodDef` bootstraps + * accept either. How the two names get there is per-platform and is the one part of this path + * that is not `commonMain` (desktop through `ctypes` over a Panama upcall stub; iOS and + * androidNative through `UpcallEntry.publish`'s `PyMethodDef`s; Android/ART through the same + * with C shims behind `ml_meth`; wasmJs through five `PyCFunction`s over one `@WasmExport`, + * told apart by an op code in `self`). The generated source checks for them and raises rather + * than defining proxies that would fail one by one at call time. + * + * Everything the generated module defines for its own use is prefixed `_pm_` too, which used + * to include a `_pm_bind` that meant *name -> handle* -- landing straight on top of the + * host's `_pm_bind`, which means *handle -> callable*. It is `_pm_lookup` now; nothing this + * renders writes to a name a bootstrap owns. + * + * @return the source that was executed, so a caller can log or inspect exactly what ran. + */ + fun install(rootModule: String = DEFAULT_ROOT_MODULE): String { + // Before the source that reads `_pm_proxy_base` out of `globals()` -- see [OWNED_HANDLE]. + // A no-op false on every platform that has not wired a handle slot yet; see + // [ProxyTypeFactory.installGcBase]'s own doc for which one that is today. + ProxyTypeFactory.installGcBase() + val source = render(UpcallTable.entries(), ClassLookup.all(), rootModule) + Python3.exec(source) + // Stamped after the render, not inside it: `render` stays a pure function of the entries. + // See `python_multiplatform.kotlin_function` for what the stamp decides. + Python3.exec( + "import ${KotlinSurface.MODULE_NAME} as _pms_root\n" + + "_pms_root._proxy_epoch = ${UpcallTable.currentEpoch}\n" + + "del _pms_root", + ) + return source + } + + /** + * Makes sure `_pm_settle` exists and hands it back. + * + * Looked up rather than cached in a Kotlin field on purpose: a `PyObject` held across an + * interpreter that gets finalized and re-initialised is a dangling pointer, and this + * repository's test fixture finalizes between tests. The lookup is a `sys.modules` hit plus one + * attribute read, on the slow path only -- a call that genuinely suspended is not counting + * attribute lookups, which is the same argument [AsyncUpcall.runningLoop] makes for itself. + */ + internal fun settleFunction(): PyObject { + val main = Python3.import("__main__") + main.getAttrOrNull(SETTLE_FUNCTION)?.let { return it } + Python3.exec(support) + return main.getAttr(SETTLE_FUNCTION) + } + + /** + * Makes sure `_pm_watch` exists and hands it back, or `null` if arming it would not work. + * + * `null` is not a failure. `_pm_watch` calls [CANCEL_ENTRY_POINT] and [RELEASE_ENTRY_POINT], + * which are per-platform bindings a host installs alongside `_pm_resolve`/`_pm_invoke`; a host + * that has not installed them gets the behaviour that existed before early notice was possible + * -- cancellation observed at completion -- rather than a `NameError` raised inside a loop + * callback, where nobody would see it and nobody could act on it. + * + * Both names are checked here rather than inside the Python callback for a second reason: a + * `_pm_watch` that could not be armed must not be paid for. [AsyncUpcall] registers the handle + * only when this returns non-null, so a target without the bindings roots nothing and leaks + * nothing. + * + * Looked up rather than cached for the reason [settleFunction] gives: a `PyObject` held across + * an interpreter that gets finalized and re-initialised is a dangling pointer. + */ + internal fun watchFunctionOrNull(): PyObject? { + val main = Python3.import("__main__") + if (main.getAttrOrNull(CANCEL_ENTRY_POINT) == null) return null + if (main.getAttrOrNull(RELEASE_ENTRY_POINT) == null) return null + main.getAttrOrNull(WATCH_FUNCTION)?.let { return it } + Python3.exec(support) + return main.getAttrOrNull(WATCH_FUNCTION) + } + + // ------------------------------------------------------------------------------- rendering + + private val ENTRY_POINT_GUARD = """ + if '_pm_resolve' not in globals() or '_pm_invoke' not in globals(): + raise RuntimeError( + 'the raw upcall entry points are not bound: define _pm_resolve(name_bytes) -> handle ' + 'and _pm_invoke(handle, args_tuple) -> result before installing proxies' + ) + """.trimIndent() + + /** + * The owner of a [python.multiplatform.reflection.HandleTable] root, and the two functions that + * put a value into one and take it back out. + * + * **Emitted here rather than in [support], and defined at most once.** Here because + * `_PmObject.__del__` resolves the host's release at class-definition time (see `_pm_releaser`) + * and [support] is `exec`'d on its own by [settleFunction] on a path that has no bootstrap + * requirement at all -- a class defined there could capture `_pm_no_release` permanently. After + * [ENTRY_POINT_GUARD] the bootstrap is known to be present, and every target that publishes + * `_pm_invoke` publishes `_pm_release` with it. + * + * At most once because `install()` is safe to run again and `GeneratedProxyCostTest` really does + * run it twenty-five times. A bare `class _PmObject:` would build a **new class** on each pass, + * and every object handed to Python before that point would stop being an instance of the one + * `_pm_unwrap` tests against: it would cross as a `PyObject`, fail the Kotlin cast, and do so + * only for the objects that predate the reinstall. The guard is what makes "safe to exec more + * than once" true of this block as well as of the assignments around it. + */ + private val OWNED_HANDLE = """ + if '_pm_own' not in globals(): + + class _PmObject: + # The Python object that owns a Kotlin object handle. + # + # A `TypeTag.OBJECT` result crosses as a `HandleTable` integer -- Python cannot hold + # a Kotlin reference on any target -- and an integer has nothing to hang a finaliser + # off, so every one of them was the caller's to release by hand and nothing ever + # did. `docs/design/kotlin-extensions-in-python.md` 6 records what that cost on the path + # that made it visible: `Modifier.padding(16.dp).size(24.dp)` leaks one root per + # intermediate link, and Compose modifiers are written long. + # + # This is the smallest thing that can own one: an object whose `__del__` gives it + # back. `__slots__` because there may be one per link of every chain, and because + # an owner with no `__dict__` cannot itself be part of a reference cycle; + # `__weakref__` because a caller that wants to observe one dying should be able to. + __slots__ = ('_pm_handle', '_pm_type', '__weakref__') + + def __init__(self, _pm_h, _pm_t=None): + self._pm_handle = _pm_h + self._pm_type = _pm_t + + def __repr__(self): + return ( + '' + ) + + def __del__(self, _pm_r=_pm_releaser()): + # getattr, not self._pm_handle: __init__ can raise before the assignment (a + # Kotlin constructor that threw), and __del__ runs on the half-built instance + # regardless. Cleared *before* the release, so a second __del__ -- an explicit + # call, or a resurrection -- cannot hand the same handle back twice. That is + # half of the double-release defence; HandleTable's generation tag is the other + # half, and it covers the case where somebody else released it first. + _pm_h = getattr(self, '_pm_handle', None) + if _pm_h is not None: + self._pm_handle = None + _pm_r(_pm_h) + + # Kotlin type name -> the class `renderClass` rendered for it. Filled by each rendered + # class's own registration line, so a reinstall replaces an entry and never duplicates it. + _pm_classes = {} + + def _pm_own(_pm_h, _pm_t=None): + # `None` is how a nullable Kotlin return arrives -- `marshalResult` answers a null + # with Python's `None` whatever the tag says -- and there is nothing to own. + if _pm_h is None: + return None + # The class rendered for the declared type, when there is one: the instance is built + # without `__init__` (which would call the Kotlin constructor) and gets the handle + # exactly where a constructed one keeps it. Ownership is unchanged -- the same + # owner base releases it. A type nothing rendered (a walked class, a library type) + # stays a generic `_PmObject`. + _pm_c = _pm_classes.get(_pm_t) + if _pm_c is None: + return _PmObject(_pm_h, _pm_t) + _pm_o = _pm_c.__new__(_pm_c) + _pm_o._pm_handle = _pm_h + _pm_o._pm_type = _pm_t + return _pm_o + + def _pm_owned_new(_pm_m, _pm_n, _pm_b, _pm_ns, **_pm_kw): + # How a rendered class that has a **metaclass** becomes an owner. A rendered class + # is one either way, but Python has no syntax for a base after a keyword, so the + # only spelling available puts the owner ahead of `metaclass=` in the base list -- + # which separates the class statement from the metaclass a reader, and a consumer's + # regex, is pairing it with. So the base comes from the only thing that holds the + # class before it exists: its own metaclass, one line of which says so. + # + # Guarded rather than unconditional because the metaclass is inherited: a Python + # subclass of a rendered class is built through this too, and its bases already + # carry the owner. Appending a second copy would be a duplicate base and `type` + # refuses those outright. + for _pm_x in _pm_b: + if isinstance(_pm_x, type) and issubclass(_pm_x, _PmObject): + return type.__new__(_pm_m, _pm_n, _pm_b, _pm_ns, **_pm_kw) + return type.__new__(_pm_m, _pm_n, _pm_b + (_PmObject,), _pm_ns, **_pm_kw) + + # `_pm_proxy_base`, if a bootstrap has set it (see + # `python.multiplatform.ffi.ProxyTypeFactory.installGcBase`), is a real + # `Py_TPFLAGS_HAVE_GC` heap type whose `tp_traverse` can reach through a proxy's handle + # into the Kotlin object's own `PyObject`-typed fields -- which `_PmObject` above cannot + # do, being a plain Python class with no such slot. `_PmGcObject` is the base + # [renderClass] uses instead, for exactly the classes [ReflectedClass.hasTraverse] marks + # and only where nothing else about the class needs a metaclass (see that function). + # + # Deliberately NOT `class _PmGcObject(_pm_gc_base, _PmObject):` -- both bases add real + # instance storage of their own (`_PmObject.__slots__` is non-empty, and the C type + # appends its own handle slot), and CPython refuses two solid bases at once with + # "multiple bases have instance lay-out conflict". So a `_PmGcObject` instance is never + # an `_PmObject`, which is why `_pm_unwrap` below names both: testing only the one meant + # a proxy of a class with a `PyObject` field could not be passed back to Kotlin as an + # *owned-result* argument at all, whatever its parameter was declared as. + # + # No `__del__`: the base's own `tp_dealloc` already releases the handle (the same one + # `_PmObject.__del__` calls by hand), and CPython runs it unconditionally for a subclass + # that defines no finalizer of its own. + _pm_gc_base = globals().get('_pm_proxy_base') + if _pm_gc_base is not None: + class _PmGcObject(_pm_gc_base): + __slots__ = ('_pm_type', '__weakref__') + + def __repr__(self): + return ( + '' + ) + else: + # No bootstrap wired this platform's handle slot yet -- today, every platform but + # desktop. Falling back to `_PmObject` is exactly what every rendered class already + # did before this existed, so a class with `hasTraverse` set costs nothing extra here + # and simply keeps not collecting a Kotlin-side cycle, same as before. + _PmGcObject = _PmObject + + def _pm_unwrap(_pm_v, _pm_owners=(_PmObject, _PmGcObject)): + # What an owner is worth on the wire: the handle inside it. Everything else is + # passed through untouched, which is what keeps a bare handle working for every + # caller written against the raw contract, and what lets an ordinary Python object + # reach a `PyObject` parameter. + # + # **Whether a call site reaches this at all is decided when it is rendered**, from + # the parameter's declared Kotlin type, not from the shape of the value: a proxy is + # a legitimate argument to a `PyObject` parameter *and* to a parameter declared as + # the Kotlin class it stands for, and only the declaration says which one this is. + # See `PythonProxySource.argValues`. + # + # Both owners, because the decision is no longer made here. `_PmGcObject` is not an + # `_PmObject` -- CPython refuses two solid bases -- so testing the one name meant + # every proxy of a class with a `PyObject` field went through untouched whatever its + # parameter said, which is the same defect from the other end. + # + # `isinstance` rather than a `getattr(v, '_pm_handle', v)` probe: the miss is the + # ordinary Python object, and a missing attribute is a raised-and-discarded + # AttributeError -- the same shape of cost the module `__getattr__` hook was + # replaced for, at 551-587 ns a time. The owners are a default argument for the same + # reason `__del__` binds its releaser that way: one less global lookup per argument. + if isinstance(_pm_v, _pm_owners): + _pm_h = _pm_v._pm_handle + if not _pm_h: + raise ValueError( + 'this Kotlin object was already released; its handle cannot be sent ' + 'again, because the slot behind it may belong to something else now' + ) + return _pm_h + return _pm_v + + """.trimIndent() + + /** + * A parenthesised Python tuple literal for [elements], with the one-element trailing comma + * that turns `(a0)` (just `a0`) into an actual tuple -- the trampoline calls `PyTuple_Size` on + * whatever it is handed, and a bare non-tuple argument fails that call rather than being + * politely rejected. + */ + private fun tupleOf(elements: List): String = when (elements.size) { + 0 -> "()" + 1 -> "(${elements[0]},)" + else -> "(${elements.joinToString(", ")})" + } + + private fun params(arity: Int): List = (0 until arity).map { "a$it" } + + /** + * [params], with every [TypeTag.OBJECT] slot that is *carrying a handle* unwrapped back to it. + * + * The other half of ownership: wrapping a result is pointless if the wrapper cannot be passed + * back in, and `size(padding(m, 16.0), 24.0)` does exactly that. Only OBJECT slots are + * considered -- no other tag can be carrying an owner, and `_pm_unwrap` on a float would be a + * Python call per argument for nothing. + * + * ### Why the tag alone is not enough + * + * [TypeTag.OBJECT] is two things at once in the argument direction, exactly as [ownedTypeOf] + * describes it in the result direction: [UpcallTrampoline]'s `toKotlinObject` reads an `int` as + * a [python.multiplatform.reflection.HandleTable] handle and reads anything else as the Python + * object itself, on its way to a parameter declared [python.multiplatform.ffi.PyObject]. So + * unwrapping *every* OBJECT argument sends the Kotlin object behind a proxy to a parameter that + * asked for the proxy, and the entry's own `args[i] as PyObject` cast fails on it -- + * `fixture.app.ProxyObjectArgumentTest` observes both halves of that, and `99acd830` filed it. + * + * The value cannot decide this, because a proxy is a legitimate argument to either kind of + * parameter: `_rh.primary = _rh` and `describeHolder(_rh)` pass the same object and want + * opposite treatment. [ExposedCallable.paramTypeNames] is the declaration, and it is the only + * thing that distinguishes them. + * + * An empty [ExposedCallable.paramTypeNames] is "the producer did not say", not "it is a + * PyObject": every hand-written fragment in this repository is that shape, and so is any + * producer not yet taught to fill it. Silence keeps the unwrapping contract that predates the + * field, which is what `OwnedResultLifetimeTest`'s chain counts. + * + * `kotlin.Any` is deliberately **not** treated like `PyObject` here, though [ownedTypeOf] + * refuses to own a result named that. The two questions differ: a *result* named `Any` is a + * runtime value that may or may not be a handle and cannot be told apart, whereas an *argument* + * slot declared `Any` accepts the Kotlin object a proxy stands for, so unwrapping it is well + * defined rather than a guess. + */ + private fun argValues(entry: ExposedCallable): List = + params(entry.arity).mapIndexed { i, name -> + val declared = entry.paramTypeNames.getOrNull(i) + if (entry.paramTypes[i] == TypeTag.OBJECT && declared != PY_OBJECT) "_pm_unwrap($name)" else name + } + + /** The one declared parameter type that means "hand this call the Python object itself". */ + private const val PY_OBJECT = "python.multiplatform.ffi.PyObject" + + /** + * The Kotlin type name a result should be owned as, or `null` if it must reach Python exactly + * as the boundary produced it. + * + * [TypeTag.OBJECT] is **two things at once** in the result direction, the same way it is in the + * argument direction: [UpcallTrampoline]'s `fromKotlinObject` answers with a + * [python.multiplatform.reflection.HandleTable] integer for a Kotlin object and with the Python + * object itself for a [PyObject]. Python cannot tell those apart when the second one happens to + * be an `int`, and owning one of those would make `__del__` release a handle nobody issued -- + * which, if it collided with a live slot, is the silent cross-object corruption the generation + * tag exists to make impossible from the other direction. + * + * So the gate is [ExposedCallable.returnTypeName]: the producer naming the Kotlin type is the + * only signal that says "this really is a handle". The artefact walker supplies it + * (`WalkedArtifactComposeModifierTest` asserts `androidx.compose.ui.Modifier` on `padding__Dp`); + * the KSP processor supplies it too (`FragmentScanner` fills `returnTypeName` for + * functions, methods and properties from the declared return type), so a KSP result is owned + * the same way. `_pm_own` then answers with the class rendered for that name when there is one + * (#94), and with a generic `_PmObject` otherwise. + */ + private fun ownedTypeOf(entry: ExposedCallable): String? = + entry.returnTypeName?.takeIf { entry.returnType == TypeTag.OBJECT && it !in NOT_A_HANDLE } + + /** + * Return types that are named and still are not handles. + * + * A declaration returning [PyObject] hands Python back a Python object, and one returning `Any` + * may be carrying one. Both are legitimately `int`-valued, which is exactly the collision + * [ownedTypeOf] refuses to guess about. + */ + private val NOT_A_HANDLE = setOf( + PY_OBJECT, + "kotlin.Any", + ) + + /** Wraps [expression] in the owner for [entry], or leaves it alone where there is none. */ + private fun owned(entry: ExposedCallable, expression: String): String { + val type = ownedTypeOf(entry) ?: return expression + return "_pm_own($expression, ${type.quoted()})" + } + + private fun renderOne(index: Int, entry: ExposedCallable, rootModule: String): String { + val handle = "_pm_h_$index" + val function = "_pm_f_$index" + val paramList = params(entry.arity).joinToString(", ") + val argsTuple = tupleOf(argValues(entry)) + val dot = entry.name.lastIndexOf('.') + val module = if (dot < 0) rootModule else entry.name.substring(0, dot) + val leaf = if (dot < 0) entry.name else entry.name.substring(dot + 1) + + val body = if (entry.isSuspend) { + // The awaited value is what gets owned, never the Future: `AsyncUpcall.deliver` + // marshals a completion with the same tag a synchronous return uses, so a slow-path + // OBJECT result is a handle too -- but it arrives *inside* the Future, and owning the + // Future would release nothing and leak everything. + val tail = if (ownedTypeOf(entry) == null) { + """ + | if hasattr(_pm_r, '__await__'): + | return await _pm_r + | return _pm_r + """.trimMargin() + } else { + """ + | if hasattr(_pm_r, '__await__'): + | _pm_r = await _pm_r + | return ${owned(entry, "_pm_r")} + """.trimMargin() + } + """ + |async def $function($paramList): + | _pm_r = _pm_invoke($handle, $argsTuple) + | # Two return types for one declaration: the real value when the Kotlin body never + | # reached a suspension point, an asyncio.Future when it did. Only the second costs + | # an await, which is what keeps the fast path free of the event loop. + |$tail + """.trimMargin() + } else { + """ + |def $function($paramList): + | return ${owned(entry, "_pm_invoke($handle, $argsTuple)")} + """.trimMargin() + } + + return """ + |$handle = _pm_lookup(${entry.name.quoted()}) + | + | + |$body + | + | + |$function = _pm_kotlin_function($function, ${KotlinSurface.row(entry)}) + |$function.__name__ = ${leaf.quoted()} + |$function.__qualname__ = ${entry.name.quoted()} + |setattr(_pm_module(${module.quoted()}), ${leaf.quoted()}, $function) + """.trimMargin() + } + + /** + * The setter entry paired with [getter], if the table has one. + * + * `null` is the answer for a Kotlin `val` **and** for a `var` whose setter is not public API + * (`private`/`protected`/`internal set`) -- `FragmentScanner` emits no setter entry for either, + * and this stage must not invent one. What it renders instead is a read-only attribute that + * *raises* on assignment; see `_PmModule.__setattr__` and, for the class case, the + * setter-less `property` on the metaclass. + */ + private fun setterFor( + getter: ExposedCallable, + byName: Map, + kind: CallableKind, + ): ExposedCallable? = byName["${getter.name}="]?.takeIf { it.kind == kind } + + /** + * Renders one static property as a live attribute of the module its name points at. + * + * @return the source, and the next unused index -- one handle for the getter, and one more + * for the setter when there is one. + */ + private fun renderModuleStatic( + startIndex: Int, + getter: ExposedCallable, + byName: Map, + rootModule: String, + ): Pair { + var index = startIndex + val getterHandle = "_pm_h_${index++}" + val dot = getter.name.lastIndexOf('.') + val module = if (dot < 0) rootModule else getter.name.substring(0, dot) + val leaf = if (dot < 0) getter.name else getter.name.substring(dot + 1) + + val source = buildString { + appendLine("$getterHandle = _pm_lookup(${getter.name.quoted()})") + val setter = setterFor(getter, byName, CallableKind.STATIC_SETTER) + val setterHandle = if (setter == null) "None" else "_pm_h_${index++}" + if (setter != null) appendLine("$setterHandle = _pm_lookup(${setter.name.quoted()})") + append( + "_pm_static_property(_pm_module(${module.quoted()}), ${leaf.quoted()}, " + + "$getterHandle, $setterHandle)\n" + + "_pm_kotlin_rows(_pm_module(${module.quoted()}), ${leaf.quoted()}, ${KotlinSurface.row(getter)})", + ) + } + return source to index + } + + /** + * Renders one Python class for [cls], plus the `_pm_lookup` calls its members need. + * + * @param startIndex the first unused `_pm_h_N` / `_pm_f_N` suffix; shared with [render]'s + * function loop so a class's handles never collide with a module function's. + * @return the source, and the next unused index -- the same threading [render] does across + * its function loop, extended across classes too. + */ + private fun renderClass( + startIndex: Int, + cls: ReflectedClass, + byName: Map, + rootModule: String, + ): Pair { + var index = startIndex + fun bindHandle(): String { + val handle = "_pm_h_$index" + index++ + return handle + } + + val binds = StringBuilder() + val body = StringBuilder() + + val ctor = byName["${cls.name}."] + if (ctor != null) { + val handle = bindHandle() + binds.appendLine("$handle = _pm_lookup(${ctor.name.quoted()})") + val paramList = params(ctor.arity).joinToString(", ") + val callParams = if (paramList.isEmpty()) "self" else "self, $paramList" + val argsTuple = tupleOf(argValues(ctor)) + body.appendLine(" def __init__($callParams):") + // A CONSTRUCTOR result is the one OBJECT result that is *never* wrapped: the instance + // being built is the owner, so the handle goes straight into its slot. Wrapping it here + // would put an owner inside an owner and release the same root twice. + body.appendLine(" self._pm_handle = _pm_invoke($handle, $argsTuple)") + body.appendLine() + // The other half of the handle's lifetime is inherited rather than rendered: `_PmObject` + // -- which every rendered class now derives from -- carries the `__del__` that gives the + // root back. `HandleTable`'s own class doc names that method as the whole of the + // contract ("the proxy's tp_dealloc calling release is the entire lifetime contract"), + // and for as long as neither existed, `ProxyHandleLifetimeTest` and + // `GeneratedProxyCostTest` both measured every handle a constructor ever issued still + // rooted at the end of the run. One implementation rather than one per class, because + // the base's also clears the handle before releasing it, which is what makes a second + // release a no-op without consulting the table at all. + } + + // Preserves [ReflectedClass.memberNames] order (declaration order) for methods, but + // collects property names into a set first: a GETTER and its SETTER are two entries with + // one Python name between them, and rendering the getter as soon as its name is seen would + // split `@property`/`@x.setter` across wherever each entry happened to fall in the member + // list instead of keeping them adjacent, which is what a reader expects of one property. + val propertyNames = LinkedHashSet() + val staticNames = LinkedHashSet() + val staticFunctionNames = LinkedHashSet() + for (memberName in cls.memberNames) { + val entry = byName[memberName] ?: continue + when (entry.kind) { + CallableKind.METHOD -> { + val handle = bindHandle() + binds.appendLine("$handle = _pm_lookup(${entry.name.quoted()})") + body.append(renderMethodBody(entry.name.substringAfterLast('.'), handle, entry)) + body.appendLine() + } + CallableKind.GETTER -> propertyNames += entry.name.substringAfterLast('.') + CallableKind.STATIC_GETTER -> staticNames += entry.name.substringAfterLast('.') + // A companion's function. `render` no longer publishes it as a module function -- + // the module it would land in is the one this class overwrites -- so the metaclass + // below is now the only place it exists. + CallableKind.FUNCTION -> staticFunctionNames += entry.name.substringAfterLast('.') + // SETTER and STATIC_SETTER are picked up alongside their getters below; + // CONSTRUCTOR was handled above. + else -> {} + } + } + + for (propName in propertyNames) { + val getter = byName["${cls.name}.$propName"] ?: continue + val getterHandle = bindHandle() + binds.appendLine("$getterHandle = _pm_lookup(${getter.name.quoted()})") + body.appendLine(" @property") + body.appendLine(" def $propName(self):") + body.appendLine(" return ${owned(getter, "_pm_invoke($getterHandle, (self._pm_handle,))")}") + body.appendLine() + + val setter = setterFor(getter, byName, CallableKind.SETTER) + if (setter != null) { + val setterHandle = bindHandle() + val value = argValues(setter).single() + binds.appendLine("$setterHandle = _pm_lookup(${setter.name.quoted()})") + body.appendLine(" @$propName.setter") + body.appendLine(" def $propName(self, a0):") + body.appendLine(" _pm_invoke($setterHandle, (self._pm_handle, $value))") + body.appendLine() + } + } + + // The metaclass body. A `property` in the class body answers `instance.x`; a static member + // has no instance, and Kotlin reaches a companion member through the *class* and never + // through an instance. A descriptor answers for the object whose *type* carries it, so for + // the class object itself to be the reader and writer, the descriptor has to live on the + // class's type -- which is what a metaclass is. See the KDoc's "Static surfaces" section + // for the alternatives this was chosen over. + val metaBody = StringBuilder() + // A companion *function* goes on the same metaclass its companion *properties* do, for the + // same two reasons: one Python name cannot be both a module and a class, and Kotlin reaches + // a companion member through the class and never through an instance. A `staticmethod` in + // the class body would answer the first but not the second -- Python resolves a + // `staticmethod` through an instance quite happily, which is a shape Kotlin does not have. + for (staticFunctionName in staticFunctionNames) { + val entry = byName["${cls.name}.$staticFunctionName"] ?: continue + val handle = bindHandle() + binds.appendLine("$handle = _pm_lookup(${entry.name.quoted()})") + metaBody.append(renderStaticFunctionBody(staticFunctionName, handle, entry)) + metaBody.appendLine() + metaBody.appendLine() + } + for (staticName in staticNames) { + val getter = byName["${cls.name}.$staticName"] ?: continue + val getterHandle = bindHandle() + binds.appendLine("$getterHandle = _pm_lookup(${getter.name.quoted()})") + metaBody.appendLine(" @property") + metaBody.appendLine(" def $staticName(cls):") + // No receiver: a STATIC_GETTER's args are empty and a STATIC_SETTER's args[0] is the + // new value, so `cls` is a Python-side formality and never crosses the boundary. + metaBody.appendLine(" return ${owned(getter, "_pm_invoke($getterHandle, ())")}") + metaBody.appendLine() + + val setter = setterFor(getter, byName, CallableKind.STATIC_SETTER) + if (setter != null) { + val setterHandle = bindHandle() + val value = argValues(setter).single() + binds.appendLine("$setterHandle = _pm_lookup(${setter.name.quoted()})") + metaBody.appendLine(" @$staticName.setter") + metaBody.appendLine(" def $staticName(cls, a0):") + metaBody.appendLine(" _pm_invoke($setterHandle, ($value,))") + metaBody.appendLine() + } + } + // A class with nothing static pays nothing: no extra type object, and no shape a reader of + // the generated source has to account for. + val metaclassName = if (metaBody.isEmpty()) null else "_pm_t_${index++}" + + val dot = cls.name.lastIndexOf('.') + val module = if (dot < 0) rootModule else cls.name.substring(0, dot) + val className = if (dot < 0) cls.name else cls.name.substring(dot + 1) + + val source = buildString { + append(binds) + appendLine() + if (metaclassName != null) { + appendLine("class $metaclassName(type):") + appendLine() + // The owner base, put on from here rather than written into the class statement + // below; `_pm_owned_new`'s own comment has the reason and [renderClass]'s KDoc has + // what it cost. First line of the body so a reader meets it before the descriptors. + appendLine(" __new__ = _pm_owned_new") + appendLine() + append(metaBody) + appendLine() + } + // `_PmObject` is the base for the same reason the constructor already stashed a handle + // and the `__del__` already gave it back: a rendered class *is* an owner. Sharing the + // type is what lets `_pm_unwrap` accept an instance of one as an OBJECT argument, which + // it could not do before -- a `Counter` passed to a function taking one crossed as a + // `PyObject` and failed the Kotlin cast. A class that has a metaclass gets the same base + // from `_pm_owned_new` above instead of naming it here. + // + // `_PmGcObject` instead, when [cls] carries a generated `tp_traverse` + // ([ReflectedClass.hasTraverse]) *and* has no metaclass: those are the classes whose + // Kotlin-side fields can form a cycle CPython's collector needs to see through, and + // `_PmGcObject`'s own doc (in [OWNED_HANDLE]) is why the metaclass case is excluded -- + // `_pm_owned_new` only ever adds `_PmObject`, and the two owners cannot be combined on + // one class. A class with statics but no traversable fields is unaffected either way. + val gcEligible = metaclassName == null && cls.hasTraverse + appendLine( + when { + gcEligible -> "class $className(_PmGcObject):" + metaclassName == null -> "class $className(_PmObject):" + else -> "class $className(metaclass=$metaclassName):" + }, + ) + if (body.isEmpty()) appendLine(" pass") else append(body) + appendLine() + appendLine("$className.__qualname__ = ${cls.name.quoted()}") + appendLine("setattr(_pm_module(${module.quoted()}), ${className.quoted()}, $className)") + // The lookup `_pm_own` uses to hand a result back as this class, keyed by the Kotlin name. + appendLine("_pm_classes[${cls.name.quoted()}] = $className") + } + return source to index + } + + /** The method half of [renderClass]: like [renderOne]'s body, but `self._pm_handle` is + * always the first element of the args tuple. */ + private fun renderMethodBody(name: String, handle: String, entry: ExposedCallable): String { + val paramList = params(entry.arity).joinToString(", ") + val callParams = if (paramList.isEmpty()) "self" else "self, $paramList" + // The receiver is already a handle -- this proxy's own -- so it is passed as it stands. + val argsTuple = tupleOf(listOf("self._pm_handle") + argValues(entry)) + + return if (entry.isSuspend) { + val tail = if (ownedTypeOf(entry) == null) { + """ + | if hasattr(_pm_r, '__await__'): + | return await _pm_r + | return _pm_r + """.trimMargin() + } else { + """ + | if hasattr(_pm_r, '__await__'): + | _pm_r = await _pm_r + | return ${owned(entry, "_pm_r")} + """.trimMargin() + } + """ + | async def $name($callParams): + | _pm_r = _pm_invoke($handle, $argsTuple) + |$tail + """.trimMargin() + } else { + """ + | def $name($callParams): + | return ${owned(entry, "_pm_invoke($handle, $argsTuple)")} + """.trimMargin() + } + } + + /** + * The companion-function half of [renderClass]'s metaclass: like [renderMethodBody], but the + * first parameter is the class object and it is **not** passed across the boundary. + * + * A `CallableKind.FUNCTION` emitted for a companion member takes no receiver -- its arguments + * start at `args[0]`, exactly as a top-level function's do -- so `cls` is a Python-side + * formality, the same one [renderClass]'s static properties already have. + */ + private fun renderStaticFunctionBody(name: String, handle: String, entry: ExposedCallable): String { + val paramList = params(entry.arity).joinToString(", ") + val callParams = if (paramList.isEmpty()) "cls" else "cls, $paramList" + val argsTuple = tupleOf(argValues(entry)) + + return if (entry.isSuspend) { + val tail = if (ownedTypeOf(entry) == null) { + """ + | if hasattr(_pm_r, '__await__'): + | return await _pm_r + | return _pm_r + """.trimMargin() + } else { + """ + | if hasattr(_pm_r, '__await__'): + | _pm_r = await _pm_r + | return ${owned(entry, "_pm_r")} + """.trimMargin() + } + """ + | async def $name($callParams): + | _pm_r = _pm_invoke($handle, $argsTuple) + |$tail + """.trimMargin() + } else { + """ + | def $name($callParams): + | return ${owned(entry, "_pm_invoke($handle, $argsTuple)")} + """.trimMargin() + } + } + + private fun String.quoted(): String = + "'" + replace("\\", "\\\\").replace("'", "\\'") + "'" +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/UpcallBootstrap.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/UpcallBootstrap.kt new file mode 100644 index 000000000..e0570c218 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/UpcallBootstrap.kt @@ -0,0 +1,40 @@ +package python.multiplatform.ffi.upcall + +/** + * Bootstraps the Python environment with the upcall entry points required by [PythonProxySource]. + * + * This is the consumer-facing route ROADMAP §16f names as the gap: every `actual` below is a thin + * wrapper over a per-platform bootstrap (`UpcallStub`'s Panama stubs on desktop, + * `python.native.ffi.UpcallEntry.publish` everywhere else) that already existed in production code + * but that nothing outside this module's own tests could reach. + * + * ### Lifetime and re-entrancy + * + * - **The published names do not outlive the interpreter they were published into.** They are + * entries in `__main__`'s dict, and `Py_Finalize()` tears that dict down along with everything + * else. The underlying addresses -- the Panama upcall stub on desktop, the `staticCFunction` on + * native, the `PyMethodDef`s on Android/ART, the `@WasmExport` table entry on wasmJs -- are process- + * lifetime (desktop: `Arena.global()`; native/Android: `nativeHeap`/heap allocations "never freed"; + * wasmJs: the wasm instance's own indirect function table) and survive a finalize/re-initialize + * cycle intact, but the Python-side bindings to them do not. A caller that finalizes and + * re-initializes the interpreter must call [publishToGlobals] again before installing any proxy; + * there is nothing to double-free or dangle on the Kotlin side either way. + * - **Calling this twice is safe and cheap.** Every `actual`'s install step is + * `PyDict_SetItemString`, an unconditional overwrite rather than a "define once" guard, so a + * second call rebinds each name to the same underlying entry point rather than refusing or + * raising. `UpcallBootstrapTest` (`commonTest`) pins this. + * - **What is not covered by a test:** the finalize/re-initialize interaction itself. + * `Python3.finalize` is `internal`, its only caller is `artMain/JniExport.kt`, and this test suite + * shares one interpreter across every test in the binary (`PythonTestFixture`) -- a test that + * finalized it would take every other test in the suite down too. The claim above follows from + * reading `Python3.finalize` (it tears down `__main__` via `Py_Finalize()`, `initialize()` rebuilds + * a fresh one) and from where each address lives, not from an executed test. + */ +expect object UpcallBootstrap { + /** + * Publishes `_pm_resolve`, `_pm_invoke`, `_pm_release`, and `_pm_cancel` to the current + * Python `__main__` module's globals. + * @return true if successful. + */ + fun publishToGlobals(): Boolean +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/UpcallTrampoline.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/UpcallTrampoline.kt new file mode 100644 index 000000000..797190724 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/UpcallTrampoline.kt @@ -0,0 +1,451 @@ +package python.multiplatform.ffi.upcall + +import python.multiplatform.ffi.PyLong_Check +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.getThreadGILState +import python.multiplatform.ffi.types.basic.PyNone +import python.multiplatform.reflection.CallableHandle +import python.multiplatform.reflection.ExposedCallable +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.ObjectReference +import python.multiplatform.reflection.TypeTag +import python.multiplatform.reflection.UpcallTable +import python.native.ffi.NativePointer +import python.native.ffi.PyBool_FromLong +import python.native.ffi.PyBytes_FromObject +import python.native.ffi.PyDict_GetItemString +import python.native.ffi.PyErr_Clear +import python.native.ffi.PyErr_Occurred +import python.native.ffi.PyErr_SetString +import python.native.ffi.PyEval_GetBuiltins +import python.native.ffi.PyFloat_AsDouble +import python.native.ffi.PyGILState_Ensure +import python.native.ffi.PyGILState_Release +import python.native.ffi.PyFloat_FromDouble +import python.native.ffi.PyLong_AsLongLong +import python.native.ffi.PyLong_FromLongLong +import python.native.ffi.PyObject_GetItem +import python.native.ffi.PyObject_IsTrue +import python.native.ffi.PyObject_Size +import python.native.ffi.PyTuple_GetItem +import python.native.ffi.PyTuple_New +import python.native.ffi.PyTuple_SetItem +import python.native.ffi.PyTuple_Size +import python.native.ffi.PyUnicode_AsUTF8 +import python.native.ffi.PyUnicode_FromString +import python.native.ffi.Py_DecRef +import python.native.ffi.Py_IncRef +import python.native.ffi.toNativePointer +import python.native.ffi.toRawValue + + +/** + * The piece ROADMAP §13 found missing: the thing that turns a Python argument tuple into the + * `Array` an [ExposedCallable] expects, and the Kotlin result back into a `PyObject *`. + * + * `UpcallTable` has carried arity and a per-argument [TypeTag] since it was written; until this + * existed, nothing read them. Both desktop stubs were `(long) -> long`, so the only entries Python + * could reach were zero-argument ones returning a `Long` -- Python could call Kotlin but could not + * pass it anything. + * + * ### One C shape, not one per signature + * + * The entry point is `(callableHandle: long, args: PyObject * ) -> PyObject *`, i.e. the carrier + * shape `(long, long) -> long`, and **that is the only shape argument passing needs**. Arity and + * types travel inside the tuple and inside the table entry, never in the C signature, so the stub + * count does not grow with the number of exposed Kotlin functions. A design that instead + * specialised the stub per signature would need one for every `(arity, tag-vector)` combination -- + * unbounded, and impossible to pre-generate for a closed world. + * + * The shape is deliberately the one CPython's own `PyCFunction` slot uses + * (`PyObject *(PyObject *self, PyObject *args)`): when the generated proxy type lands, `self` + * takes the handle's place and no new stub is needed. `docs/design/upcall.md` §1.3 (formerly "왜 트램폴린이 + * 적어도 되는가") is exactly this argument, and the remaining CPython slot shapes + * (`(long,long,long) -> int` for `initproc`/`setter`/`traverse`, `(long) -> int` for `clear`, + * `(long) -> void` for `destructor`) are already in `Panama`'s vocabulary. + * + * ### Reference conventions + * + * Both directions are stated because CPython's are inconsistent and getting one wrong is silent: + * + * - **Arguments are borrowed.** `PyTuple_GetItem` lends; a Kotlin `PyObject` built over one is + * therefore constructed with `borrowed = true`, so the wrapper takes a reference of its own + * instead of adopting one it was never given. `borrowed = false` here gives back one reference + * per call that nobody ever took, and the resulting free lands somewhere unrelated -- twice in + * this repo's history. + * - **The result is a new reference.** Python takes ownership of whatever comes back, so a + * `PyObject` handed out is incremented first. The constructors used for the primitive tags + * (`PyLong_FromLongLong` and friends) already return new references. + * + * ### Nothing may be thrown out of here + * + * The return path is C. A Kotlin exception crossing a Panama upcall stub terminates the VM, and on + * Kotlin/Native it terminates the process; there is no unwinding to catch it further out. So every + * failure -- a stale handle, a wrong argument count, a `ClassCastException` from the generated + * lambda, anything the exposed function itself raises -- leaves through [NULL] with Python's error + * indicator set, which is the one channel C understands. + * + * ### The GIL, and why the ordinary `withGIL` is not enough here + * + * `withGIL` skips `PyGILState_Ensure` when the calling thread's nesting depth is already non-zero, + * which is right for Kotlin-side code: the depth counter is that thread's own record of scopes it + * opened. It is **wrong for an entry point from C**, because C may have dropped the GIL under a + * scope that is still open. `ctypes.CFUNCTYPE` does exactly that -- it releases the GIL around the + * foreign call, unlike `PYFUNCTYPE` -- so a trampoline reached that way runs with a non-zero depth + * and no thread state at all, and the first C API call segfaults inside `_PyThreadState_GET`. + * Measured: `PyErr_Occurred+0x1c`, on a thread whose depth said the GIL was held. + * + * So this takes its own `PyGILState_Ensure`/`Release` pair unconditionally -- which is what that + * pair is *for* -- and sets the depth to 1 for its duration so nested `withGIL` scopes inside stay + * cheap. The counters are restored on the way out, and the release is LIFO with respect to any + * outer scope. It also makes this callable from plain Kotlin on a thread holding nothing, which is + * how `UpcallTrampolineTest` drives it. + */ +object UpcallTrampoline { + + /** What C reads as a null `PyObject *`, and the only failure signal this boundary has. */ + const val NULL: Long = 0L + + /** + * Calls the entry [callableHandle] resolves to with the arguments in [argsTuple]. + * + * @param callableHandle a raw [CallableHandle]; a stale or unknown one is an error, not a crash. + * @param argsTuple a borrowed `PyObject *` to a tuple, or `0` for "no arguments". For an entry + * whose [python.multiplatform.reflection.CallableKind] has a receiver, item 0 is that + * receiver -- an [ObjectReference] integer -- and the declared parameters follow it. + * @return a **new** reference, or [NULL] with Python's error indicator set. + */ + fun invoke(callableHandle: Long, argsTuple: Long): Long = attached { + try { + val entry = UpcallTable.callable(CallableHandle(callableHandle)) + val result = entry.callable(unmarshalArguments(entry, argsTuple)) + // A suspending entry's body hands back a PendingCall rather than the value, because + // this frame has to return before the coroutine can finish. `AsyncUpcall` either + // unwraps an already-complete one -- the common case, and free -- or builds the + // `asyncio.Future` that will carry the answer. See docs/design/upcall.md §5.2. + if (entry.isSuspend) AsyncUpcall.deliver(entry, result) + else marshalResult(entry.returnType, result) + } catch (t: Throwable) { + raiseInPython(t) + NULL + } + } + + /** + * Attaches this thread for the duration of [block], **without** consulting the nesting depth. + * + * See the class doc: the depth counter records scopes Kotlin opened, and C is free to have + * released the GIL inside one of them. This is the only place in the codebase that must not + * trust it. + */ + private inline fun attached(block: () -> T): T { + val threadState = getThreadGILState() + val outerState = threadState.state + val outerDepth = threadState.depth + val gilState = PyGILState_Ensure() + threadState.state = gilState + threadState.depth = 1 + try { + return block() + } finally { + threadState.depth = outerDepth + threadState.state = outerState + PyGILState_Release(gilState) + } + } + + /** + * Drops the root a [TypeTag.OBJECT] result handed to Python, and reports whether it did + * anything. + * + * The counterpart to [marshalResult]'s [HandleTable.register]: a Kotlin object returned to + * Python is rooted until this is called, which is what a proxy's `tp_dealloc` does. Its shape + * is `(long) -> int`, already in `Panama`'s vocabulary, so exposing it adds no stub. + * + * @return 1 if a live entry was released, 0 for a handle already released or never issued. + * A double release must stay a no-op: the slot may belong to someone else by then. + */ + fun releaseObject(objectHandle: Long): Int = + if (HandleTable.release(ObjectReference(objectHandle))) 1 else 0 + + /** + * Tells the [PendingCall] behind [callHandle] that the Python side has stopped waiting for it. + * + * The Python end of `docs/design/upcall.md` §5.6's missing half. Without this, the only + * moment Kotlin could learn that a `Future` had been cancelled was when the coroutine finished + * and [AsyncUpcall] found the `Future` already settled -- which is the moment the news becomes + * useless. `PythonProxySource`'s `_pm_watch` hangs a `Future.add_done_callback` on the `Future` + * the slow path hands out and routes it here, so a body checking + * [python.multiplatform.ffi.upcall.ensureActive] stops at its next checkpoint instead of running + * to the end for a result nobody will read. + * + * Shape `(long) -> int`, deliberately the same one [releaseObject] already has, so no target + * grows a new stub *shape* for this -- only one more binding of a shape it already builds. + * + * @param callHandle a raw [ObjectReference] issued by [HandleTable] for a [PendingCall]. A + * stale one -- the ordinary case for a callback that fires after the call was reclaimed -- + * resolves to nothing and is a no-op, which is what the table's generation tag is for. + * @return 1 if this call cancelled a live, not-yet-cancelled [PendingCall]; 0 otherwise, + * including for a handle that names something that is not a [PendingCall] at all. + */ + fun cancelCall(callHandle: Long): Int = attached { + try { + val call = HandleTable.resolveRaw(callHandle) as? PendingCall ?: return@attached 0 + if (call.cancel()) 1 else 0 + } catch (t: Throwable) { + // Reached from C on a path whose only job is to deliver news. Nothing above this can + // act on a failure, and unwinding into C would take the process with it. + 0 + } + } + + // --------------------------------------------------------------------------------------- + // Arguments: Python tuple -> Array + // --------------------------------------------------------------------------------------- + + private fun unmarshalArguments(entry: ExposedCallable, argsTuple: Long): Array { + val expected = entry.expectedArgCount + val tuple = if (argsTuple == NULL) null else argsTuple.toNativePointer() + + val given = if (tuple == null) 0L else PyTuple_Size(tuple) + if (given < 0) failFromPython("${entry.name} expects its arguments as a tuple") + if (given != expected.toLong()) { + throw IllegalArgumentException( + "${entry.name} takes $expected argument${if (expected == 1) "" else "s"}, $given given" + ) + } + + val args = arrayOfNulls(expected) + if (expected == 0) return args + + // Item 0 is the receiver when the kind has one; the declared parameters follow, so a + // paramTypes index and an args index differ by exactly that slot. + val offset = if (entry.kind.hasReceiver) 1 else 0 + if (offset == 1) args[0] = toKotlinObject(item(tuple!!, 0)) + for (i in entry.paramTypes.indices) { + args[offset + i] = toKotlin(entry.paramTypes[i], item(tuple!!, (offset + i).toLong())) + } + return args + } + + /** `PyTuple_GetItem` **borrows**; nothing here releases what it returns. */ + private fun item(tuple: NativePointer, index: Long): NativePointer = + PyTuple_GetItem(tuple, index) ?: failFromPython("argument $index is missing") + + private fun toKotlin(tag: TypeTag, value: NativePointer): Any? { + // `None` is how Python spells a null argument, whatever the tag says it should have been. + if (value.toRawValue() == noneAddress) return null + return when (tag) { + TypeTag.INT -> PyLong_AsLongLong(value).also { checkPythonError("expected an int") } + TypeTag.FLOAT -> PyFloat_AsDouble(value).also { checkPythonError("expected a float") } + TypeTag.BOOLEAN -> (PyObject_IsTrue(value) != 0).also { checkPythonError("expected a bool") } + TypeTag.STRING -> PyUnicode_AsUTF8(value) ?: failFromPython("expected a str") + TypeTag.BYTES -> toByteArray(value) + TypeTag.UNIT -> null + TypeTag.OBJECT -> toKotlinObject(value) + } + } + + /** + * The one tag that is two things at once, resolved by what Python actually sent. + * + * A Kotlin object crosses as an [ObjectReference] integer -- Python cannot hold a Kotlin + * reference on any target, which is the whole reason [HandleTable] exists. Anything else is a + * Python object, and reaches a Kotlin parameter declared as [PyObject]. + */ + private fun toKotlinObject(value: NativePointer): Any { + if (PyLong_Check(value) != 0) { + val raw = PyLong_AsLongLong(value) + checkPythonError("expected an object handle") + return HandleTable.resolveRaw(raw) + ?: throw IllegalArgumentException("stale or unknown Kotlin object handle: $raw") + } + // Borrowed, so the wrapper takes its own reference rather than adopting the tuple's. + return PyObject(value, borrowed = true) + } + + /** + * Reads `bytes` an item at a time, which is the price of this ABI subset. + * + * `PyBytes_AsString` is the fast route and cannot be used: it is bound here as a + * NUL-terminated UTF-8 *string* read, so it destroys exactly the payloads `ByteArray` exists + * for. `PyBytes_AsStringAndSize` is not in `EmbedAPI` on any platform. Until it is, this is + * O(n) C API crossings plus an index object per byte -- correct, and the slowest path across + * this boundary by a wide margin. + */ + private fun toByteArray(value: NativePointer): ByteArray { + val size = PyObject_Size(value) + if (size < 0) failFromPython("expected bytes") + val out = ByteArray(size.toInt()) + for (i in out.indices) { + val index = PyLong_FromLongLong(i.toLong()) ?: failFromPython("byte index $i") + val element = PyObject_GetItem(value, index) + Py_DecRef(index) + if (element == null) failFromPython("byte $i") + out[i] = PyLong_AsLongLong(element).toByte() + Py_DecRef(element) + checkPythonError("byte $i") + } + return out + } + + // --------------------------------------------------------------------------------------- + // Result: Kotlin value -> new PyObject reference + // --------------------------------------------------------------------------------------- + + /** Internal so [AsyncUpcall] can marshal a completion with the same rules a return uses -- + * a value delivered late must reach Python as the same type it would have reached it as + * synchronously, and the tag is the only thing that decides that. */ + internal fun marshalResult(tag: TypeTag, value: Any?): Long { + if (tag == TypeTag.UNIT || value == null || value == Unit) return newNone() + return when (tag) { + TypeTag.INT -> newReference(PyLong_FromLongLong(value as Long), "int") + TypeTag.FLOAT -> newReference(PyFloat_FromDouble(value as Double), "float") + TypeTag.BOOLEAN -> newReference(PyBool_FromLong(if (value as Boolean) 1 else 0), "bool") + TypeTag.STRING -> newReference(PyUnicode_FromString(value as String), "str") + TypeTag.BYTES -> newReference(toPythonBytes(value as ByteArray), "bytes") + TypeTag.UNIT -> newNone() + TypeTag.OBJECT -> fromKotlinObject(value) + } + } + + private fun fromKotlinObject(value: Any): Long { + if (value is ScalarResult) return fromKotlinScalar(value.value) + if (value is PyObject) { + // The wrapper keeps the reference it holds, so Python needs one of its own. + Py_IncRef(value.pointer) + return value.pointer.toRawValue() + } + // Rooted until Python gives it back through releaseObject(); see HandleTable's class doc + // on why this table leaks by construction if nothing does. + return newReference(PyLong_FromLongLong(HandleTable.register(value).raw), "object handle") + } + + /** + * A boxed Kotlin scalar as the Python scalar it means (issue #69), reached only through a + * [ScalarResult] -- see there for why a bare boxed value must not take this path. + * + * Every constructor here returns a **new reference**, which is what this function owes Python; + * nothing is wrapped in a Kotlin `PyObject`, so there is no second reference left for a cleaner. + */ + private fun fromKotlinScalar(value: Any): Long = when (value) { + is Boolean -> newReference(PyBool_FromLong(if (value) 1 else 0), "bool") + is Int, is Long, is Short, is Byte -> newReference(PyLong_FromLongLong((value as Number).toLong()), "int") + is Double, is Float -> newReference(PyFloat_FromDouble((value as Number).toDouble()), "float") + is String -> newReference(PyUnicode_FromString(value), "str") + is Char -> newReference(PyUnicode_FromString(value.toString()), "str") + else -> throw IllegalArgumentException( + "a ${value::class.simpleName} is not a Kotlin scalar Python has a value type for", + ) + } + + /** The mirror of [toByteArray], and the same caveat: a tuple of ints, one object per byte. */ + private fun toPythonBytes(bytes: ByteArray): NativePointer? { + val tuple = PyTuple_New(bytes.size.toLong()) ?: return null + for (i in bytes.indices) { + val element = PyLong_FromLongLong(bytes[i].toLong() and 0xFF) + if (element == null) { + Py_DecRef(tuple) + return null + } + PyTuple_SetItem(tuple, i.toLong(), element) // steals; nothing to release here + } + val result = PyBytes_FromObject(tuple) + Py_DecRef(tuple) + return result + } + + private fun newReference(pointer: NativePointer?, what: String): Long = + pointer?.toRawValue() ?: failFromPython("could not build the $what result") + + /** `None` is returned like any other value, so it owes Python a reference too. */ + private fun newNone(): Long { + val none = noneObject + Py_IncRef(none) + return none.toRawValue() + } + + // --------------------------------------------------------------------------------------- + // Errors + // --------------------------------------------------------------------------------------- + + /** + * Turns [t] into a set error indicator. Never rethrows: this runs on the path back to C. + * + * An error Python has already raised (the exposed function called back into Python and it + * failed) is left exactly as it is -- overwriting it would replace the real traceback with a + * Kotlin wrapper's message. + */ + private fun raiseInPython(t: Throwable) { + try { + if (PyErr_Occurred() != null) return + val message = t.message ?: t::class.simpleName ?: "upcall failed" + PyErr_SetString(runtimeErrorType, message) + } catch (_: Throwable) { + // Reporting the failure failed. Returning NULL with no indicator set is still a + // Python-level error ("SystemError: returned NULL without setting an exception"), + // which is strictly better than unwinding into C. + } + } + + /** Raises whatever Python already put on the indicator as a Kotlin exception, or a fallback. */ + private fun failFromPython(what: String): Nothing { + val pending = python.multiplatform.ffi.exceptions.PyException.fromCurrentError() + throw pending ?: IllegalArgumentException(what) + } + + /** + * The `-1`-and-check convention: `PyLong_AsLongLong` and friends signal failure with a value + * that is also a legitimate result, so the indicator is the only reliable answer. + */ + private fun checkPythonError(what: String) { + if (PyErr_Occurred() == null) return + val pending = python.multiplatform.ffi.exceptions.PyException.fromCurrentError() + PyErr_Clear() + throw pending ?: IllegalArgumentException(what) + } + + // --------------------------------------------------------------------------------------- + // Cached singletons. Data symbols, so they are reached the way PyNone/PyTypeChecks reach + // theirs -- through builtins -- rather than by adding per-platform raw-symbol plumbing. + // --------------------------------------------------------------------------------------- + + private val noneObject: NativePointer by lazy { PyNone.get().pointer } + + private val noneAddress: Long by lazy { noneObject.toRawValue() } + + private val runtimeErrorType: NativePointer by lazy { + val builtins = PyEval_GetBuiltins() ?: error("builtins is unreachable") + PyDict_GetItemString(builtins, "RuntimeError") ?: error("builtins.RuntimeError is unreachable") + } + + /** + * The same `RuntimeError` class [raiseInPython] uses, as a callable wrapper. + * + * [AsyncUpcall] needs to *construct* an instance rather than set the indicator, because + * `Future.set_exception` takes an exception object. Sharing the lookup keeps a Kotlin failure + * mapping to one Python type whether it is raised at the call or delivered to an `await`. + * + * The cached pointer is borrowed from the builtins dict, so the wrapper takes a reference of + * its own. + */ + internal fun runtimeErrorClass(): PyObject = PyObject(runtimeErrorType, borrowed = true) +} + +/** + * A `TypeTag.OBJECT` result that is to reach Python as the **scalar** [value] holds -- `int`, + * `float`, `bool` or `str` -- rather than as a [HandleTable] handle (issue #69). + * + * ### Why a marker, and not a rule on the boxed value itself + * + * An `OBJECT` result crosses as an integer handle, and Python has no way to tell a handle from an + * `int`. If [UpcallTrampoline] turned every boxed `Int` it was handed into a Python `int`, a caller + * expecting a handle -- every KSP entry, every walked function returning a type parameter -- could + * no longer tell a Kotlin `Int` from the Kotlin object whose handle happens to be that number. So + * the scalar form is **asked for**, by the one caller that knows it wants it: the binding layer, + * reading a `kotlin.Any` value, calls `PythonCallables.unboxScalar`, which answers with this. No + * other path produces one, so nothing that crosses today changes shape. + * + * @property value a `Boolean`, `Int`, `Long`, `Short`, `Byte`, `Double`, `Float`, `String` or `Char`. + */ +internal class ScalarResult(val value: Any) diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/utils/PyGC.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/utils/PyGC.kt index 58829dcef..9a06d1db3 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/utils/PyGC.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/utils/PyGC.kt @@ -1,4 +1,151 @@ package python.multiplatform.ffi.utils -class PyGC { -} \ No newline at end of file +import python.multiplatform.ffi.PyObject +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pyErrorOrGeneric +import python.multiplatform.ffi.types.modules.PyModule +import python.native.ffi.NativePointer +import python.native.ffi.PyErr_Occurred +import python.native.ffi.PyLong_AsInt +import python.native.ffi.PyLong_AsLongLong +import python.native.ffi.PyObject_CallNoArgs +import python.native.ffi.PyObject_CallObject +import python.native.ffi.PyObject_GetAttrString +import python.native.ffi.PyObject_IsTrue +import python.native.ffi.PyTuple_New +import python.native.ffi.PyTuple_SetItem +import python.native.ffi.Py_DecRef +import python.native.ffi.Py_IncRef + +/** + * Thin façade over CPython's garbage collector and refcounting. + * + * ### Why this drives Python code rather than C entry points + * + * `PyGC_Collect`, `PyGC_Enable`, `PyGC_Disable` and `PyGC_IsEnabled` *are* + * part of the Stable ABI (the last three since 3.10), so this is not an abi3 + * restriction. `PyGC_Collect` is now declared in `EmbedAPI` and wired on + * every platform's `actual` set (ROADMAP §9) -- callers who want the direct + * C entry point instead of `gc.collect()` can reach it as + * `python.native.ffi.PyGC_Collect()` from inside [Python3.withPython], or + * indirectly through [Python3.drainPendingReleases]. `PyGC_Enable`, + * `PyGC_Disable` and `PyGC_IsEnabled` are not, so [enable], [disable] and + * [isEnabled] below still go through the `gc` module, with identical + * semantics at the cost of one Python-level call per operation. + * + * [refCount] is a genuinely different case. Reading a refcount directly needs + * `Py_REFCNT`, which is a macro over the object header and is therefore not + * usable under `Py_LIMITED_API` at the versions this project targets. + * `sys.getrefcount` is the abi3-legal way to ask the question, and is what + * this uses -- there is no cheaper route available to us. + * + * The two module handles are resolved once and kept for the lifetime of the + * interpreter. As with [python.multiplatform.ffi.types.basic.PyNone] they do + * not survive a `Py_Finalize()`/`Py_Initialize()` cycle. + */ +object PyGC { + private val gc: PyModule by lazy { Python3.import("gc") } + private val sys: PyModule by lazy { Python3.import("sys") } + + /** Calls `gc.()` and returns the **new reference** it produced; the caller releases it. */ + private fun callGc(name: String): NativePointer = Python3.withPython { + // PyObject_GetAttrString: new reference to the module-level function. + val function = PyObject_GetAttrString(gc.pointer, name) + ?: throw pyErrorOrGeneric("gc.$name is unavailable") + try { + PyObject_CallNoArgs(function) ?: throw pyErrorOrGeneric("gc.$name() failed") + } finally { + Py_DecRef(function) + } + } + + /** Calls `gc.()` for its side effect only, releasing the `None` it hands back. */ + private fun callGcForEffect(name: String) { + val result = callGc(name) + Python3.withPython { Py_DecRef(result) } + } + + /** + * `gc.collect()`; returns the number of unreachable objects found. + * + * Equivalent to `withPython { PyGC_Collect() }` from `python.native.ffi` -- both bottom out in + * `gc_collect_internal` -- at the cost of one Python-level call. Use the direct binding instead + * if that call is measurable in context. + */ + fun collect(): Int { + val result = callGc("collect") + return Python3.withPython { + try { + val count = PyLong_AsInt(result) + // -1 is both a legitimate value and the failure return; only the error + // indicator tells them apart. + if (count == -1 && PyErr_Occurred() != null) { + throw pyErrorOrGeneric("gc.collect() did not return an int") + } + count + } finally { + Py_DecRef(result) + } + } + } + + /** `gc.enable()`. */ + fun enable() = callGcForEffect("enable") + + /** `gc.disable()`. */ + fun disable() = callGcForEffect("disable") + + /** `gc.isenabled()`. */ + fun isEnabled(): Boolean { + val result = callGc("isenabled") + return Python3.withPython { + val truthy = try { + PyObject_IsTrue(result) + } finally { + Py_DecRef(result) + } + if (truthy < 0) throw pyErrorOrGeneric("gc.isenabled() returned a value with no truth value") + truthy != 0 + } + } + + /** + * `sys.getrefcount(obj) - 1`. + * + * The subtraction removes the temporary reference the call itself holds: + * the argument tuple built below owns one for the duration of the call, + * so the raw number is always one higher than what exists independently + * of asking. Even so, only *differences* between two readings are firm -- + * CPython makes no promise about the absolute count of interned or + * immortal objects. + */ + fun refCount(obj: PyObject): Long = Python3.withPython { + val getrefcount = PyObject_GetAttrString(sys.pointer, "getrefcount") + ?: throw pyErrorOrGeneric("sys.getrefcount is unavailable") + try { + val argTuple = PyTuple_New(1) ?: throw pyErrorOrGeneric("Failed to build the argument tuple") + try { + // PyTuple_SetItem steals, and obj keeps its own reference, so hand over a fresh +1. + Py_IncRef(obj.pointer) + if (PyTuple_SetItem(argTuple, 0, obj.pointer) != 0) { + throw pyErrorOrGeneric("Failed to populate the argument tuple") + } + val result = PyObject_CallObject(getrefcount, argTuple) + ?: throw pyErrorOrGeneric("sys.getrefcount() failed") + try { + val count = PyLong_AsLongLong(result) + if (count == -1L && PyErr_Occurred() != null) { + throw pyErrorOrGeneric("sys.getrefcount() did not return an int") + } + count - 1 + } finally { + Py_DecRef(result) + } + } finally { + Py_DecRef(argTuple) + } + } finally { + Py_DecRef(getrefcount) + } + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/utils/PyImport.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/utils/PyImport.kt index 6299c6f9a..0b76d7f21 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/utils/PyImport.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/utils/PyImport.kt @@ -1,4 +1,65 @@ package python.multiplatform.ffi.utils -class PyImport { -} \ No newline at end of file +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pyErrorOrGeneric +import python.multiplatform.ffi.types.modules.PyModule +import python.native.ffi.PyDict_GetItemString +import python.native.ffi.PyImport_GetModuleDict +import python.native.ffi.PyImport_ReloadModule + +/** + * Import-related helpers that go beyond the single + * [python.multiplatform.ffi.Python3.import] entry point (module reloading, + * inspecting `sys.modules`). + * + * Backed by `PyImport_ReloadModule` and `PyImport_GetModuleDict`, both + * present in `EmbedAPI` and both part of the Stable ABI. + * + * The `PyImport_AddModule` case named in the original sketch -- "adding a + * module without fully importing it" -- is deliberately absent. That function + * returns a **borrowed** reference to a module it creates *and registers in + * `sys.modules`*, which is a mutation dressed as a lookup: exposing it beside + * [getModuleOrNull] would give two similarly-named functions where one + * silently installs a permanent, empty module under the name that was merely + * being asked about. The one internal caller that genuinely needs it + * (`__main__` bring-up) uses `PyImport_AddModuleRef` directly. + */ +object PyImport { + /** `importlib.reload(module)`. */ + fun reload(module: PyModule): PyModule { + // Read the name BEFORE the reload: it is only used in the failure message, and reading + // it there would mean calling back into the C API with an exception already set, which + // corrupts whatever runs next. + val name = module.name + return Python3.withPython { + // PyImport_ReloadModule: new reference on success (to the *same* module object -- + // reload re-executes in place), null + exception set on failure. + val reloaded = PyImport_ReloadModule(module.pointer) + ?: throw pyErrorOrGeneric("Failed to reload module '$name'") + PyModule(reloaded, borrowed = false) + } + } + + /** Looks up an already-imported module by name in `sys.modules`, without importing it. */ + fun getModuleOrNull(name: String): PyModule? = Python3.withPython { + // PyImport_GetModuleDict returns a BORROWED reference to sys.modules. + val modules = PyImport_GetModuleDict() ?: throw pyErrorOrGeneric("sys.modules is unavailable") + // PyDict_GetItemString BORROWS as well, and returns null *without* setting the error + // indicator for a missing key -- which is exactly the "not imported" answer here, so + // there is nothing to clear. + val module = PyDict_GetItemString(modules, name) ?: return@withPython null + PyModule(module, borrowed = true) + } + + /** + * Whether [name] is currently present in `sys.modules`. + * + * Cheaper than `getModuleOrNull(name) != null`: the answer is a borrowed + * pointer either way, and this never builds a wrapper (nor registers a + * cleaner) just to throw it away. + */ + fun isImported(name: String): Boolean = Python3.withPython { + val modules = PyImport_GetModuleDict() ?: throw pyErrorOrGeneric("sys.modules is unavailable") + PyDict_GetItemString(modules, name) != null + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ref/PyAutoCloseable.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ref/PyAutoCloseable.kt index 7393665f7..25747cb10 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ref/PyAutoCloseable.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ref/PyAutoCloseable.kt @@ -3,6 +3,19 @@ package python.multiplatform.ref import python.native.ffi.NativePointer -expect abstract class PyAutoCloseable(pointer: NativePointer) { - abstract fun clean() +expect interface PlatformCleaner : AutoCloseable { + override fun close() +} + +expect fun registerCleaner(pointer: NativePointer, closeAction: (NativePointer) -> Unit): PlatformCleaner + +abstract class PyAutoCloseable( + pointer: NativePointer, + closeAction: (NativePointer) -> Unit +) : AutoCloseable { + private val platformCleaner = registerCleaner(pointer, closeAction) + + override fun close() { + platformCleaner.close() + } } diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ref/PyAutoCloseable.native.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ref/PyAutoCloseable.native.kt deleted file mode 100644 index 169712027..000000000 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ref/PyAutoCloseable.native.kt +++ /dev/null @@ -1,7 +0,0 @@ -package python.multiplatform.ref - -import python.native.ffi.NativePointer - -expect abstract class PyAutoCloseable(pointer: NativePointer) { - -} \ No newline at end of file diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ClassLookup.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ClassLookup.kt index 2a4b25385..d7c0fefcd 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ClassLookup.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ClassLookup.kt @@ -1,80 +1,41 @@ -package python.multiplatform.reflection//package python.multiplatform.ffi -// -//import kotlin.reflect.KClass -// -// -//object ClassLookup { -// private val classMap = mapOf( -// "kotlin" to mapOf( -// "reflect" to mapOf( -// "KClass" to KClass::class -// ) -// ), -// "python" to mapOf( -// "multiplatform" to mapOf( -// "currentPlatform" to python.multiplatform.currentPlatform::class, -// ) -// ) -// ) -// -// private const val CACHE_SIZE = 100 -// private val lruCache = LRUCache>(CACHE_SIZE) -// -// fun findClass(packageName: String): KClass<*>? { -// if (packageName in lruCache) { -// return lruCache.get(packageName) // From cache -// } -// -// val parts = packageName.split(".") -// var currentMap: Any? = classMap -// for (part in parts) { -// if (currentMap is Map<*, *>) { -// currentMap = currentMap[part] -// } else { -// return null // Not matched while searching -// } -// } -// -// return currentMap as? KClass<*> -// } -// -// fun getMember(kClass: KClass<*>, memberName: String): Any? { -// return kClass.members.find { it.name == memberName } -// } -// -// fun invokeStaticMethod(kClass: KClass<*>, methodName: String, vararg args: Any?): Any? { -// val method = kClass.declaredFunctions.find { it.name == methodName } -// method?.isAccessible = true -// return method?.call(*args) -// } -// -// fun invokeStaticMethodWithPackageName(packageName: String, methodName: String, vararg args: Any): Any? { -// val kClass = findClass -// } -//} -// -// -//private class LRUCache(val capacity: Int) { -// private val map: MutableMap = linkedMapOf() -// -// fun get(key: K): V? { -// return map[key] -// } -// -// fun put(key: K, value: V) { -// if (key in map) { -// map.remove(key) // Update the key ordering -// } else if (map.size >= capacity) { -// map.entries.iterator().next().also { map.remove(it.key) } -// } -// map[key] = value -// } -// -// fun containsKey(packageName: K): Boolean { -// return map.containsKey(packageName) -// } -// -// operator fun contains(packageName: K): Boolean { -// return containsKey(packageName) -// } -//} +package python.multiplatform.reflection + + +/** + * Name -> [ReflectedClass] registry. + * + * Populated by [UpcallTable] as fragments are registered, and cleared alongside it -- kept + * separate because class lookup (`type()`, `isinstance()`) is a distinct Python-visible operation + * from callable resolution, even though both come from the same fragments. + */ +object ClassLookup { + + private val classes = LinkedHashMap() + + internal fun register(cls: ReflectedClass) { + classes[cls.name] = cls + } + + internal fun clear() { + classes.clear() + } + + internal val count: Int get() = classes.size + + /** The class registered under [name], or `null` if nothing claims it. */ + fun find(name: String): ReflectedClass? = classes[name] + + /** [find], but throws where the caller has no better response than to give up. */ + fun require(name: String): ReflectedClass = + find(name) ?: throw IllegalArgumentException("no such exposed class: $name") + + /** + * Every class currently registered, in registration order. + * + * For generators that need to describe the whole set rather than resolve one name -- + * [python.multiplatform.ffi.upcall.PythonProxySource] is the one that does, to render a + * Python class per [ReflectedClass] alongside the module functions it already renders. A + * copy, for the same reason [UpcallTable.entries] returns one. + */ + fun all(): List = classes.values.toList() +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ExposedCallable.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ExposedCallable.kt new file mode 100644 index 000000000..a40edcdc3 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ExposedCallable.kt @@ -0,0 +1,229 @@ +package python.multiplatform.reflection + +import kotlin.jvm.JvmInline + + +/** + * How a value is marshalled across the boundary. The trampoline reads these, never the Kotlin + * type, because there is no runtime type to read on Kotlin/Native. + * + * `docs/design/upcall.md` lists five (`INT`, `FLOAT`, `STRING`, `BYTES`, `OBJECT`); + * [BOOLEAN] and [UNIT] are here because Python's `bool` is a distinct type and because a + * function returning `Unit` must marshal to `None` rather than to an object handle. + */ +enum class TypeTag { + INT, + FLOAT, + BOOLEAN, + STRING, + BYTES, + UNIT, + + /** Anything without a dedicated marshaller: crosses as an [ObjectReference] handle. */ + OBJECT, +} + + +/** + * Which CPython slot an entry is reached through, and therefore whether `args[0]` is a receiver. + * + * Every CPython callback convention passes the relevant object as an argument -- `self` for + * methods, `type` for `tp_new`, the closure for getters -- which is why a handful of shared + * trampolines can serve an unbounded number of Kotlin targets and no runtime code generation is + * needed. See `docs/design/upcall.md`. + */ +enum class CallableKind(val hasReceiver: Boolean) { + /** Top-level or companion function. `args` is exactly the declared parameters. */ + FUNCTION(false), + + /** `tp_new`/`tp_init`: the receiver does not exist yet, so it is not in `args`. */ + CONSTRUCTOR(false), + + /** `args[0]` is the receiver, resolved from the proxy's handle. */ + METHOD(true), + + /** Property getter. `args[0]` is the receiver and there are no further arguments. */ + GETTER(true), + + /** Property setter, `var` only. `args[0]` is the receiver, `args[1]` the new value. */ + SETTER(true), + + /** + * A property read that needs no instance: a `companion object` or `object` property, or an + * enum entry. `args` is empty. + * + * Distinct from [FUNCTION] with arity 0 because Python renders it as an attribute on the + * type rather than as a callable, and distinct from [GETTER] because there is no receiver to + * put in `args[0]` -- treating it as one shifts every argument by a slot. + */ + STATIC_GETTER(false), + + /** The `var` counterpart of [STATIC_GETTER]. `args[0]` is the new value, not a receiver. */ + STATIC_SETTER(false), +} + + +/** + * One entry in the function table: everything the boundary needs to call a Kotlin target, with + * no reflection anywhere. + * + * [callable] is deliberately a lambda rather than a `KFunction` reference. + * `KFunction.call()` re-checks arity and types and boxes on every call (50--100 ns, measured in + * this project) and does not exist at all on Kotlin/Native; a generated + * `{ args -> greet(args[0] as String) }` compiles to a direct call there. On the JVM a + * `MethodHandle` can be substituted behind the same shape. + * + * The generator emits these; [python.multiplatform.reflection.FunctionTableFragment] is where + * they arrive from. + */ +class ExposedCallable( + /** The Python-visible qualified name. The only string on this path, and it is used once. */ + val name: String, + /** Declared parameter count, **excluding** any receiver. See [expectedArgCount]. */ + val arity: Int, + val paramTypes: List, + val returnType: TypeTag, + val kind: CallableKind = CallableKind.FUNCTION, + /** + * Whether the Kotlin declaration behind this entry is a `suspend fun`, and therefore whether + * [callable] returns the result or a + * [python.multiplatform.ffi.upcall.PendingCall] standing in for one. + * + * A flag rather than a [CallableKind] of its own, because the two are orthogonal: a suspending + * *method* still has its receiver in `args[0]` and a suspending companion member still has + * none, so folding suspension into [CallableKind] would make every [CallableKind.hasReceiver] + * decision restate itself once per variant. What [kind] answers is *where the arguments are*; + * what this answers is *what comes back*. + * + * [paramTypes] and [returnType] describe the declaration as written -- the continuation + * parameter the Kotlin compiler adds is not here, and [returnType] is the value the coroutine + * will eventually produce, not the `PendingCall`. That is what lets + * `docs/design/upcall.md` §5.2's fast path marshal a body that never suspended exactly as + * a synchronous entry would. + */ + val isSuspend: Boolean = false, + /** + * The Kotlin declaration's parameter names, in [paramTypes] order, or empty when the producer + * did not supply them. + * + * `docs/archive/pythonx-adapter-design.md` §2.4 is the reason this exists: mapping a Python keyword + * argument onto a positional slot needs names, and neither side of the boundary had them -- + * "this is not a `pythonx` design choice, it is arithmetic." Both producers can read them + * (`KmValueParameter.name` for the walker, KSP's own parameter list for the processor), so the + * only thing that was missing is the field. + * + * For an [isExtension] entry, slot 0 is the receiver and is named ``: it is + * positional-only by construction (Kotlin has no keyword form for a receiver either), and a name + * no Python identifier can spell is the honest way to say so. + * + * Empty rather than absent-per-entry so that a producer that has not been taught to fill it in + * -- and every hand-written fragment in this repository's tests -- keeps compiling and keeps + * working positionally. + */ + val paramNames: List = emptyList(), + /** + * The Kotlin type each parameter was **declared** as, in [paramTypes] order, or empty when the + * producer did not supply them. + * + * [paramTypes] says how a value is *marshalled*, which is deliberately lossy: a `Dp` parameter is + * a [TypeTag.FLOAT] because the generated Kotlin wraps the raw number back up into a `Dp` on the + * way in (`docs/archive/pythonx-adapter-design.md` §4.4). That is correct for calling and useless for + * describing -- a Python adapter cannot tell `Dp` from `Float`, and a `.pyi` generator cannot + * name the type it is meant to stub. This is the declaration as written. + */ + val paramTypeNames: List = emptyList(), + /** [paramTypeNames] for the return, or `null` when the producer did not supply it. */ + val returnTypeName: String? = null, + /** + * Whether this entry is a Kotlin extension function, and therefore whether slot 0 of `args` is a + * receiver rather than an ordinary first parameter. + * + * Distinct from [CallableKind.hasReceiver], which asks a different question: a [CallableKind + * .METHOD]'s receiver is an *instance* resolved from a proxy's handle and is not counted in + * [arity], whereas an extension's receiver is an ordinary argument that is. Both are "there is a + * receiver"; only this one means "and it is already in `args[0]` and counted". + * + * `docs/design/kotlin-extensions-in-python.md` §4.1 -- an extension becomes a method on its receiver's + * proxy -- has no input without this and [receiverTypeName]. + */ + val isExtension: Boolean = false, + /** The Kotlin type [isExtension] extends, e.g. `androidx.compose.ui.Modifier`; `null` otherwise. */ + val receiverTypeName: String? = null, + /** + * Whether each parameter declares a default value, in [paramTypes] order, or empty when the + * producer did not supply it. + * + * **A flag, and nothing acts on it yet.** Every entry this repository generates passes every + * argument explicitly; there is no route from here to a call that omits one. + * `docs/archive/pythonx-adapter-design.md` §4.5 names four candidates and a measured reason each may not + * work -- notably that Compose carries a composable's `$default` mask as a *declared* trailing + * parameter and emits no `fn$default` bridge at all, so "call the synthetic" is not available. + * The flag is carried because metadata has it and because a stub that marks 254 of `Modifier`'s + * 435 parameters required would be wrong about most of the API; the calling convention that uses + * it is out of scope and open. + */ + val paramHasDefault: List = emptyList(), + val callable: (Array) -> Any?, +) { + init { + // A generator bug, caught where the generated code is loaded rather than at the first + // call from Python with the wrong number of arguments. + require(paramTypes.size == arity) { + "$name declares arity $arity but ${paramTypes.size} parameter types" + } + // Empty is "not supplied"; any other length is a producer that disagrees with itself about + // how many parameters this declaration has, which would silently misalign every keyword + // argument by one slot. + require(paramNames.isEmpty() || paramNames.size == arity) { + "$name declares arity $arity but ${paramNames.size} parameter names" + } + require(paramTypeNames.isEmpty() || paramTypeNames.size == arity) { + "$name declares arity $arity but ${paramTypeNames.size} parameter type names" + } + require(paramHasDefault.isEmpty() || paramHasDefault.size == arity) { + "$name declares arity $arity but ${paramHasDefault.size} default flags" + } + } + + /** The size of the `args` array [callable] expects: [arity] plus a receiver slot if any. */ + val expectedArgCount: Int get() = arity + if (kind.hasReceiver) 1 else 0 + + override fun toString(): String = + "ExposedCallable(${if (isSuspend) "suspend " else ""}$kind $name/$arity -> $returnType)" +} + + +/** + * A resolved entry in [UpcallTable] -- the thing the Python proxy caches so that no call after + * the first one passes a string. + * + * `docs/design/upcall.md` takes this from ObjC: a selector is fast because it is an interned + * pointer, not because a table exists. The equivalent here is that the name is resolved once + * and this integer is what crosses afterwards. + * + * The raw value packs the table **epoch** into the high 32 bits and the entry index into the + * low 32, for the same reason [ObjectReference] carries a generation: a handle cached by a + * Python proxy across a table reinstall would otherwise index into the new table and invoke a + * different function. + */ +@JvmInline +value class CallableHandle(val raw: Long) { + + /** False for [NONE]. A valid-looking handle may still belong to a superseded table. */ + val isValid: Boolean get() = raw >= 0 + + internal val index: Int get() = (raw and 0xFFFF_FFFFL).toInt() + + internal val epoch: Int get() = (raw ushr 32).toInt() + + override fun toString(): String = + if (raw < 0) "CallableHandle(NONE)" else "CallableHandle(index=$index, epoch=$epoch)" + + companion object { + /** "No such name." Crosses the boundary as -1; raising `AttributeError` is Python's job. */ + val NONE: CallableHandle = CallableHandle(-1L) + + internal fun encode(index: Int, epoch: Int): CallableHandle = + CallableHandle((epoch.toLong() shl 32) or (index.toLong() and 0xFFFF_FFFFL)) + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/HandleTable.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/HandleTable.kt new file mode 100644 index 000000000..8c7157020 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/HandleTable.kt @@ -0,0 +1,169 @@ +package python.multiplatform.reflection + + +/** + * The GC root that lets Python hold a Kotlin object. + * + * `docs/design/object-lifetime.md` sketches the whole thing in four lines: + * + * private val liveHandles = HashMap() + * fun register(obj: Any): Long { val h = next++; liveHandles[h] = obj; return h } + * fun release(h: Long) { liveHandles.remove(h) } + * + * This is that, with the two properties the sketch leaves out. + * + * **Slot reuse.** Storage is an array indexed by the low half of the handle, so resolution is a + * bounds check and an array read rather than a hash. A release returns the slot to a free list, + * which keeps the array bounded by the number of *live* handles instead of the number ever + * issued. + * + * **Staleness.** Reusing a slot without changing the handle would alias a released handle onto + * the next object to occupy it: Python would keep calling, every call would land on the wrong + * instance, and nothing anywhere would report an error. So each slot carries a generation that + * is bumped on release, and resolution rejects a handle whose generation no longer matches. + * + * **This table leaks by construction.** Every entry is a strong reference the Kotlin GC can + * see and Python cannot; nothing here can tell that the Python proxy has died. The proxy's + * `tp_dealloc` calling [release] is the entire lifetime contract, and it is why [release] + * reports whether it actually did anything -- a double release must not free a slot that has + * since been handed to someone else. + * + * That contract is kept in exactly two places, and a handle that reaches Python by any other + * route is a leak: + * + * - a **proxy instance**, through the `__del__` `python.multiplatform.ffi.upcall.PythonProxySource` + * renders beside every `__init__` that takes a handle. It went unwritten for as long as the + * generator existed, and `GeneratedProxyCostTest` ended a run with 78 002 live entries because + * of it; `ProxyHandleLifetimeTest` now pins both ends of the count. + * - a **bare handle** handed to a caller that asked for one over the raw boundary, through + * `_pm_release`. Nothing else can give that one back: it is an integer, and an integer has + * nothing to hang a finaliser off. + * + * Cycles are a separate problem this table cannot solve on its own: a strong entry here is a + * root, so a Python object reachable only through a Kotlin object reachable only from this + * table never collects. That needs `tp_traverse` to reach through the handle + * ([ReflectedClass.traverse]), and for the Kotlin-side case it needs this table to hold weakly + * with the proxy supplying the strength -- which `docs/design/object-lifetime.md` records as not yet + * worked through. + * + * ### Threading + * + * Not synchronised. Every mutation happens on a thread that holds the GIL: an upcall arrives + * with it held, and handing a Kotlin object to Python requires it to build the proxy. That is + * the same rule the rest of the boundary already lives under (`commonMain/README.md`), and it + * is what makes the plain `ArrayList`s here safe. It stops being true under free-threading + * (ROADMAP §9), where this needs a real lock. + */ +object HandleTable { + + /** Slot storage. A `null` element is a free slot; the index is the handle's low half. */ + private val objects = ArrayList() + + /** Per-slot generation, bumped on release. Starts at 1 so no handle is ever `0`. */ + private val generations = ArrayList() + + /** Free slot indices, used as a stack so the hot slot stays hot. */ + private val freeSlots = ArrayList() + + private var live = 0 + + /** How many handles are currently rooting an object. The leak counter. */ + val liveCount: Int get() = live + + /** How many slots the backing array holds. Bounded by peak [liveCount], not by total issued. */ + val slotCount: Int get() = objects.size + + /** + * Roots [obj] and returns the handle Python will hold. + * + * Registration is not interning: registering the same object twice yields two handles, each + * released independently. Two Python proxies over one Kotlin object therefore do not have to + * agree about who dies first. (Whether they *should* be the same proxy -- Python `is` + * identity across the boundary -- is the Python side's question and is not settled here.) + */ + fun register(obj: Any): ObjectReference { + val slot: Int + if (freeSlots.isEmpty()) { + slot = objects.size + objects.add(obj) + generations.add(1) + } else { + slot = freeSlots.removeAt(freeSlots.size - 1) + objects[slot] = obj + } + live++ + return ObjectReference.encode(slot, generations[slot]) + } + + /** The object [ref] roots, or `null` if the handle is [ObjectReference.NONE] or stale. */ + fun resolve(ref: ObjectReference): Any? = resolveRaw(ref.raw) + + /** + * [resolve] for the boundary, which carries the handle as a bare `Long`. + * + * Every rejection path is a plain comparison: a handle that was never issued, one whose slot + * is out of range, and one whose generation has moved on all return `null` rather than + * reading past the end of the array. + */ + fun resolveRaw(raw: Long): Any? { + if (raw == ObjectReference.NONE_RAW) return null + val slot = (raw and ObjectReference.SLOT_MASK).toInt() + if (slot < 0 || slot >= objects.size) return null + if (generations[slot] != (raw ushr ObjectReference.GENERATION_SHIFT).toInt()) return null + return objects[slot] + } + + /** + * [resolve], but throws instead of returning `null`. + * + * The trampolines want this one: an unresolvable handle there means Python outlived the + * object it was holding, and carrying on would corrupt something further away. + */ + fun require(ref: ObjectReference): Any = + resolveRaw(ref.raw) ?: error("stale or unknown object handle: $ref") + + /** + * Drops the root [ref] holds. + * + * @return true if this call released a live entry, false if the handle was already released, + * never issued, or [ObjectReference.NONE]. A `false` here is the double-release case, and + * it must stay a no-op: the slot may already belong to someone else. + */ + fun release(ref: ObjectReference): Boolean { + val raw = ref.raw + if (raw == ObjectReference.NONE_RAW) return false + val slot = (raw and ObjectReference.SLOT_MASK).toInt() + if (slot < 0 || slot >= objects.size) return false + if (generations[slot] != (raw ushr ObjectReference.GENERATION_SHIFT).toInt()) return false + if (objects[slot] == null) return false + releaseSlot(slot) + return true + } + + /** + * Drops every root. + * + * For interpreter finalisation and for tests. Generations are bumped rather than reset, so + * handles issued before the call stay unresolvable afterwards. + */ + fun releaseAll() { + for (slot in objects.indices) { + if (objects[slot] != null) releaseSlot(slot) + } + } + + private fun releaseSlot(slot: Int) { + objects[slot] = null + generations[slot] = nextGeneration(generations[slot]) + freeSlots.add(slot) + live-- + } + + /** + * Wraps back to 1 rather than to 0, because generation 0 would make a handle collide with + * [ObjectReference.NONE]. A slot released 2^31 times can alias one handle issued that long + * ago; the alternative is a 128-bit handle, and this is the same trade every generational + * handle table makes. + */ + private fun nextGeneration(current: Int): Int = if (current == Int.MAX_VALUE) 1 else current + 1 +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/InstallsUpcallTable.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/InstallsUpcallTable.kt new file mode 100644 index 000000000..358421489 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/InstallsUpcallTable.kt @@ -0,0 +1,53 @@ +package python.multiplatform.reflection + +/** + * Marks the `expect fun` that shared code calls to install the generated function table. + * + * ### The problem this exists for + * + * KSP writes `python.multiplatform.generated.FunctionTable` into the *compilation* that generated + * it -- `build/generated/ksp/iosSimulatorArm64/iosSimulatorArm64Main/kotlin` and one sibling per + * target. An **intermediate** source set (`commonMain`, `iosMain`, `androidNativeMain`) is one the + * generating leaves depend on, not the other way round, so it cannot name that object at all: + * + * e: TableInstall.kt: Unresolved reference 'FunctionTable'. + * + * observed on `ksp-fixtures/app`'s `commonMain`. ROADMAP §13 recorded the consequence: every + * consumer wrote the same one-line `actual` once per leaf target, and shared code that wanted the + * table had nothing to call. `androidMain` is the exception that proves the rule -- it *is* the + * Android target's own source set, so it can name `FunctionTable` -- and an exception per platform + * is exactly what shared code cannot be written against. + * + * ### What to write + * + * One `expect` declaration in `commonMain`, and nothing else. The processor emits the matching + * `actual` into every leaf compilation it generates a `FunctionTable` for: + * + * // commonMain + * @InstallsUpcallTable + * expect fun installGeneratedUpcallTable() + * + * // anywhere at all, commonMain and intermediate source sets included + * installGeneratedUpcallTable() + * + * `expect`/`actual` rather than an interface plus a runtime registry because the reference chain + * has to stay *static*: `docs/design/upcall.md` keeps every fragment reachable to the + * Kotlin/Native linker by referencing it explicitly, with no `ServiceLoader` and no + * `@EagerInitialization`. A generated object that nothing names is dead code the linker is free to + * drop. The generated `actual` is that name, and it is generated rather than written by hand + * because the leaf source set is the one place the author of shared code cannot put it. + * + * ### Rules + * + * - The annotated declaration must be a top-level `expect fun`, taking no parameters and + * returning `Unit`. `public` or `internal`; the generated `actual` matches. + * - Only an **app-role** module can satisfy it -- a `library`-role module emits a fragment and no + * aggregator, so there is no `FunctionTable` for the `actual` to call. The processor reports + * that as an error rather than leaving an `expect` unimplemented. + * - More than one is allowed, and each gets its own `actual`. + * + * Read at build time by `python-multiplatform-ksp` only; it has no effect at runtime. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.SOURCE) +annotation class InstallsUpcallTable diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ObjectReference.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ObjectReference.kt index b24ecf365..a22d7a503 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ObjectReference.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ObjectReference.kt @@ -1,18 +1,47 @@ -package python.multiplatform.reflection//package python.multiplatform.ffi -// -// -//object ObjectReference { -// private val referenceMap = mutableMapOf() -// -// fun addReference(key: String, value: Any) { -// referenceMap[key] = value -// } -// -// fun getReference(key: String): Any? { -// return referenceMap[key] -// } -// -// fun removeReference(key: String) { -// referenceMap.remove(key) -// } -//} +package python.multiplatform.reflection + +import kotlin.jvm.JvmInline + + +/** + * A Python-side reference to a Kotlin object: an integer, and nothing else. + * + * Python cannot hold the object itself on any of our targets -- a JVM reference stored in + * native memory is invisible to the GC, a Kotlin/Native reference is a raw pointer with no + * ownership, and a WasmGC reference cannot be written into linear memory at all. So the proxy's + * instance data holds this integer and [HandleTable] holds the object. `docs/design/object-lifetime.md` + * calls this "what JNI's `NewGlobalRef` does, made explicit". + * + * The raw value packs a **generation** into the high 32 bits and a **slot index** into the low + * 32. Slots are reused; generations are not, so a handle that outlived its release does not + * resolve to whatever took its slot. Without that, a stale handle would silently call methods + * on the wrong instance -- the one boundary bug that produces no error at all. + * + * Generations start at 1, so a valid handle is never `0`. Zero is [NONE], which is what the + * boundary uses for "no object". + */ +@JvmInline +value class ObjectReference(val raw: Long) { + + /** False only for [NONE]; a non-zero handle may still be stale, which only the table knows. */ + val isValid: Boolean get() = raw != NONE_RAW + + internal val slot: Int get() = (raw and SLOT_MASK).toInt() + + internal val generation: Int get() = (raw ushr GENERATION_SHIFT).toInt() + + override fun toString(): String = + if (raw == NONE_RAW) "ObjectReference(NONE)" else "ObjectReference(slot=$slot, gen=$generation)" + + companion object { + internal const val NONE_RAW: Long = 0L + internal const val SLOT_MASK: Long = 0xFFFF_FFFFL + internal const val GENERATION_SHIFT: Int = 32 + + /** The null handle. Never issued by [HandleTable]. */ + val NONE: ObjectReference = ObjectReference(NONE_RAW) + + internal fun encode(slot: Int, generation: Int): ObjectReference = + ObjectReference((generation.toLong() shl GENERATION_SHIFT) or (slot.toLong() and SLOT_MASK)) + } +} diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/PythonInternal.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/PythonInternal.kt new file mode 100644 index 000000000..106c57d54 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/PythonInternal.kt @@ -0,0 +1,14 @@ +package python.multiplatform.reflection + +/** + * Opt-out of the upcall table. `docs/design/binding-policy.md` settles the exposure model as a + * blacklist -- every `public` declaration is exposed to Python by default -- so the only knob a + * library author needs is a way to say "not this one". + * + * Applies to a top-level function, a class (excluding the whole class and its members), or a + * member function/property. The KSP fragment generator (`docs/design/upcall.md`) is the + * only reader of this annotation; it has no effect at runtime. + */ +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.SOURCE) +annotation class PythonInternal diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ReflectedClass.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ReflectedClass.kt index 714ab1f37..5265b967d 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ReflectedClass.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/ReflectedClass.kt @@ -1,6 +1,79 @@ package python.multiplatform.reflection -interface ReflectedClass { +/** + * Which Kotlin declaration a [ReflectedClass] describes. + * + * The Python side cannot infer this from [ReflectedClass.memberNames]: a class with a private + * constructor and an interface both simply have no `` entry, yet one may gain instances + * from a factory and the other never has a Python-constructible form at all. Each value here + * names a different proxy shape, so the decision is recorded at generation time rather than + * guessed at import time. + */ +enum class ReflectedClassKind { + /** An ordinary class. Constructible from Python if a `` entry is present. */ + CLASS, + /** + * An interface. Never constructible; its entries exist so that a Kotlin object handed to + * Python can be called through the interface even when its concrete class is not exposed. + */ + INTERFACE, + + /** A Kotlin `object`. Exactly one instance, so every member is reached without a receiver. */ + OBJECT, + + /** An `enum class`. [ReflectedClass.enumEntryNames] lists its members. */ + ENUM, +} + + +/** + * A build-time descriptor for one exposed Kotlin class: what to call it from Python, which + * [UpcallTable] entries are its members, and how to walk the Python references it holds for + * cycle collection. + * + * KSP emits one of these per exposed class alongside the [ExposedCallable] entries for its + * constructor, methods and accessors -- see `docs/design/upcall.md`, and for why + * [traverse] exists at all, `docs/design/object-lifetime.md`'s "Cycle collection is part of the + * table's job". + */ +class ReflectedClass( + /** The Python-visible qualified name, and the prefix every entry in [memberNames] shares. */ + val name: String, + /** [UpcallTable]-resolvable names of this class's constructor, methods and accessors. */ + val memberNames: List, + traverse: ((Any, (Long) -> Unit) -> Unit)? = null, + /** Which Kotlin shape this came from; see [ReflectedClassKind]. */ + val kind: ReflectedClassKind = ReflectedClassKind.CLASS, + /** + * For [ReflectedClassKind.ENUM], the entry names in declaration order -- everything a Python + * `enum.Enum` mirror needs, without the boundary having to marshal a collection. Empty for + * every other kind. + * + * Each name has a matching [CallableKind.STATIC_GETTER] entry `"$name.$entry"` that returns + * the single instance. + */ + val enumEntryNames: List = emptyList(), +) { + private val traverseImpl = traverse + + /** + * Whether the proxy type needs a `tp_traverse` slot at all. A class with no `PyObject`-typed + * fields cannot be part of a cycle through this object, so the generator leaves [traverse] + * unset here rather than emitting an always-present no-op. + */ + val hasTraverse: Boolean get() = traverseImpl != null + + /** + * Visits the raw pointer of every live `PyObject`-typed field [obj] holds. CPython runs + * `tp_traverse` during collection, which is why the generated [traverse] is restricted to + * field reads and [visit] calls -- no allocation, no other user code. A no-op when + * [hasTraverse] is false. + */ + fun traverse(obj: Any, visit: (Long) -> Unit) { + traverseImpl?.invoke(obj, visit) + } + + override fun toString(): String = "ReflectedClass($kind $name, ${memberNames.size} members)" } diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/UpcallTable.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/UpcallTable.kt new file mode 100644 index 000000000..481f97ae4 --- /dev/null +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/reflection/UpcallTable.kt @@ -0,0 +1,149 @@ +package python.multiplatform.reflection + + +/** + * What a KSP-generated fragment implements: one object per module, holding the calls and + * classes that module exposes. + * + * `docs/design/upcall.md` names the generated objects `Fragment_`; this interface + * is the shape they are emitted into and the shape [UpcallTable] registers. `moduleName` is what + * makes [UpcallTable.register] idempotent -- an aggregator that references a fragment already + * installed manually must not double its entries. + */ +interface FunctionTableFragment { + /** Sanitised module name, the same value the generator was passed as a KSP option. */ + val moduleName: String + + fun entries(): List + + /** Classes this fragment exposes. Most fragments -- anything without a class -- expose none. */ + fun classes(): List = emptyList() +} + + +/** + * The runtime half of the build-time function table (ROADMAP §7, `docs/design/upcall.md`). + * + * A name is resolved to a [CallableHandle] once; every call after that carries only the handle -- + * a table index plus an epoch that rejects handles from a table that has since been reinstalled. + * `docs/design/upcall.md` takes this from ObjC: the win is not having a table, it is never + * hashing and comparing a string more than once per name. + * + * [ClassLookup] is the companion registry for the [ReflectedClass] half of a fragment; it is + * cleared and populated in lockstep with this table rather than merged into it, because class + * lookup (`type()`, `isinstance()`) is a distinct Python-visible operation from callable + * resolution even though both come from the same fragments. + * + * ### Threading + * Not synchronised, for the same reason as [HandleTable]: installation happens once at start-up, + * or under the GIL for an upcall-driven [register], never concurrently with a lookup. + */ +object UpcallTable { + + /** Bumped on every [clear]. A handle's epoch must match this or it is treated as unresolved. */ + private var epoch = 0 + + /** + * The current [epoch], read-only. The two Python installers stamp it on what they install, so + * that when both have run, the one installed for the newer table is the one that answers + * (`python_multiplatform.kotlin_function`). + */ + internal val currentEpoch: Int get() = epoch + + private val callables = ArrayList() + private val nameToIndex = HashMap() + private val installedModules = LinkedHashSet() + + val callableCount: Int get() = callables.size + val classCount: Int get() = ClassLookup.count + val moduleNames: Set get() = installedModules + + /** Drops every registration and invalidates every handle issued so far. */ + fun clear() { + epoch++ + callables.clear() + nameToIndex.clear() + installedModules.clear() + ClassLookup.clear() + } + + /** + * What the generated aggregator calls: replace the table wholesale with [fragments]. + * + * Equivalent to [clear] followed by [register] on each fragment. Reinstalling deliberately + * invalidates handles resolved against the old table rather than trying to preserve them -- + * continuing to trust a handle across a reinstall is the failure the epoch exists to catch. + */ + fun install(fragments: List) { + clear() + fragments.forEach { register(it) } + } + + /** + * Adds one fragment's entries and classes. + * + * A fragment already registered (by [FunctionTableFragment.moduleName]) is a no-op, so a + * fragment swept up by both a manual call and an aggregator does not get counted twice. Name + * collisions against entries from a *different* fragment are validated before anything is + * mutated, so a failed registration leaves the table exactly as it was. + */ + fun register(fragment: FunctionTableFragment) { + if (fragment.moduleName in installedModules) return + + val entries = fragment.entries() + for (entry in entries) { + if (entry.name in nameToIndex) { + error("duplicate exposed name '${entry.name}': already registered by another fragment") + } + } + + for (entry in entries) { + nameToIndex[entry.name] = callables.size + callables.add(entry) + } + fragment.classes().forEach(ClassLookup::register) + installedModules.add(fragment.moduleName) + } + + /** + * Every entry currently installed, in registration order. + * + * For generators that need to describe the whole table rather than resolve one name -- + * [python.multiplatform.ffi.upcall.PythonProxySource] is the one that does. A copy, because a + * caller iterating the live list while an upcall registers into it would be reading a table + * mid-mutation; this path is not hot enough for that to matter. + */ + fun entries(): List = callables.toList() + + /** Resolves [name] to a handle, or [CallableHandle.NONE] if nothing claims it. */ + fun resolve(name: String): CallableHandle { + val index = nameToIndex[name] ?: return CallableHandle.NONE + return CallableHandle.encode(index, epoch) + } + + /** The entry [handle] resolves to. Throws if [handle] is [CallableHandle.NONE], unknown, or stale. */ + fun callable(handle: CallableHandle): ExposedCallable = + resolveEntry(handle) ?: throw IllegalArgumentException("invalid or stale callable handle: $handle") + + /** The fast path: call through an already-resolved [handle]. */ + fun invoke(handle: CallableHandle, args: Array): Any? = callable(handle).callable(args) + + /** + * The slow path: resolve and call by name in one step. Exists for the cost comparison in + * `UpcallOverheadTest`; production trampolines [resolve] once and [invoke] on every call + * after that. + */ + fun invokeByName(name: String, args: Array): Any? { + val index = nameToIndex[name] + ?: throw IllegalArgumentException("no such exposed name: $name") + return callables[index].callable(args) + } + + private fun resolveEntry(handle: CallableHandle): ExposedCallable? { + if (!handle.isValid) return null + if (handle.epoch != epoch) return null + val index = handle.index + if (index < 0 || index >= callables.size) return null + return callables[index] + } +} 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 d35e61cba..5903e9daf 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/native/ffi/EmbedAPI.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/native/ffi/EmbedAPI.kt @@ -7,7 +7,7 @@ import kotlin.jvm.JvmInline * Runs given [block] providing allocation of memory * which will be automatically disposed at the end of this scope. */ -expect inline fun memScoped(block: () -> R): R +expect inline fun memScoped(block: () -> R): R interface AddressValue @@ -18,7 +18,7 @@ value class NativePointer internal constructor(val address: Any) { } @HighOverheadNativeCall expect fun NativePointer.toAddressValue(): AddressValue -expect inline fun NativePointer.toRawValue(): Long +expect fun NativePointer.toRawValue(): Long @HighOverheadNativeCall expect fun AddressValue.toNativePointer(): NativePointer @HighOverheadNativeCall @@ -27,224 +27,4006 @@ expect fun Long.toNativePointer(): NativePointer? //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Section 1 -// Initializing and finalizing the interpreter +// Initialization, Finalization, and Threads //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /** - * Part of the Stable ABI. + * *Part of the Stable ABI.* * - * Initialize the Python interpreter. - * In an application embedding Python, this should be called before using any other Python/C API functions; see Before Python Initialization for the few exceptions. + * Initialize the Python interpreter. In an application embedding + * Python, this should be called before using any other Python/C API + * functions; see Before Python Initialization for the few exceptions. * - * This initializes the table of loaded modules (sys.modules), and creates the fundamental modules builtins, __main__ and sys. It also initializes the module search path (sys.path). - * It does not set sys.argv; use the Python Initialization Configuration API for that. This is a no-op when called for a second time (without calling Py_FinalizeEx() first). - * There is no return value; it is a fatal error if the initialization fails. + * This initializes the table of loaded modules ("sys.modules"), and + * creates the fundamental modules "builtins", "__main__" and "sys". + * It also initializes the module search path ("sys.path"). It does + * not set "sys.argv"; use the Python Initialization Configuration API + * for that. This is a no-op when called for a second time (without + * calling "Py_FinalizeEx()" first). There is no return value; it is + * a fatal error if the initialization fails. * - * Use Py_InitializeFromConfig() to customize the Python Initialization Configuration. + * Use "Py_InitializeFromConfig()" to customize the Python + * Initialization Configuration. * - * Note: On Windows, changes the console mode from O_TEXT to O_BINARY, which will also affect non-Python uses of the console using the C Runtime. + * Note: + * + * On Windows, changes the console mode from "O_TEXT" to "O_BINARY", + * which will also affect non-Python uses of the console using the C + * Runtime. + */ +expect inline fun Py_Initialize() + +/** + * *Part of the Stable ABI.* + * + * This function works like "Py_Initialize()" if *initsigs* is "1". If + * *initsigs* is "0", it skips initialization registration of signal + * handlers, which may be useful when CPython is embedded as part of a + * larger application. + * + * Use "Py_InitializeFromConfig()" to customize the Python + * Initialization Configuration. + */ +expect inline fun Py_InitializeEx(initsigs: Int) + +/** + * *Part of the Stable ABI.* + * + * Return true (nonzero) when the Python interpreter has been + * initialized, false (zero) if not. After "Py_FinalizeEx()" is + * called, this returns false until "Py_Initialize()" is called again. + */ +expect inline fun Py_IsInitialized(): Int + +/** + * *Part of the Stable ABI since version 3.13.* + * + * Return true (non-zero) if the main Python interpreter is *shutting + * down*. Return false (zero) otherwise. + * + * Added in version 3.13. + */ +expect inline fun Py_IsFinalizing(): Int + +/** + * *Part of the Stable ABI since version 3.6.* + * + * Undo all initializations made by "Py_Initialize()" and subsequent + * use of Python/C API functions, and destroy all sub-interpreters + * (see "Py_NewInterpreter()" below) that were created and not yet + * destroyed since the last call to "Py_Initialize()". Ideally, this + * frees all memory allocated by the Python interpreter. This is a + * no-op when called for a second time (without calling + * "Py_Initialize()" again first). + * + * Since this is the reverse of "Py_Initialize()", it should be called + * in the same thread with the same interpreter active. That means + * the main thread and the main interpreter. This should never be + * called while "Py_RunMain()" is running. + * + * Normally the return value is "0". If there were errors during + * finalization (flushing buffered data), "-1" is returned. + * + * This function is provided for a number of reasons. An embedding + * application might want to restart Python without having to restart + * the application itself. An application that has loaded the Python + * interpreter from a dynamically loadable library (or DLL) might want + * to free all memory allocated by Python before unloading the DLL. + * During a hunt for memory leaks in an application a developer might + * want to free all memory allocated by Python before exiting from the + * application. + * + * **Bugs and caveats:** The destruction of modules and objects in + * modules is done in random order; this may cause destructors + * ("__del__()" methods) to fail when they depend on other objects + * (even functions) or modules. Dynamically loaded extension modules + * loaded by Python are not unloaded. Small amounts of memory + * allocated by the Python interpreter may not be freed (if you find a + * leak, please report it). Memory tied up in circular references + * between objects is not freed. Some memory allocated by extension + * modules may not be freed. Some extensions may not work properly if + * their initialization routine is called more than once; this can + * happen if an application calls "Py_Initialize()" and + * "Py_FinalizeEx()" more than once. + * + * Raises an auditing event "cpython._PySys_ClearAuditHooks" with no + * arguments. + * + * Added in version 3.6. + */ +expect inline fun Py_FinalizeEx(): Int + +/** + * *Part of the Stable ABI.* + * + * This is a backwards-compatible version of "Py_FinalizeEx()" that + * disregards the return value. + */ +expect inline fun Py_Finalize() + +// `Py_BytesMain(int argc, char **argv)` is deliberately absent from this surface. +// +// It is the one declaration from the superseded pre-migration draft that had no live counterpart, +// so deleting that draft removed the last copy of it. It is not being revived here: every other +// entry point in this file marshals scalars, pointers or a single string, and `char **argv` needs +// a per-platform array marshaller -- an `Arena`-allocated pointer array on desktop, a +// `jobjectArray` -> `char**` wrapper plus a `JNINativeMethod` registration and a call-convention +// classification on Android, `allocArray>` on native, and a linear-memory +// argv on wasm. That is four marshallers and a JNI table change for one function. +// +// Its only consumer is `Python3.runApp`, which already throws `NotImplementedError` naming this +// function, and `Python3Test` pins that message so the gap cannot be quietly closed or quietly +// forgotten. Reviving it is a task of its own; the native-side body written for it survives in +// git history (`EmbedAPI.native.kt`, inside the block deleted here). + +/** + * Executes the main module in a fully configured CPython runtime. + * + * Executes the command ("PyConfig.run_command"), the script + * ("PyConfig.run_filename") or the module ("PyConfig.run_module") + * specified on the command line or in the configuration. If none of + * these values are set, runs the interactive Python prompt (REPL) + * using the "__main__" module’s global namespace. + * + * If "PyConfig.inspect" is not set (the default), the return value + * will be "0" if the interpreter exits normally (that is, without + * raising an exception), or "1" if the interpreter exits due to an + * exception. If an otherwise unhandled "SystemExit" is raised, the + * function will immediately exit the process instead of returning + * "1". + * + * If "PyConfig.inspect" is set (such as when the "-i" option is + * used), rather than returning when the interpreter exits, execution + * will instead resume in an interactive Python prompt (REPL) using + * the "__main__" module’s global namespace. If the interpreter exited + * with an exception, it is immediately raised in the REPL session. + * The function return value is then determined by the way the *REPL + * session* terminates: returning "0" if the session terminates + * without raising an unhandled exception, exiting immediately for an + * unhandled "SystemExit", and returning "1" for any other unhandled + * exception. + * + * This function always finalizes the Python interpreter regardless of + * whether it returns a value or immediately exits the process due to + * an unhandled "SystemExit" exception. + * + * See Python Configuration for an example of a customized Python that + * always runs in isolated mode using "Py_RunMain()". + */ +expect inline fun Py_RunMain(): Int // 수동 추가 + +/** + * *Part of the Stable ABI.* + * + * Return the version of this Python interpreter. This is a string + * that looks something like + * + * "3.0a5+ (py3k:63103M, May 12 2008, 00:53:55) \n[GCC 4.2.3]" + * + * The first word (up to the first space character) is the current + * Python version; the first characters are the major and minor + * version separated by a period. The returned string points into + * static storage; the caller should not modify its value. The value + * is available to Python code as "sys.version". + * + * See also the "Py_Version" constant. + */ +expect inline fun Py_GetVersion(): String? + +/** + * *Part of the Stable ABI.* + * + * Return the platform identifier for the current platform. On Unix, + * this is formed from the “official” name of the operating system, + * converted to lower case, followed by the major revision number; + * e.g., for Solaris 2.x, which is also known as SunOS 5.x, the value + * is "'sunos5'". On macOS, it is "'darwin'". On Windows, it is + * "'win'". The returned string points into static storage; the + * caller should not modify its value. The value is available to + * Python code as "sys.platform". + */ +expect inline fun Py_GetPlatform(): String? + +/** + * *Part of the Stable ABI.* + * + * Return the official copyright string for the current Python + * version, for example + * + * "'Copyright 1991-1995 Stichting Mathematisch Centrum, Amsterdam'" + * + * The returned string points into static storage; the caller should + * not modify its value. The value is available to Python code as + * "sys.copyright". + */ +expect inline fun Py_GetCopyright(): String? + +/** + * *Part of the Stable ABI.* + * + * Return an indication of the compiler used to build the current + * Python version, in square brackets, for example: + * + * "[GCC 2.7.2.2]" + * + * The returned string points into static storage; the caller should + * not modify its value. The value is available to Python code as + * part of the variable "sys.version". + */ +expect inline fun Py_GetCompiler(): String? + +/** + * *Part of the Stable ABI.* + * + * Return information about the sequence number and build date and + * time of the current Python interpreter instance, for example + * + * "#67, Aug 1 1997, 22:34:28" + * + * The returned string points into static storage; the caller should + * not modify its value. The value is available to Python code as + * part of the variable "sys.version". + */ +expect inline fun Py_GetBuildInfo(): String? + +/** + * *Part of the Stable ABI.* + * + * Deprecated function which does nothing. + * + * In Python 3.6 and older, this function created the GIL if it didn’t + * exist. + * + * Changed in version 3.9: The function now does nothing. + * + * Changed in version 3.7: This function is now called by + * "Py_Initialize()", so you don’t have to call it yourself anymore. + * + * Changed in version 3.2: This function cannot be called before + * "Py_Initialize()" anymore. + * + * Deprecated since version 3.9. + * + * 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, + * 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. + */ +@Deprecated( + "Does nothing since CPython 3.9, where it also became Py_DEPRECATED. Py_Initialize has " + + "created the GIL by itself since 3.7; delete the call.", + level = DeprecationLevel.WARNING, +) +fun PyEval_InitThreads() { + // Intentionally empty: this mirrors the CPython 3.9+ implementation exactly. +} + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * Return a dictionary in which extensions can store thread-specific + * state information. Each extension should use a unique key to use + * to store state in the dictionary. It is okay to call this function + * when no current thread state is available. If this function returns + * "NULL", no exception has been raised and the caller should assume + * no current thread state is available. + */ +expect fun PyThreadState_GetDict(): NativePointer? + +/** + * Thread-state and GIL management bindings + */ +expect inline fun PyGILState_Ensure(): Int +expect inline fun PyGILState_Release(state: Int) +expect fun PyGILState_GetThisThreadState(): NativePointer? +expect fun PyEval_SaveThread(): NativePointer? +expect inline fun PyEval_RestoreThread(tstate: NativePointer) + +/** + * Runs the interpreter's pending-call queue -- and, on the main thread of the main interpreter, + * pending signal handlers -- on the calling thread. + * + * Part of the Stable ABI. Note what it does *not* do. `Py_MakePendingCalls` covers only the + * `_PY_CALLS_TO_DO_BIT` and `_PY_SIGNALS_PENDING_BIT` work items, while a full eval-loop + * checkpoint (`_Py_HandlePending`) additionally merges the free-threaded build's biased + * reference-counting queue, processes QSBR-deferred frees and runs a scheduled cyclic + * collection. So this is *not* a way to reclaim memory that another thread released; see + * [python.multiplatform.ffi.Python3.drainPendingReleases] for that. + * + * Returns 0 on success and -1 with the error indicator set otherwise. Called from a thread that + * is not the main thread of the main interpreter it returns 0 having done nothing. + */ +expect fun Py_MakePendingCalls(): Int + +/** + * Runs a full cyclic garbage collection: the C equivalent of `gc.collect()`. Part of the Stable ABI. + * + * On a free-threaded build this stops the world and merges *every* thread's deferred + * reference-count queue, so unlike an eval-loop checkpoint it can reclaim references that were + * released on behalf of some thread other than the caller. It is correspondingly expensive: the + * cost is proportional to the size of the heap, not to the amount of queued work. + * + * Returns the number of unreachable objects collected. + */ +expect fun PyGC_Collect(): Long + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 2 +// The Very High Level Layer +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * + * This is a simplified interface to "PyRun_SimpleStringFlags()" + * below, leaving the "PyCompilerFlags"* argument set to "NULL". + */ +expect inline fun PyRun_SimpleString(command: String): Int // 수동 추가 + +/** + * *Return value: New reference.* + * + * This is a simplified interface to "PyRun_StringFlags()" below, + * leaving *flags* set to "NULL". + */ +expect fun PyRun_String(str: String, start: Int, globals: NativePointer, locals: NativePointer): NativePointer? // 수동 추가 + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * This is a simplified interface to "Py_CompileStringFlags()" below, + * leaving *flags* set to "NULL". + */ +expect fun Py_CompileString(str: String, filename: String, start: Int): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * This is a simplified interface to "PyEval_EvalCodeEx()", with just + * the code object, and global and local variables. The other + * arguments are set to "NULL". + */ +expect fun PyEval_EvalCode(co: NativePointer, globals: NativePointer, locals: NativePointer): NativePointer? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 3 +// Exception Handling +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Part of the Stable ABI.* + * + * Clear the error indicator. If the error indicator is not set, + * there is no effect. + */ +expect inline fun PyErr_Clear() + +/** + * *Part of the Stable ABI.* + * + * Print a standard traceback to "sys.stderr" and clear the error + * indicator. **Unless** the error is a "SystemExit", in that case no + * traceback is printed and the Python process will exit with the + * error code specified by the "SystemExit" instance. + * + * Call this function **only** when the error indicator is set. + * Otherwise it will cause a fatal error! + * + * If *set_sys_last_vars* is nonzero, the variable "sys.last_exc" is + * set to the printed exception. For backwards compatibility, the + * deprecated variables "sys.last_type", "sys.last_value" and + * "sys.last_traceback" are also set to the type, value and traceback + * of this exception, respectively. + * + * Changed in version 3.12: The setting of "sys.last_exc" was added. + */ +expect inline fun PyErr_PrintEx(set_sys_last_vars: Int) + +/** + * *Part of the Stable ABI.* + * + * Alias for "PyErr_PrintEx(1)". + */ +expect inline fun PyErr_Print() + +/** + * *Part of the Stable ABI.* + * + * Call "sys.unraisablehook()" using the current exception and *obj* + * argument. + * + * This utility function prints a warning message to "sys.stderr" when + * an exception has been set but it is impossible for the interpreter + * to actually raise the exception. It is used, for example, when an + * exception occurs in an "__del__()" method. + * + * The function is called with a single argument *obj* that identifies + * the context in which the unraisable exception occurred. If + * possible, the repr of *obj* will be printed in the warning message. + * If *obj* is "NULL", only the traceback is printed. + * + * An exception must be set when calling this function. + * + * Changed in version 3.4: Print a traceback. Print only traceback if + * *obj* is "NULL". + * + * Changed in version 3.8: Use "sys.unraisablehook()". + */ +expect inline fun PyErr_WriteUnraisable(obj: NativePointer) + +/** + * *Part of the Stable ABI since version 3.12.* + * + * Print the standard traceback display of "exc" to "sys.stderr", + * including chained exceptions and notes. + * + * Added in version 3.12. + */ +expect inline fun PyErr_DisplayException(exc: NativePointer) + +/** + * *Part of the Stable ABI.* + * + * This is the most common way to set the error indicator. The first + * argument specifies the exception type; it is normally one of the + * standard exceptions, e.g. "PyExc_RuntimeError". You need not + * create a new *strong reference* to it (e.g. with "Py_INCREF()"). + * The second argument is an error message; it is decoded from + * "'utf-8'". + */ +expect inline fun PyErr_SetString(type: NativePointer, message: String) + +/** + * *Part of the Stable ABI.* + * + * This function is similar to "PyErr_SetString()" but lets you + * specify an arbitrary Python object for the “value” of the + * exception. + */ +expect inline fun PyErr_SetObject(type: NativePointer, value: NativePointer) + +/** + * *Part of the Stable ABI.* + * + * This is a shorthand for "PyErr_SetObject(type, Py_None)". + */ +expect inline fun PyErr_SetNone(type: NativePointer) + +/** + * *Part of the Stable ABI.* + * + * This is a shorthand for "PyErr_SetString(PyExc_TypeError, + * message)", where *message* indicates that a built-in operation was + * invoked with an illegal argument. It is mostly for internal use. + */ +expect inline fun PyErr_BadArgument(): Int + +/** + * *Return value: Always NULL.* + * *Part of the Stable ABI.* + * + * This is a shorthand for "PyErr_SetNone(PyExc_MemoryError)"; it + * returns "NULL" so an object allocation function can write "return + * PyErr_NoMemory();" when it runs out of memory. + */ +expect fun PyErr_NoMemory(): NativePointer? + +/** + * *Return value: Always NULL.* + * *Part of the Stable ABI.* + * + * This is a convenience function to raise an exception when a C + * library function has returned an error and set the C variable + * "errno". It constructs a tuple object whose first item is the + * integer "errno" value and whose second item is the corresponding + * error message (gotten from "strerror()"), and then calls + * "PyErr_SetObject(type, object)". On Unix, when the "errno" value + * is "EINTR", indicating an interrupted system call, this calls + * "PyErr_CheckSignals()", and if that set the error indicator, leaves + * it set to that. The function always returns "NULL", so a wrapper + * function around a system call can write "return + * PyErr_SetFromErrno(type);" when the system call returns an error. + */ +expect fun PyErr_SetFromErrno(type: NativePointer): NativePointer? + +/** + * *Return value: Always NULL.* + * *Part of the Stable ABI.* + * + * Similar to "PyErr_SetFromErrno()", with the additional behavior + * that if *filenameObject* is not "NULL", it is passed to the + * constructor of *type* as a third parameter. In the case of + * "OSError" exception, this is used to define the "filename" + * attribute of the exception instance. + */ +expect fun PyErr_SetFromErrnoWithFilenameObject(type: NativePointer, filenameObject: NativePointer): NativePointer? + +/** + * *Return value: Always NULL.* + * *Part of the Stable ABI since version 3.7.* + * + * Similar to "PyErr_SetFromErrnoWithFilenameObject()", but takes a + * second filename object, for raising errors when a function that + * takes two filenames fails. + * + * Added in version 3.4. + */ +expect fun PyErr_SetFromErrnoWithFilenameObjects(type: NativePointer, filenameObject: NativePointer, filenameObject2: NativePointer): NativePointer? + +/** + * *Return value: Always NULL.* + * *Part of the Stable ABI.* + * + * Similar to "PyErr_SetFromErrnoWithFilenameObject()", but the + * filename is given as a C string. *filename* is decoded from the + * *filesystem encoding and error handler*. + */ +expect fun PyErr_SetFromErrnoWithFilename(type: NativePointer, filename: String): NativePointer? + +/** + * *Return value: Always NULL.* + * *Part of the Stable ABI since version 3.7.* + * + * This is a convenience function to raise "ImportError". *msg* will + * be set as the exception’s message string. *name* and *path*, both + * of which can be "NULL", will be set as the "ImportError"’s + * respective "name" and "path" attributes. + * + * Added in version 3.3. + */ +expect fun PyErr_SetImportError(msg: NativePointer, name: NativePointer, path: NativePointer): NativePointer? + +/** + * *Return value: Always NULL.* + * *Part of the Stable ABI since version 3.6.* + * + * Much like "PyErr_SetImportError()" but this function allows for + * specifying a subclass of "ImportError" to raise. + * + * Added in version 3.6. + */ +expect fun PyErr_SetImportErrorSubclass(exception: NativePointer, msg: NativePointer, name: NativePointer, path: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI since version 3.7.* + * + * Like "PyErr_SyntaxLocationObject()", but *filename* is a byte + * string decoded from the *filesystem encoding and error handler*. + * + * Added in version 3.2. + */ +expect inline fun PyErr_SyntaxLocationEx(filename: String, lineno: Int, col_offset: Int) + +/** + * *Part of the Stable ABI.* + * + * Like "PyErr_SyntaxLocationEx()", but the *col_offset* parameter is + * omitted. + */ +expect inline fun PyErr_SyntaxLocation(filename: String, lineno: Int) + +/** + * *Part of the Stable ABI.* + * + * This is a shorthand for "PyErr_SetString(PyExc_SystemError, + * message)", where *message* indicates that an internal operation + * (e.g. a Python/C API function) was invoked with an illegal + * argument. It is mostly for internal use. + */ +expect inline fun PyErr_BadInternalCall() + +/** + * *Part of the Stable ABI.* + * + * Similar to "PyErr_WarnExplicitObject()" except that *message* and + * *module* are UTF-8 encoded strings, and *filename* is decoded from + * the *filesystem encoding and error handler*. + */ +expect inline fun PyErr_WarnExplicit(category: NativePointer, message: String, filename: String, lineno: Int, module: String, registry: NativePointer): Int + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * Test whether the error indicator is set. If set, return the + * exception *type* (the first argument to the last call to one of the + * "PyErr_Set*" functions or to "PyErr_Restore()"). If not set, + * return "NULL". You do not own a reference to the return value, so + * you do not need to "Py_DECREF()" it. + * + * The caller must hold the GIL. + * + * Note: + * + * Do not compare the return value to a specific exception; use + * "PyErr_ExceptionMatches()" instead, shown below. (The comparison + * could easily fail since the exception may be an instance instead + * of a class, in the case of a class exception, or it may be a + * subclass of the expected exception.) + */ +expect fun PyErr_Occurred(): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Equivalent to "PyErr_GivenExceptionMatches(PyErr_Occurred(), exc)". + * This should only be called when an exception is actually set; a + * memory access violation will occur if no exception has been raised. + */ +expect inline fun PyErr_ExceptionMatches(exc: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Return true if the *given* exception matches the exception type in + * *exc*. If *exc* is a class object, this also returns true when + * *given* is an instance of a subclass. If *exc* is a tuple, all + * exception types in the tuple (and recursively in subtuples) are + * searched for a match. + */ +expect inline fun PyErr_GivenExceptionMatches(given: NativePointer, exc: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.12.* + * + * Return the exception currently being raised, clearing the error + * indicator at the same time. Return "NULL" if the error indicator is + * not set. + * + * This function is used by code that needs to catch exceptions, or + * code that needs to save and restore the error indicator + * temporarily. + * + * For example: + * + * { + * PyObject *exc = PyErr_GetRaisedException(); + * + * /* ... code that might produce other errors ... */ + * + * PyErr_SetRaisedException(exc); + * } + * + * See also: + * + * "PyErr_GetHandledException()", to save the exception currently + * being handled. + * + * Added in version 3.12. + */ +expect fun PyErr_GetRaisedException(): NativePointer? + +/** + * *Part of the Stable ABI since version 3.12.* + * + * Set *exc* as the exception currently being raised, clearing the + * existing exception if one is set. + * + * Warning: + * + * This call steals a reference to *exc*, which must be a valid + * exception. + * + * Added in version 3.12. + */ +expect inline fun PyErr_SetRaisedException(exc: NativePointer) + +/** + * *Part of the Stable ABI.* + * + * Deprecated since version 3.12: Use "PyErr_SetRaisedException()" + * instead. + * + * Set the error indicator from the three objects, *type*, *value*, + * and *traceback*, clearing the existing exception if one is set. If + * the objects are "NULL", the error indicator is cleared. Do not + * pass a "NULL" type and non-"NULL" value or traceback. The + * exception type should be a class. Do not pass an invalid exception + * type or value. (Violating these rules will cause subtle problems + * later.) This call takes away a reference to each object: you must + * own a reference to each object before the call and after the call + * you no longer own these references. (If you don’t understand this, + * don’t use this function. I warned you.) + * + * Note: + * + * This function is normally only used by legacy code that needs to + * save and restore the error indicator temporarily. Use + * "PyErr_Fetch()" to save the current error indicator. + * + * Kept in the surface rather than deleted, because [PyErr_SetRaisedException] is not an alias for + * it: that one takes a single, already-normalized exception instance, while this takes the + * unnormalized `(type, value, traceback)` triple and normalizes lazily. Nothing in Kotlin can + * forward one to the other without inventing the normalization, so removing this would remove a + * capability rather than a duplicate. CPython names no removal version for it either. + */ +@Deprecated( + "Deprecated by CPython 3.12 in favour of PyErr_SetRaisedException, which takes one " + + "normalized exception instead of the (type, value, traceback) triple. Not a mechanical " + + "substitution: this call steals a reference to each of the three arguments.", + level = DeprecationLevel.WARNING, +) +expect inline fun PyErr_Restore(type: NativePointer, value: NativePointer, traceback: NativePointer) + +/** + * *Part of the Stable ABI since version 3.11.* + * + * Retrieve the active exception instance, as would be returned by + * "sys.exception()". This refers to an exception that was *already + * caught*, not to an exception that was freshly raised. Returns a new + * reference to the exception or "NULL". Does not modify the + * interpreter’s exception state. + * + * Note: + * + * This function is not normally used by code that wants to handle + * exceptions. Rather, it can be used when code needs to save and + * restore the exception state temporarily. Use + * "PyErr_SetHandledException()" to restore or clear the exception + * state. + * + * Added in version 3.11. + */ +expect fun PyErr_GetHandledException(): NativePointer? + +/** + * *Part of the Stable ABI since version 3.11.* + * + * Set the active exception, as known from "sys.exception()". This + * refers to an exception that was *already caught*, not to an + * exception that was freshly raised. To clear the exception state, + * pass "NULL". + * + * Note: + * + * This function is not normally used by code that wants to handle + * exceptions. Rather, it can be used when code needs to save and + * restore the exception state temporarily. Use + * "PyErr_GetHandledException()" to get the exception state. + * + * Added in version 3.11. + */ +expect inline fun PyErr_SetHandledException(exc: NativePointer) + +/** + * *Part of the Stable ABI since version 3.7.* + * + * Set the exception info, as known from "sys.exc_info()". This + * refers to an exception that was *already caught*, not to an + * exception that was freshly raised. This function steals the + * references of the arguments. To clear the exception state, pass + * "NULL" for all three arguments. This function is kept for backwards + * compatibility. Prefer using "PyErr_SetHandledException()". + * + * Note: + * + * This function is not normally used by code that wants to handle + * exceptions. Rather, it can be used when code needs to save and + * restore the exception state temporarily. Use + * "PyErr_GetExcInfo()" to read the exception state. + * + * Added in version 3.3. + * + * Changed in version 3.11: The "type" and "traceback" arguments are + * no longer used and can be NULL. The interpreter now derives them + * from the exception instance (the "value" argument). The function + * still steals references of all three arguments. + */ +expect inline fun PyErr_SetExcInfo(type: NativePointer, value: NativePointer, traceback: NativePointer) + +/** + * *Part of the Stable ABI.* + * + * This function interacts with Python’s signal handling. + * + * If the function is called from the main thread and under the main + * Python interpreter, it checks whether a signal has been sent to the + * processes and if so, invokes the corresponding signal handler. If + * the "signal" module is supported, this can invoke a signal handler + * written in Python. + * + * The function attempts to handle all pending signals, and then + * returns "0". However, if a Python signal handler raises an + * exception, the error indicator is set and the function returns "-1" + * immediately (such that other pending signals may not have been + * handled yet: they will be on the next "PyErr_CheckSignals()" + * invocation). + * + * If the function is called from a non-main thread, or under a non- + * main Python interpreter, it does nothing and returns "0". + * + * This function can be called by long-running C code that wants to be + * interruptible by user requests (such as by pressing Ctrl-C). + * + * Note: + * + * The default Python signal handler for "SIGINT" raises the + * "KeyboardInterrupt" exception. + */ +expect inline fun PyErr_CheckSignals(): Int + +/** + * *Part of the Stable ABI.* + * + * Simulate the effect of a "SIGINT" signal arriving. This is + * equivalent to "PyErr_SetInterruptEx(SIGINT)". + * + * Note: + * + * This function is async-signal-safe. It can be called without the + * *GIL* and from a C signal handler. + */ +expect inline fun PyErr_SetInterrupt() + +/** + * *Part of the Stable ABI since version 3.10.* + * + * Simulate the effect of a signal arriving. The next time + * "PyErr_CheckSignals()" is called, the Python signal handler for + * the given signal number will be called. + * + * This function can be called by C code that sets up its own signal + * handling and wants Python signal handlers to be invoked as expected + * when an interruption is requested (for example when the user + * presses Ctrl-C to interrupt an operation). + * + * If the given signal isn’t handled by Python (it was set to + * "signal.SIG_DFL" or "signal.SIG_IGN"), it will be ignored. + * + * If *signum* is outside of the allowed range of signal numbers, "-1" + * is returned. Otherwise, "0" is returned. The error indicator is + * never changed by this function. + * + * Note: + * + * This function is async-signal-safe. It can be called without the + * *GIL* and from a C signal handler. + * + * Added in version 3.10. + */ +expect inline fun PyErr_SetInterruptEx(signum: Int): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * This utility function creates and returns a new exception class. + * The *name* argument must be the name of the new exception, a C + * string of the form "module.classname". The *base* and *dict* + * arguments are normally "NULL". This creates a class object derived + * from "Exception" (accessible in C as "PyExc_Exception"). + * + * The "__module__" attribute of the new class is set to the first + * part (up to the last dot) of the *name* argument, and the class + * name is set to the last part (after the last dot). The *base* + * argument can be used to specify alternate base classes; it can + * either be only one class or a tuple of classes. The *dict* argument + * can be used to specify a dictionary of class variables and methods. + */ +expect fun PyErr_NewException(name: String, base: NativePointer, dict: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Same as "PyErr_NewException()", except that the new exception class + * can easily be given a docstring: If *doc* is non-"NULL", it will be + * used as the docstring for the exception class. + * + * Added in version 3.2. + */ +expect fun PyErr_NewExceptionWithDoc(name: String, doc: String, base: NativePointer, dict: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the traceback associated with the exception as a new + * reference, as accessible from Python through the "__traceback__" + * attribute. If there is no traceback associated, this returns + * "NULL". + */ +expect fun PyException_GetTraceback(ex: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Set the traceback associated with the exception to *tb*. Use + * "Py_None" to clear it. + */ +expect inline fun PyException_SetTraceback(ex: NativePointer, tb: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the context (another exception instance during whose + * handling *ex* was raised) associated with the exception as a new + * reference, as accessible from Python through the "__context__" + * attribute. If there is no context associated, this returns "NULL". + */ +expect fun PyException_GetContext(ex: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Set the context associated with the exception to *ctx*. Use "NULL" + * to clear it. There is no type check to make sure that *ctx* is an + * exception instance. This steals a reference to *ctx*. + */ +expect inline fun PyException_SetContext(ex: NativePointer, ctx: NativePointer) + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the cause (either an exception instance, or "None", set by + * "raise ... from ...") associated with the exception as a new + * reference, as accessible from Python through the "__cause__" + * attribute. + */ +expect fun PyException_GetCause(ex: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Set the cause associated with the exception to *cause*. Use "NULL" + * to clear it. There is no type check to make sure that *cause* is + * either an exception instance or "None". This steals a reference to + * *cause*. + * + * The "__suppress_context__" attribute is implicitly set to "True" by + * this function. + */ +expect inline fun PyException_SetCause(ex: NativePointer, cause: NativePointer) + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.12.* + * + * Return "args" of exception *ex*. + */ +expect fun PyException_GetArgs(ex: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI since version 3.12.* + * + * Set "args" of exception *ex* to *args*. + */ +expect inline fun PyException_SetArgs(ex: NativePointer, args: NativePointer) + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the *encoding* attribute of the given exception object. + */ +expect fun PyUnicodeEncodeError_GetEncoding(exc: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the *object* attribute of the given exception object. + */ +expect fun PyUnicodeTranslateError_GetObject(exc: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the *reason* attribute of the given exception object. + */ +expect fun PyUnicodeTranslateError_GetReason(exc: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Set the *reason* attribute of the given exception object to + * *reason*. Return "0" on success, "-1" on failure. + */ +expect inline fun PyUnicodeTranslateError_SetReason(exc: NativePointer, reason: String): Int + +/** + * *Part of the Stable ABI since version 3.9.* + * + * Marks a point where a recursive C-level call is about to be + * performed. + * + * If "USE_STACKCHECK" is defined, this function checks if the OS + * stack overflowed using "PyOS_CheckStack()". If this is the case, + * it sets a "MemoryError" and returns a nonzero value. + * + * The function then checks if the recursion limit is reached. If + * this is the case, a "RecursionError" is set and a nonzero value is + * returned. Otherwise, zero is returned. + * + * *where* should be a UTF-8 encoded string such as "" in instance + * check"" to be concatenated to the "RecursionError" message caused + * by the recursion depth limit. + * + * Changed in version 3.9: This function is now also available in the + * limited API. + */ +expect inline fun Py_EnterRecursiveCall(where: String): Int + +/** + * *Part of the Stable ABI since version 3.9.* + * + * Ends a "Py_EnterRecursiveCall()". Must be called once for each + * *successful* invocation of "Py_EnterRecursiveCall()". + * + * Changed in version 3.9: This function is now also available in the + * limited API. + */ +expect inline fun Py_LeaveRecursiveCall() + +/** + * *Part of the Stable ABI.* + * + * Called at the beginning of the "tp_repr" implementation to detect + * cycles. + * + * If the object has already been processed, the function returns a + * positive integer. In that case the "tp_repr" implementation should + * return a string object indicating a cycle. As examples, "dict" + * objects return "{...}" and "list" objects return "[...]". + * + * The function will return a negative integer if the recursion limit + * is reached. In that case the "tp_repr" implementation should + * typically return "NULL". + * + * Otherwise, the function returns zero and the "tp_repr" + * implementation can continue normally. + */ +expect inline fun Py_ReprEnter(o: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Ends a "Py_ReprEnter()". Must be called once for each invocation + */ +expect inline fun Py_ReprLeave(o: NativePointer) + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 4 +// Reference Counting +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Part of the Stable ABI since version 3.10.* + * + * Create a new *strong reference* to an object: call "Py_INCREF()" on + * *o* and return the object *o*. + * + * When the *strong reference* is no longer needed, "Py_DECREF()" + * should be called on it to release the reference. + * + * The object *o* must not be "NULL"; use "Py_XNewRef()" if *o* can be + * "NULL". + * + * For example: + * + * Py_INCREF(obj); + * self->attr = obj; + * + * can be written as: + * + * self->attr = Py_NewRef(obj); + * + * See also "Py_INCREF()". + * + * Added in version 3.10. + */ +expect fun Py_NewRef(o: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI since version 3.10.* + * + * Similar to "Py_NewRef()", but the object *o* can be NULL. + * + * If the object *o* is "NULL", the function just returns "NULL". + * + * Added in version 3.10. + */ +expect fun Py_XNewRef(o: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Indicate taking a new *strong reference* to object *o*. A function + * version of "Py_XINCREF()". It can be used for runtime dynamic + * embedding of Python. + */ +expect inline fun Py_IncRef(o: NativePointer) + +/** + * *Part of the Stable ABI.* + * + * Release a *strong reference* to object *o*. A function version of + * "Py_XDECREF()". It can be used for runtime dynamic embedding of + */ +expect inline fun Py_DecRef(o: NativePointer) + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 5 +// Operating System Utilities +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.6.* + * + * Return the file system representation for *path*. If the object is + * a "str" or "bytes" object, then a new *strong reference* is + * returned. If the object implements the "os.PathLike" interface, + * then "__fspath__()" is returned as long as it is a "str" or "bytes" + * object. Otherwise "TypeError" is raised and "NULL" is returned. + * + * Added in version 3.6. + */ +expect fun PyOS_FSPath(path: NativePointer): NativePointer? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 6 +// System Functions +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * Return the object *name* from the "sys" module or "NULL" if it does + * not exist, without setting an exception. + */ +expect fun PySys_GetObject(name: String): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Set *name* in the "sys" module to *v* unless *v* is "NULL", in + * which case *name* is deleted from the sys module. Returns "0" on + * success, "-1" on error. + */ +expect inline fun PySys_SetObject(name: String, v: NativePointer): Int + +// `PySys_ResetWarnOptions` was bound here until CPython 3.15 removed it. It has no replacement +// in the C API: the documented migration is to configure `sys.warnoptions` through `PyConfig` +// before initialisation, or to clear `sys.warnoptions` and `warnings.filters` from Python. +// The symbol is still *exported* by the 3.15 shared library and only gone from the headers, so +// desktop (which resolves by name at run time) never noticed; cinterop and the hand-written C +// in `jni_onload.def` compile against the headers, so the native targets did. See ROADMAP §9. + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI since version 3.7.* + * + * Return the current dictionary of "-X" options, similarly to + * "sys._xoptions". On error, "NULL" is returned and an exception is + * set. + * + * Added in version 3.2. + */ +expect fun PySys_GetXOptions(): NativePointer? + +/** + * *Part of the Stable ABI since version 3.13.* + * + * Similar to "PySys_Audit()", but pass arguments as a Python object. + * *args* must be a "tuple". To pass no arguments, *args* can be + * *NULL*. + * + * Added in version 3.13. + */ +expect inline fun PySys_AuditTuple(event: String, args: NativePointer): Int + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 7 +// Process Control +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Part of the Stable ABI.* + * + * Print a fatal error message and kill the process. No cleanup is + * performed. This function should only be invoked when a condition is + * detected that would make it dangerous to continue using the Python + * interpreter; e.g., when the object administration appears to be + * corrupted. On Unix, the standard C library function "abort()" is + * called which will attempt to produce a "core" file. + * + * The "Py_FatalError()" function is replaced with a macro which logs + * automatically the name of the current function, unless the + * "Py_LIMITED_API" macro is defined. + * + * Changed in version 3.9: Log the function name automatically. + */ +expect inline fun Py_FatalError(message: String) + +/** + * *Part of the Stable ABI.* + * + * Exit the current process. This calls "Py_FinalizeEx()" and then + * calls the standard C library function "exit(status)". If + * "Py_FinalizeEx()" indicates an error, the exit status is set to + * 120. + * + * Changed in version 3.6: Errors from finalization no longer ignored. + */ +expect inline fun Py_Exit(status: Int) + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 8 +// Importing Modules +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * This is a wrapper around "PyImport_Import()" which takes a const + * char* as an argument instead of a PyObject*. + */ +expect fun PyImport_ImportModule(name: String): NativePointer? + +/** + * *Return value: New reference.* + * + * A deprecated alias of [PyImport_ImportModule]. + * + * Changed in version 3.3: This function used to fail immediately when + * the import lock was held by another thread. In Python 3.3 though, + * the locking scheme switched to per-module locks for most purposes, + * so this function’s special behaviour isn’t needed anymore. + * + * Deprecated since version 3.13, removed in version 3.15: use + * [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 + * 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. + */ +@Deprecated( + "Removed in CPython 3.15; it has been an exact alias of PyImport_ImportModule since 3.3.", + ReplaceWith("PyImport_ImportModule(name)") +) +fun PyImport_ImportModuleNoBlock(name: String): NativePointer? = PyImport_ImportModule(name) + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.7.* + * + * Import a module. This is best described by referring to the built- + * in Python function "__import__()", as the standard "__import__()" + * function calls this function directly. + * + * The return value is a new reference to the imported module or top- + * level package, or "NULL" with an exception set on failure. Like + * for "__import__()", the return value when a submodule of a package + * was requested is normally the top-level package, unless a non-empty + * *fromlist* was given. + * + * Added in version 3.3. + */ +expect fun PyImport_ImportModuleLevelObject(name: NativePointer, globals: NativePointer, locals: NativePointer, fromlist: NativePointer, level: Int): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Similar to "PyImport_ImportModuleLevelObject()", but the name is a + * UTF-8 encoded string instead of a Unicode object. + * + * Changed in version 3.3: Negative values for *level* are no longer + * accepted. + */ +expect fun PyImport_ImportModuleLevel(name: String, globals: NativePointer, locals: NativePointer, fromlist: NativePointer, level: Int): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * This is a higher-level interface that calls the current “import + * hook function” (with an explicit *level* of 0, meaning absolute + * import). It invokes the "__import__()" function from the + * "__builtins__" of the current globals. This means that the import + * is done using whatever import hooks are installed in the current + * environment. + * + * This function always uses absolute imports. + */ +expect fun PyImport_Import(name: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Reload a module. Return a new reference to the reloaded module, or + * "NULL" with an exception set on failure (the module still exists in + * this case). + */ +expect fun PyImport_ReloadModule(m: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.13.* + * + * Return the module object corresponding to a module name. + * + * The *name* argument may be of the form "package.module". First + * check the modules dictionary if there’s one there, and if not, + * create a new one and insert it in the modules dictionary. + * + * Return a *strong reference* to the module on success. Return "NULL" + * with an exception set on failure. + * + * The module name *name* is decoded from UTF-8. + * + * This function does not load or import the module; if the module + * wasn’t already loaded, you will get an empty module object. Use + * "PyImport_ImportModule()" or one of its variants to import a + * module. Package structures implied by a dotted name for *name* are + * not created if not already present. + * + * Added in version 3.13. + */ +expect fun PyImport_AddModuleRef(name: String): NativePointer? + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI since version 3.7.* + * + * Similar to "PyImport_AddModuleRef()", but return a *borrowed + * reference* and *name* is a Python "str" object. + * + * Added in version 3.3. + */ +expect fun PyImport_AddModuleObject(name: NativePointer): NativePointer? + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * Similar to "PyImport_AddModuleRef()", but return a *borrowed + * reference*. + */ +expect fun PyImport_AddModule(name: String): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Given a module name (possibly of the form "package.module") and a + * code object read from a Python bytecode file or obtained from the + * built-in function "compile()", load the module. Return a new + * reference to the module object, or "NULL" with an exception set if + * an error occurred. *name* is removed from "sys.modules" in error + * cases, even if *name* was already in "sys.modules" on entry to + * "PyImport_ExecCodeModule()". Leaving incompletely initialized + * modules in "sys.modules" is dangerous, as imports of such modules + * have no way to know that the module object is an unknown (and + * probably damaged with respect to the module author’s intents) + * state. + * + * The module’s "__spec__" and "__loader__" will be set, if not set + * already, with the appropriate values. The spec’s loader will be + * set to the module’s "__loader__" (if set) and to an instance of + * "SourceFileLoader" otherwise. + * + * The module’s "__file__" attribute will be set to the code object’s + * "co_filename". If applicable, "__cached__" will also be set. + * + * This function will reload the module if it was already imported. + * See "PyImport_ReloadModule()" for the intended way to reload a + * module. + * + * If *name* points to a dotted name of the form "package.module", any + * package structures not already created will still not be created. + * + * See also "PyImport_ExecCodeModuleEx()" and + * "PyImport_ExecCodeModuleWithPathnames()". + * + * Changed in version 3.12: The setting of "__cached__" and + * "__loader__" is deprecated. See "ModuleSpec" for alternatives. + */ +expect fun PyImport_ExecCodeModule(name: String, co: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Like "PyImport_ExecCodeModule()", but the "__file__" attribute of + * the module object is set to *pathname* if it is non-"NULL". + * + * See also "PyImport_ExecCodeModuleWithPathnames()". + */ +expect fun PyImport_ExecCodeModuleEx(name: String, co: NativePointer, pathname: String): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.7.* + * + * Like "PyImport_ExecCodeModuleEx()", but the "__cached__" attribute + * of the module object is set to *cpathname* if it is non-"NULL". Of + * the three functions, this is the preferred one to use. + * + * Added in version 3.3. + * + * Changed in version 3.12: Setting "__cached__" is deprecated. See + * "ModuleSpec" for alternatives. + */ +expect fun PyImport_ExecCodeModuleObject(name: NativePointer, co: NativePointer, pathname: NativePointer, cpathname: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Like "PyImport_ExecCodeModuleObject()", but *name*, *pathname* and + * *cpathname* are UTF-8 encoded strings. Attempts are also made to + * figure out what the value for *pathname* should be from *cpathname* + * if the former is set to "NULL". + * + * Added in version 3.2. + * + * Changed in version 3.3: Uses "imp.source_from_cache()" in + * calculating the source path if only the bytecode path is provided. + * + * Changed in version 3.12: No longer uses the removed "imp" module. + */ +expect fun PyImport_ExecCodeModuleWithPathnames(name: String, co: NativePointer, pathname: String, cpathname: String): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Return the magic tag string for **PEP 3147** format Python bytecode + * file names. Keep in mind that the value at + * "sys.implementation.cache_tag" is authoritative and should be used + * instead of this function. + * + * Added in version 3.2. + */ +expect inline fun PyImport_GetMagicTag(): String? + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * Return the dictionary used for the module administration (a.k.a. + * "sys.modules"). Note that this is a per-interpreter variable. + */ +expect fun PyImport_GetModuleDict(): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.8.* + * + * Return the already imported module with the given name. If the + * module has not been imported yet then returns "NULL" but does not + * set an error. Returns "NULL" and sets an error if the lookup + * failed. + * + * Added in version 3.7. + */ +expect fun PyImport_GetModule(name: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a finder object for a "sys.path"/"pkg.__path__" item *path*, + * possibly by fetching it from the "sys.path_importer_cache" dict. + * If it wasn’t yet cached, traverse "sys.path_hooks" until a hook is + * found that can handle the path item. Return "None" if no hook + * could; this tells our caller that the *path based finder* could not + * find a finder for this path item. Cache the result in + * "sys.path_importer_cache". Return a new reference to the finder + * object. + */ +expect fun PyImport_GetImporter(path: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI since version 3.7.* + * + * Load a frozen module named *name*. Return "1" for success, "0" if + * 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 + * imported.) + * + * Added in version 3.3. + * + * Changed in version 3.4: The "__file__" attribute is no longer set + * on the module. + */ +expect inline fun PyImport_ImportFrozenModuleObject(name: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Similar to "PyImport_ImportFrozenModuleObject()", but the name is a + * UTF-8 encoded string instead of a Unicode object. + */ +expect inline fun PyImport_ImportFrozenModule(name: String): Int + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 9 +// Reflection +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * Deprecated since version 3.13: Use "PyEval_GetFrameBuiltins()" + * instead. + * + * Return a dictionary of the builtins in the current execution frame, + * or the interpreter of the thread state if no frame is currently + * executing. + * + * Kept in the surface rather than deleted, because PyEval_GetFrameBuiltins is **not** a drop-in replacement: this + * function returns a *borrowed* reference and PyEval_GetFrameBuiltins returns a *new* one. Substituting it without + * also adding the matching decref moves ownership silently, and this repository has already had a + * heap corrupted once by a borrowed pointer wrapped as owned. The migration is a real change at + * each call site, so the declaration stays and carries this warning until those call sites move. + */ +@Deprecated( + "Deprecated by CPython 3.13 in favour of PyEval_GetFrameBuiltins. Do NOT substitute blindly: this returns a " + + "BORROWED reference and PyEval_GetFrameBuiltins returns a NEW one, so the replacement needs a decref " + + "this one must not have.", + level = DeprecationLevel.WARNING, +) +expect fun PyEval_GetBuiltins(): NativePointer? + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * Deprecated since version 3.13: Use either "PyEval_GetFrameLocals()" + * to obtain the same behaviour as calling "locals()" in Python code, + * or else call "PyFrame_GetLocals()" on the result of + * "PyEval_GetFrame()" to access the "f_locals" attribute of the + * currently executing frame. + * + * Return a mapping providing access to the local variables in the + * current execution frame, or "NULL" if no frame is currently + * executing. + * + * Refer to "locals()" for details of the mapping returned at + * different scopes. + * + * As this function returns a *borrowed reference*, the dictionary + * returned for *optimized scopes* is cached on the frame object and + * will remain alive as long as the frame object does. Unlike + * "PyEval_GetFrameLocals()" and "locals()", subsequent calls to this + * function in the same frame will update the contents of the cached + * dictionary to reflect changes in the state of the local variables + * rather than returning a new snapshot. + * + * Changed in version 3.13: As part of **PEP 667**, + * "PyFrame_GetLocals()", "locals()", and "FrameType.f_locals" no + * longer make use of the shared cache dictionary. Refer to the What’s + * New entry for additional details. + * + * Kept in the surface rather than deleted, because PyEval_GetFrameLocals is **not** a drop-in replacement: this + * function returns a *borrowed* reference and PyEval_GetFrameLocals returns a *new* one. Substituting it without + * also adding the matching decref moves ownership silently, and this repository has already had a + * heap corrupted once by a borrowed pointer wrapped as owned. The migration is a real change at + * each call site, so the declaration stays and carries this warning until those call sites move. + */ +@Deprecated( + "Deprecated by CPython 3.13 in favour of PyEval_GetFrameLocals. Do NOT substitute blindly: this returns a " + + "BORROWED reference and PyEval_GetFrameLocals returns a NEW one, so the replacement needs a decref " + + "this one must not have.", + level = DeprecationLevel.WARNING, +) +expect fun PyEval_GetLocals(): NativePointer? + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * Deprecated since version 3.13: Use "PyEval_GetFrameGlobals()" + * instead. + * + * Return a dictionary of the global variables in the current + * execution frame, or "NULL" if no frame is currently executing. + * + * Kept in the surface rather than deleted, because PyEval_GetFrameGlobals is **not** a drop-in replacement: this + * function returns a *borrowed* reference and PyEval_GetFrameGlobals returns a *new* one. Substituting it without + * also adding the matching decref moves ownership silently, and this repository has already had a + * heap corrupted once by a borrowed pointer wrapped as owned. The migration is a real change at + * each call site, so the declaration stays and carries this warning until those call sites move. + */ +@Deprecated( + "Deprecated by CPython 3.13 in favour of PyEval_GetFrameGlobals. Do NOT substitute blindly: this returns a " + + "BORROWED reference and PyEval_GetFrameGlobals returns a NEW one, so the replacement needs a decref " + + "this one must not have.", + level = DeprecationLevel.WARNING, +) +expect fun PyEval_GetGlobals(): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.13.* + * + * Return a dictionary of the builtins in the current execution frame, + * or the interpreter of the thread state if no frame is currently + * executing. + * + * Added in version 3.13. + */ +expect fun PyEval_GetFrameBuiltins(): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.13.* + * + * Return a dictionary of the local variables in the current execution + * frame, or "NULL" if no frame is currently executing. Equivalent to + * calling "locals()" in Python code. + * + * To access "f_locals" on the current frame without making an + * independent snapshot in *optimized scopes*, call + * "PyFrame_GetLocals()" on the result of "PyEval_GetFrame()". + * + * Added in version 3.13. + */ +expect fun PyEval_GetFrameLocals(): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.13.* + * + * Return a dictionary of the global variables in the current + * execution frame, or "NULL" if no frame is currently executing. + * Equivalent to calling "globals()" in Python code. + * + * Added in version 3.13. + */ +expect fun PyEval_GetFrameGlobals(): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Return the name of *func* if it is a function, class or instance + * object, else the name of *func*s type. + */ +expect inline fun PyEval_GetFuncName(func: NativePointer): String? + +/** + * *Part of the Stable ABI.* + * + * Return a description string, depending on the type of *func*. + * Return values include “()” for functions and methods, “ + * constructor”, “ instance”, and “ object”. Concatenated with the + * result of "PyEval_GetFuncName()", the result will be a description + */ +expect inline fun PyEval_GetFuncDesc(func: NativePointer): String? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 10 +// Object Protocol +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Part of the Stable ABI since version 3.13.* + * + * Returns "1" if *o* has the attribute *attr_name*, and "0" + * otherwise. This is equivalent to the Python expression "hasattr(o, + * attr_name)". On failure, return "-1". + * + * Added in version 3.13. + */ +expect inline fun PyObject_HasAttrWithError(o: NativePointer, attr_name: NativePointer): Int + +/** + * *Part of the Stable ABI since version 3.13.* + * + * This is the same as "PyObject_HasAttrWithError()", but *attr_name* + * is specified as a const char* UTF-8 encoded bytes string, rather + * than a PyObject*. + * + * Added in version 3.13. + */ +expect inline fun PyObject_HasAttrStringWithError(o: NativePointer, attr_name: String): Int + +/** + * *Part of the Stable ABI.* + * + * Returns "1" if *o* has the attribute *attr_name*, and "0" + * otherwise. This function always succeeds. + * + * Note: + * + * Exceptions that occur when this calls "__getattr__()" and + * "__getattribute__()" methods are silently ignored. For proper + * error handling, use "PyObject_HasAttrWithError()", + * "PyObject_GetOptionalAttr()" or "PyObject_GetAttr()" instead. + */ +expect inline fun PyObject_HasAttr(o: NativePointer, attr_name: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * This is the same as "PyObject_HasAttr()", but *attr_name* is + * specified as a const char* UTF-8 encoded bytes string, rather than + * a PyObject*. + * + * Note: + * + * Exceptions that occur when this calls "__getattr__()" and + * "__getattribute__()" methods or while creating the temporary + * "str" object are silently ignored. For proper error handling, use + * "PyObject_HasAttrStringWithError()", + * "PyObject_GetOptionalAttrString()" or "PyObject_GetAttrString()" + * instead. + */ +expect inline fun PyObject_HasAttrString(o: NativePointer, attr_name: String): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Retrieve an attribute named *attr_name* from object *o*. Returns + * the attribute value on success, or "NULL" on failure. This is the + * equivalent of the Python expression "o.attr_name". + * + * If the missing attribute should not be treated as a failure, you + * can use "PyObject_GetOptionalAttr()" instead. + */ +expect fun PyObject_GetAttr(o: NativePointer, attr_name: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * This is the same as "PyObject_GetAttr()", but *attr_name* is + * specified as a const char* UTF-8 encoded bytes string, rather than + * a PyObject*. + * + * If the missing attribute should not be treated as a failure, you + * can use "PyObject_GetOptionalAttrString()" instead. + */ +expect fun PyObject_GetAttrString(o: NativePointer, attr_name: String): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Generic attribute getter function that is meant to be put into a + * type object’s "tp_getattro" slot. It looks for a descriptor in the + * dictionary of classes in the object’s MRO as well as an attribute + * in the object’s "__dict__" (if present). As outlined in + * Implementing Descriptors, data descriptors take preference over + * instance attributes, while non-data descriptors don’t. Otherwise, + * an "AttributeError" is raised. + */ +expect fun PyObject_GenericGetAttr(o: NativePointer, name: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Set the value of the attribute named *attr_name*, for object *o*, + * to the value *v*. Raise an exception and return "-1" on failure; + * return "0" on success. This is the equivalent of the Python + * statement "o.attr_name = v". + * + * If *v* is "NULL", the attribute is deleted. This behaviour is + * deprecated in favour of using "PyObject_DelAttr()", but there are + * currently no plans to remove it. + */ +expect inline fun PyObject_SetAttr(o: NativePointer, attr_name: NativePointer, v: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * This is the same as "PyObject_SetAttr()", but *attr_name* is + * specified as a const char* UTF-8 encoded bytes string, rather than + * a PyObject*. + * + * If *v* is "NULL", the attribute is deleted, but this feature is + * deprecated in favour of using "PyObject_DelAttrString()". + * + * The number of different attribute names passed to this function + * should be kept small, usually by using a statically allocated + * string as *attr_name*. For attribute names that aren’t known at + * compile time, prefer calling "PyUnicode_FromString()" and + * "PyObject_SetAttr()" directly. For more details, see + * "PyUnicode_InternFromString()", which may be used internally to + * create a key object. + */ +expect inline fun PyObject_SetAttrString(o: NativePointer, attr_name: String, v: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Generic attribute setter and deleter function that is meant to be + * put into a type object’s "tp_setattro" slot. It looks for a data + * descriptor in the dictionary of classes in the object’s MRO, and if + * found it takes preference over setting or deleting the attribute in + * the instance dictionary. Otherwise, the attribute is set or deleted + * in the object’s "__dict__" (if present). On success, "0" is + * returned, otherwise an "AttributeError" is raised and "-1" is + * returned. + */ +expect inline fun PyObject_GenericSetAttr(o: NativePointer, name: NativePointer, value: NativePointer): Int + +/** + * *Part of the Stable ABI since version 3.13.* + * + * Delete attribute named *attr_name*, for object *o*. Returns "-1" on + * failure. This is the equivalent of the Python statement "del + * o.attr_name". + */ +expect inline fun PyObject_DelAttr(o: NativePointer, attr_name: NativePointer): Int + +/** + * *Part of the Stable ABI since version 3.13.* + * + * This is the same as "PyObject_DelAttr()", but *attr_name* is + * specified as a const char* UTF-8 encoded bytes string, rather than + * a PyObject*. + * + * The number of different attribute names passed to this function + * should be kept small, usually by using a statically allocated + * string as *attr_name*. For attribute names that aren’t known at + * compile time, prefer calling "PyUnicode_FromString()" and + * "PyObject_DelAttr()" directly. For more details, see + * "PyUnicode_InternFromString()", which may be used internally to + * create a key object for lookup. + */ +expect inline fun PyObject_DelAttrString(o: NativePointer, attr_name: String): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Compare the values of *o1* and *o2* using the operation specified + * by *opid*, which must be one of "Py_LT", "Py_LE", "Py_EQ", "Py_NE", + * "Py_GT", or "Py_GE", corresponding to "<", "<=", "==", "!=", ">", + * or ">=" respectively. This is the equivalent of the Python + * expression "o1 op o2", where "op" is the operator corresponding to + * *opid*. Returns the value of the comparison on success, or "NULL" + * on failure. + */ +expect fun PyObject_RichCompare(o1: NativePointer, o2: NativePointer, opid: Int): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Compare the values of *o1* and *o2* using the operation specified + * by *opid*, like "PyObject_RichCompare()", but returns "-1" on + * error, "0" if the result is false, "1" otherwise. + */ +expect inline fun PyObject_RichCompareBool(o1: NativePointer, o2: NativePointer, opid: Int): Int + +/** + * *Part of the Stable ABI.* + * + * Format *obj* using *format_spec*. This is equivalent to the Python + * expression "format(obj, format_spec)". + * + * *format_spec* may be "NULL". In this case the call is equivalent to + * "format(obj)". Returns the formatted string on success, "NULL" on + * failure. + */ +expect fun PyObject_Format(obj: NativePointer, format_spec: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Compute a string representation of object *o*. Returns the string + * representation on success, "NULL" on failure. This is the + * equivalent of the Python expression "repr(o)". Called by the + * "repr()" built-in function. + * + * Changed in version 3.4: This function now includes a debug + * assertion to help ensure that it does not silently discard an + * active exception. + */ +expect fun PyObject_Repr(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * As "PyObject_Repr()", compute a string representation of object + * *o*, but escape the non-ASCII characters in the string returned by + * "PyObject_Repr()" with "\x", "\u" or "\U" escapes. This generates + * a string similar to that returned by "PyObject_Repr()" in Python 2. + * Called by the "ascii()" built-in function. + */ +expect fun PyObject_ASCII(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Compute a string representation of object *o*. Returns the string + * representation on success, "NULL" on failure. This is the + * equivalent of the Python expression "str(o)". Called by the + * "str()" built-in function and, therefore, by the "print()" + * function. + * + * Changed in version 3.4: This function now includes a debug + * assertion to help ensure that it does not silently discard an + * active exception. + */ +expect fun PyObject_Str(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Compute a bytes representation of object *o*. "NULL" is returned + * on failure and a bytes object on success. This is equivalent to + * the Python expression "bytes(o)", when *o* is not an integer. + * Unlike "bytes(o)", a TypeError is raised when *o* is an integer + * instead of a zero-initialized bytes object. + */ +expect fun PyObject_Bytes(o: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Return "1" if the class *derived* is identical to or derived from + * the class *cls*, otherwise return "0". In case of an error, return + * "-1". + * + * If *cls* is a tuple, the check will be done against every entry in + * *cls*. The result will be "1" when at least one of the checks + * returns "1", otherwise it will be "0". + * + * If *cls* has a "__subclasscheck__()" method, it will be called to + * determine the subclass status as described in **PEP 3119**. + * Otherwise, *derived* is a subclass of *cls* if it is a direct or + * indirect subclass, i.e. contained in "cls.__mro__". + * + * Normally only class objects, i.e. instances of "type" or a derived + * class, are considered classes. However, objects can override this + * by having a "__bases__" attribute (which must be a tuple of base + * classes). + */ +expect inline fun PyObject_IsSubclass(derived: NativePointer, cls: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Return "1" if *inst* is an instance of the class *cls* or a + * subclass of *cls*, or "0" if not. On error, returns "-1" and sets + * an exception. + * + * If *cls* is a tuple, the check will be done against every entry in + * *cls*. The result will be "1" when at least one of the checks + * returns "1", otherwise it will be "0". + * + * If *cls* has a "__instancecheck__()" method, it will be called to + * determine the subclass status as described in **PEP 3119**. + * Otherwise, *inst* is an instance of *cls* if its class is a + * subclass of *cls*. + * + * An instance *inst* can override what is considered its class by + * having a "__class__" attribute. + * + * An object *cls* can override if it is considered a class, and what + * its base classes are, by having a "__bases__" attribute (which must + * be a tuple of base classes). + */ +expect inline fun PyObject_IsInstance(inst: NativePointer, cls: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Returns "1" if the object *o* is considered to be true, and "0" + * otherwise. This is equivalent to the Python expression "not not o". + * On failure, return "-1". + */ +expect inline fun PyObject_IsTrue(o: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Returns "0" if the object *o* is considered to be true, and "1" + * otherwise. This is equivalent to the Python expression "not o". On + * failure, return "-1". + */ +expect inline fun PyObject_Not(o: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * When *o* is non-"NULL", returns a type object corresponding to the + * object type of object *o*. On failure, raises "SystemError" and + * returns "NULL". This is equivalent to the Python expression + * "type(o)". This function creates a new *strong reference* to the + * return value. There’s really no reason to use this function instead + * of the "Py_TYPE()" function, which returns a pointer of type + * PyTypeObject*, except when a new *strong reference* is needed. + */ +expect fun PyObject_Type(o: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Return the length of object *o*. If the object *o* provides + * both sequence and mapping protocols, the sequence length is + * returned. On error, "-1" is returned. This is the equivalent + * to the Python expression "len(o)". + * + * Note: replaces the `__len__` protocol bridge (`GetAttrString("__len__")` + * + `CallNoArgs`) this project used before this binding existed -- + * one FFI crossing instead of two, with no bound-method object to + * allocate and release. + */ +expect inline fun PyObject_Size(o: NativePointer): Long + +/** + * *Part of the Stable ABI.* + * + * A synonym for "PyObject_Size()", kept for source compatibility + * with older CPython versions that used this name instead. A + * distinct exported symbol at the C level, but behaviourally + * identical to "PyObject_Size()". + */ +expect inline fun PyObject_Length(o: NativePointer): Long + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return element of *o* corresponding to the object *key* or "NULL" + * on failure. This is the equivalent of the Python expression + * "o[key]". + */ +expect fun PyObject_GetItem(o: NativePointer, key: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Map the object *key* to the value *v*. Raise an exception and + * return "-1" on failure; return "0" on success. This is the + * equivalent of the Python statement "o[key] = v". This function + * *does not* steal a reference to *v*. + */ +expect inline fun PyObject_SetItem(o: NativePointer, key: NativePointer, v: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Remove the mapping for the object *key* from the object *o*. + * Return "-1" on failure. This is equivalent to the Python statement + * "del o[key]". + */ +expect inline fun PyObject_DelItem(o: NativePointer, key: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * This is equivalent to the Python expression "dir(o)", returning a + * (possibly empty) list of strings appropriate for the object + * argument, or "NULL" if there was an error. If the argument is + * "NULL", this is like the Python "dir()", returning the names of the + * current locals; in this case, if no execution frame is active then + * "NULL" is returned but "PyErr_Occurred()" will return false. + */ +expect fun PyObject_Dir(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * This is equivalent to the Python expression "iter(o)". It returns a + * new iterator for the object argument, or the object itself if the + * object is already an iterator. Raises "TypeError" and returns + * "NULL" if the object cannot be iterated. + */ +expect fun PyObject_GetIter(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.10.* + * + * This is the equivalent to the Python expression "aiter(o)". Takes + * an "AsyncIterable" object and returns an "AsyncIterator" for it. + * This is typically a new iterator but if the argument is an + * "AsyncIterator", this returns itself. Raises "TypeError" and + * returns "NULL" if the object cannot be iterated. + * + * Added in version 3.10. + */ +expect fun PyObject_GetAIter(o: NativePointer): NativePointer? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 11 +// Call Protocol +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Part of the Stable ABI since version 3.12.* + * + * Call *callable*’s "vectorcallfunc" with positional and keyword + * arguments given in a tuple and dict, respectively. + * + * This is a specialized function, intended to be put in the "tp_call" + * slot or be used in an implementation of "tp_call". It does not + * check the "Py_TPFLAGS_HAVE_VECTORCALL" flag and it does not fall + * back to "tp_call". + * + * Added in version 3.8. + */ +expect fun PyVectorcall_Call(callable: NativePointer, tuple: NativePointer, dict: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Call a callable Python object *callable*, with arguments given by + * the tuple *args*, and named arguments given by the dictionary + * *kwargs*. + * + * *args* must not be *NULL*; use an empty tuple if no arguments are + * needed. If no named arguments are needed, *kwargs* can be *NULL*. + * + * Return the result of the call on success, or raise an exception and + * return *NULL* on failure. + * + * This is the equivalent of the Python expression: "callable(*args, + * **kwargs)". + */ +expect fun PyObject_Call(callable: NativePointer, args: NativePointer, kwargs: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.10.* + * + * Call a callable Python object *callable* without any arguments. It + * is the most efficient way to call a callable Python object without + * any argument. + * + * Return the result of the call on success, or raise an exception and + * return *NULL* on failure. + * + * Added in version 3.9. + */ +expect fun PyObject_CallNoArgs(callable: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Call a callable Python object *callable*, with arguments given by + * the tuple *args*. If no arguments are needed, then *args* can be + * *NULL*. + * + * Return the result of the call on success, or raise an exception and + * return *NULL* on failure. + * + * This is the equivalent of the Python expression: "callable(*args)". + */ +expect fun PyObject_CallObject(callable: NativePointer, args: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Determine if the object *o* is callable. Return "1" if the object + */ +expect inline fun PyCallable_Check(o: NativePointer): Int + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 12 +// Number Protocol +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Part of the Stable ABI.* + * + * Returns "1" if the object *o* provides numeric protocols, and false + * otherwise. This function always succeeds. + * + * Changed in version 3.8: Returns "1" if *o* is an index integer. + */ +expect inline fun PyNumber_Check(o: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the result of adding *o1* and *o2*, or "NULL" on failure. + * This is the equivalent of the Python expression "o1 + o2". + */ +expect fun PyNumber_Add(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the result of subtracting *o2* from *o1*, or "NULL" on + * failure. This is the equivalent of the Python expression "o1 - + * o2". + */ +expect fun PyNumber_Subtract(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the result of multiplying *o1* and *o2*, or "NULL" on + * failure. This is the equivalent of the Python expression "o1 * + * o2". + */ +expect fun PyNumber_Multiply(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.7.* + * + * Returns the result of matrix multiplication on *o1* and *o2*, or + * "NULL" on failure. This is the equivalent of the Python expression + * "o1 @ o2". + * + * Added in version 3.5. + */ +expect fun PyNumber_MatrixMultiply(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the floor of *o1* divided by *o2*, or "NULL" on failure. + * This is the equivalent of the Python expression "o1 // o2". + */ +expect fun PyNumber_FloorDivide(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a reasonable approximation for the mathematical value of + * *o1* divided by *o2*, or "NULL" on failure. The return value is + * “approximate” because binary floating-point numbers are + * approximate; it is not possible to represent all real numbers in + * base two. This function can return a floating-point value when + * passed two integers. This is the equivalent of the Python + * expression "o1 / o2". + */ +expect fun PyNumber_TrueDivide(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the remainder of dividing *o1* by *o2*, or "NULL" on + * failure. This is the equivalent of the Python expression "o1 % + * o2". + */ +expect fun PyNumber_Remainder(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * See the built-in function "divmod()". Returns "NULL" on failure. + * This is the equivalent of the Python expression "divmod(o1, o2)". + */ +expect fun PyNumber_Divmod(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * See the built-in function "pow()". Returns "NULL" on failure. This + * is the equivalent of the Python expression "pow(o1, o2, o3)", where + * *o3* is optional. If *o3* is to be ignored, pass "Py_None" in its + * place (passing "NULL" for *o3* would cause an illegal memory + * access). + */ +expect fun PyNumber_Power(o1: NativePointer, o2: NativePointer, o3: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the negation of *o* on success, or "NULL" on failure. This + * is the equivalent of the Python expression "-o". + */ +expect fun PyNumber_Negative(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns *o* on success, or "NULL" on failure. This is the + * equivalent of the Python expression "+o". + */ +expect fun PyNumber_Positive(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the absolute value of *o*, or "NULL" on failure. This is + * the equivalent of the Python expression "abs(o)". + */ +expect fun PyNumber_Absolute(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the bitwise negation of *o* on success, or "NULL" on + * failure. This is the equivalent of the Python expression "~o". + */ +expect fun PyNumber_Invert(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the result of left shifting *o1* by *o2* on success, or + * "NULL" on failure. This is the equivalent of the Python expression + * "o1 << o2". + */ +expect fun PyNumber_Lshift(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the result of right shifting *o1* by *o2* on success, or + * "NULL" on failure. This is the equivalent of the Python expression + * "o1 >> o2". + */ +expect fun PyNumber_Rshift(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the “bitwise and” of *o1* and *o2* on success and "NULL" on + * failure. This is the equivalent of the Python expression "o1 & o2". + */ +expect fun PyNumber_And(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the “bitwise exclusive or” of *o1* by *o2* on success, or + * "NULL" on failure. This is the equivalent of the Python expression + * "o1 ^ o2". + */ +expect fun PyNumber_Xor(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the “bitwise or” of *o1* and *o2* on success, or "NULL" on + * failure. This is the equivalent of the Python expression "o1 | o2". + */ +expect fun PyNumber_Or(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the result of adding *o1* and *o2*, or "NULL" on failure. + * The operation is done *in-place* when *o1* supports it. This is + * the equivalent of the Python statement "o1 += o2". + */ +expect fun PyNumber_InPlaceAdd(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the result of subtracting *o2* from *o1*, or "NULL" on + * failure. The operation is done *in-place* when *o1* supports it. + * This is the equivalent of the Python statement "o1 -= o2". + */ +expect fun PyNumber_InPlaceSubtract(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the result of multiplying *o1* and *o2*, or "NULL" on + * failure. The operation is done *in-place* when *o1* supports it. + * This is the equivalent of the Python statement "o1 *= o2". + */ +expect fun PyNumber_InPlaceMultiply(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.7.* + * + * Returns the result of matrix multiplication on *o1* and *o2*, or + * "NULL" on failure. The operation is done *in-place* when *o1* + * supports it. This is the equivalent of the Python statement "o1 @= + * o2". + * + * Added in version 3.5. + */ +expect fun PyNumber_InPlaceMatrixMultiply(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the mathematical floor of dividing *o1* by *o2*, or "NULL" + * on failure. The operation is done *in-place* when *o1* supports it. + * This is the equivalent of the Python statement "o1 //= o2". + */ +expect fun PyNumber_InPlaceFloorDivide(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a reasonable approximation for the mathematical value of + * *o1* divided by *o2*, or "NULL" on failure. The return value is + * “approximate” because binary floating-point numbers are + * approximate; it is not possible to represent all real numbers in + * base two. This function can return a floating-point value when + * passed two integers. The operation is done *in-place* when *o1* + * supports it. This is the equivalent of the Python statement "o1 /= + * o2". + */ +expect fun PyNumber_InPlaceTrueDivide(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the remainder of dividing *o1* by *o2*, or "NULL" on + * failure. The operation is done *in-place* when *o1* supports it. + * This is the equivalent of the Python statement "o1 %= o2". + */ +expect fun PyNumber_InPlaceRemainder(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * See the built-in function "pow()". Returns "NULL" on failure. The + * operation is done *in-place* when *o1* supports it. This is the + * equivalent of the Python statement "o1 **= o2" when o3 is + * "Py_None", or an in-place variant of "pow(o1, o2, o3)" otherwise. + * If *o3* is to be ignored, pass "Py_None" in its place (passing + * "NULL" for *o3* would cause an illegal memory access). + */ +expect fun PyNumber_InPlacePower(o1: NativePointer, o2: NativePointer, o3: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the result of left shifting *o1* by *o2* on success, or + * "NULL" on failure. The operation is done *in-place* when *o1* + * supports it. This is the equivalent of the Python statement "o1 + * <<= o2". + */ +expect fun PyNumber_InPlaceLshift(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the result of right shifting *o1* by *o2* on success, or + * "NULL" on failure. The operation is done *in-place* when *o1* + * supports it. This is the equivalent of the Python statement "o1 + * >>= o2". + */ +expect fun PyNumber_InPlaceRshift(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the “bitwise and” of *o1* and *o2* on success and "NULL" on + * failure. The operation is done *in-place* when *o1* supports it. + * This is the equivalent of the Python statement "o1 &= o2". + */ +expect fun PyNumber_InPlaceAnd(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the “bitwise exclusive or” of *o1* by *o2* on success, or + * "NULL" on failure. The operation is done *in-place* when *o1* + * supports it. This is the equivalent of the Python statement "o1 ^= + * o2". + */ +expect fun PyNumber_InPlaceXor(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the “bitwise or” of *o1* and *o2* on success, or "NULL" on + * failure. The operation is done *in-place* when *o1* supports it. + * This is the equivalent of the Python statement "o1 |= o2". + */ +expect fun PyNumber_InPlaceOr(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the *o* converted to an integer object on success, or + * "NULL" on failure. This is the equivalent of the Python expression + * "int(o)". + */ +expect fun PyNumber_Long(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the *o* converted to a float object on success, or "NULL" + * on failure. This is the equivalent of the Python expression + * "float(o)". + */ +expect fun PyNumber_Float(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the *o* converted to a Python int on success or "NULL" with + * a "TypeError" exception raised on failure. + * + * Changed in version 3.10: The result always has exact type "int". + * Previously, the result could have been an instance of a subclass of + * "int". + */ +expect fun PyNumber_Index(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Returns the integer *n* converted to base *base* as a string. The + * *base* argument must be one of 2, 8, 10, or 16. For base 2, 8, or + * 16, the returned string is prefixed with a base marker of "'0b'", + * "'0o'", or "'0x'", respectively. If *n* is not a Python int, it is + * converted with "PyNumber_Index()" first. + */ +expect fun PyNumber_ToBase(n: NativePointer, base: Int): NativePointer? + +/** + * *Part of the Stable ABI since version 3.8.* + * + * Returns "1" if *o* is an index integer (has the "nb_index" slot of + * the "tp_as_number" structure filled in), and "0" otherwise. This + */ +expect inline fun PyIndex_Check(o: NativePointer): Int + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 13 +// Sequence Protocol +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Part of the Stable ABI.* + * + * Return "1" if the object provides the sequence protocol, and "0" + * otherwise. Note that it returns "1" for Python classes with a + * "__getitem__()" method, unless they are "dict" subclasses, since in + * general it is impossible to determine what type of keys the class + * supports. This function always succeeds. + */ +expect inline fun PySequence_Check(o: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the concatenation of *o1* and *o2* on success, and "NULL" on + * failure. This is the equivalent of the Python expression "o1 + o2". + */ +expect fun PySequence_Concat(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the concatenation of *o1* and *o2* on success, and "NULL" on + * failure. The operation is done *in-place* when *o1* supports it. + * This is the equivalent of the Python expression "o1 += o2". + */ +expect fun PySequence_InPlaceConcat(o1: NativePointer, o2: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Determine if *o* contains *value*. If an item in *o* is equal to + * *value*, return "1", otherwise return "0". On error, return "-1". + * This is equivalent to the Python expression "value in o". + */ +expect inline fun PySequence_Contains(o: NativePointer, value: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a list object with the same contents as the sequence or + * iterable *o*, or "NULL" on failure. The returned list is + * guaranteed to be new. This is equivalent to the Python expression + * "list(o)". + */ +expect fun PySequence_List(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a tuple object with the same contents as the sequence or + * iterable *o*, or "NULL" on failure. If *o* is a tuple, a new + * reference will be returned, otherwise a tuple will be constructed + * with the appropriate contents. This is equivalent to the Python + * expression "tuple(o)". + */ +expect fun PySequence_Tuple(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the sequence or iterable *o* as an object usable by the + * other "PySequence_Fast*" family of functions. If the object is not + * a sequence or iterable, raises "TypeError" with *m* as the message + * text. Returns "NULL" on failure. + * + * The "PySequence_Fast*" functions are thus named because they assume + * *o* is a "PyTupleObject" or a "PyListObject" and access the data + * fields of *o* directly. + * + * As a CPython implementation detail, if *o* is already a sequence or + * list, it will be returned. + */ +expect fun PySequence_Fast(o: NativePointer, m: String): NativePointer? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 14 +// Mapping Protocol +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Part of the Stable ABI.* + * + * Return "1" if the object provides the mapping protocol or supports + * slicing, and "0" otherwise. Note that it returns "1" for Python + * classes with a "__getitem__()" method, since in general it is + * impossible to determine what type of keys the class supports. This + * function always succeeds. + */ +expect inline fun PyMapping_Check(o: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * This is the same as "PyObject_GetItem()", but *key* is specified as + * a const char* UTF-8 encoded bytes string, rather than a PyObject*. + */ +expect fun PyMapping_GetItemString(o: NativePointer, key: String): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * This is the same as "PyObject_SetItem()", but *key* is specified as + * a const char* UTF-8 encoded bytes string, rather than a PyObject*. + */ +expect inline fun PyMapping_SetItemString(o: NativePointer, key: String, v: NativePointer): Int + +/** + * *Part of the Stable ABI since version 3.13.* + * + * Return "1" if the mapping object has the key *key* and "0" + * otherwise. This is equivalent to the Python expression "key in o". + * On failure, return "-1". + * + * Added in version 3.13. + */ +expect inline fun PyMapping_HasKeyWithError(o: NativePointer, key: NativePointer): Int + +/** + * *Part of the Stable ABI since version 3.13.* + * + * This is the same as "PyMapping_HasKeyWithError()", but *key* is + * specified as a const char* UTF-8 encoded bytes string, rather than + * a PyObject*. + * + * Added in version 3.13. + */ +expect inline fun PyMapping_HasKeyStringWithError(o: NativePointer, key: String): Int + +/** + * *Part of the Stable ABI.* + * + * Return "1" if the mapping object has the key *key* and "0" + * otherwise. This is equivalent to the Python expression "key in o". + * This function always succeeds. + * + * Note: + * + * Exceptions which occur when this calls "__getitem__()" method are + * silently ignored. For proper error handling, use + * "PyMapping_HasKeyWithError()", "PyMapping_GetOptionalItem()" or + * "PyObject_GetItem()" instead. + */ +expect inline fun PyMapping_HasKey(o: NativePointer, key: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * This is the same as "PyMapping_HasKey()", but *key* is specified as + * a const char* UTF-8 encoded bytes string, rather than a PyObject*. + * + * Note: + * + * Exceptions that occur when this calls "__getitem__()" method or + * while creating the temporary "str" object are silently ignored. + * For proper error handling, use + * "PyMapping_HasKeyStringWithError()", + * "PyMapping_GetOptionalItemString()" or + * "PyMapping_GetItemString()" instead. + */ +expect inline fun PyMapping_HasKeyString(o: NativePointer, key: String): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * On success, return a list of the keys in object *o*. On failure, + * return "NULL". + * + * Changed in version 3.7: Previously, the function returned a list or + * a tuple. + */ +expect fun PyMapping_Keys(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * On success, return a list of the values in object *o*. On failure, + * return "NULL". + * + * Changed in version 3.7: Previously, the function returned a list or + * a tuple. + */ +expect fun PyMapping_Values(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * On success, return a list of the items in object *o*, where each + * item is a tuple containing a key-value pair. On failure, return + * "NULL". + * + * Changed in version 3.7: Previously, the function returned a list or + */ +expect fun PyMapping_Items(o: NativePointer): NativePointer? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 15 +// Iterator Protocol +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Part of the Stable ABI since version 3.8.* + * + * Return non-zero if the object *o* can be safely passed to + * "PyIter_Next()", and "0" otherwise. This function always succeeds. + */ +expect inline fun PyIter_Check(o: NativePointer): Int + +/** + * *Part of the Stable ABI since version 3.10.* + * + * Return non-zero if the object *o* provides the "AsyncIterator" + * protocol, and "0" otherwise. This function always succeeds. + * + * Added in version 3.10. + */ +expect inline fun PyAIter_Check(o: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the next value from the iterator *o*. The object must be an + * iterator according to "PyIter_Check()" (it is up to the caller to + * check this). If there are no remaining values, returns "NULL" with + * no exception set. If an error occurs while retrieving the item, + * returns "NULL" and passes along the exception. + */ +expect fun PyIter_Next(o: NativePointer): NativePointer? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 27 +// Type Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Part of the Stable ABI.* + * + * Return true if *a* is a subtype of *b*. + * + * This function only checks for actual subtypes, which means that + * "__subclasscheck__()" is not called on *b*. Call + * "PyObject_IsSubclass()" to do the same check that "issubclass()" + * would do. + */ +expect inline fun PyType_IsSubtype(a: NativePointer, b: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Finalize a type object. This should be called on all type objects + * to finish their initialization. This function is responsible for + * adding inherited slots from a type’s base class. Return "0" on + * success, or return "-1" and sets an exception on error. + * + * Note: + * + * If some of the base classes implements the GC protocol and the + * provided type does not include the "Py_TPFLAGS_HAVE_GC" in its + * flags, then the GC protocol will be automatically implemented + * from its parents. On the contrary, if the type being created does + * include "Py_TPFLAGS_HAVE_GC" in its flags then it **must** + * implement the GC protocol itself by at least implementing the + * "tp_traverse" handle. +*/ +expect inline fun PyType_Ready(type: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.11.* + * + * Return the type’s name. Equivalent to getting the type’s "__name__" + * attribute. + * + * Added in version 3.11. + */ +expect fun PyType_GetName(type: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI since version 3.13.* + * + * Return the type’s fully qualified name. Equivalent to + * "f"{type.__module__}.{type.__qualname__}"", or "type.__qualname__" + * if "type.__module__" is not a string or is equal to ""builtins"". + * + * Added in version 3.13. + */ +expect fun PyType_GetFullyQualifiedName(type: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI since version 3.13.* + * + * Return the type’s module name. Equivalent to getting the + * "type.__module__" attribute. + * + * Added in version 3.13. + */ +expect fun PyType_GetModuleName(type: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI since version 3.10.* + * + * Return the module object associated with the given type when the + * type was created using "PyType_FromModuleAndSpec()". + * + * If no module is associated with the given type, sets "TypeError" + * and returns "NULL". + * + * This function is usually used to get the module in which a method + * is defined. Note that in such a method, + * "PyType_GetModule(Py_TYPE(self))" may not return the intended + * result. "Py_TYPE(self)" may be a *subclass* of the intended class, + * and subclasses are not necessarily defined in the same module as + * their superclass. See "PyCMethod" to get the class that defines the + * method. See "PyType_GetModuleByDef()" for cases when "PyCMethod" + * cannot be used. + * + * Added in version 3.9. + */ +expect fun PyType_GetModule(type: NativePointer): NativePointer? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 16 +// Integer Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a new "PyLongObject" object from a C long long, or "NULL" on + * failure. + */ +expect fun PyLong_FromLongLong(v: Long): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a new "PyLongObject" object from the integer part of *v*, or + * "NULL" on failure. + */ +expect fun PyLong_FromDouble(v: Double): NativePointer? + +/** + * *Part of the Stable ABI since version 3.13.* + * + * Similar to "PyLong_AsLong()", but store the result in a C int + * instead of a C long. + * + * Added in version 3.13. + */ +expect inline fun PyLong_AsInt(obj: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Return a C long long representation of *obj*. If *obj* is not an + * instance of "PyLongObject", first call its "__index__()" method (if + * present) to convert it to a "PyLongObject". + * + * Raise "OverflowError" if the value of *obj* is out of range for a + * long long. + * + * Returns "-1" on error. Use "PyErr_Occurred()" to disambiguate. + * + * Changed in version 3.8: Use "__index__()" if available. + * + * Changed in version 3.10: This function will no longer use + * "__int__()". + */ +expect inline fun PyLong_AsLongLong(obj: NativePointer): Long + +/** + * *Part of the Stable ABI.* + * + * Return a C double representation of *pylong*. *pylong* must be an + * instance of "PyLongObject". + * + * Raise "OverflowError" if the value of *pylong* is out of range for + * a double. + * + * Returns "-1.0" on error. Use "PyErr_Occurred()" to disambiguate. + */ +expect inline fun PyLong_AsDouble(pylong: NativePointer): Double + +/** + * *Part of the Stable ABI.* + * + * On success, return a read only *named tuple*, that holds + * information about Python’s internal representation of integers. See + * "sys.int_info" for description of individual fields. + * + * On failure, return "NULL" with an exception set. + * + * Added in version 3.1. + */ +expect fun PyLong_GetInfo(): NativePointer? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 17 +// Boolean Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return "Py_True" or "Py_False", depending on the truth value of + */ +expect fun PyBool_FromLong(v: Int): NativePointer? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 18 +// Floating-Point Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Create a "PyFloatObject" object based on the string value in *str*, + * or "NULL" on failure. + */ +expect fun PyFloat_FromString(str: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Create a "PyFloatObject" object from *v*, or "NULL" on failure. + */ +expect fun PyFloat_FromDouble(v: Double): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Return a C double representation of the contents of *pyfloat*. If + * *pyfloat* is not a Python floating-point object but has a + * "__float__()" method, this method will first be called to convert + * *pyfloat* into a float. If "__float__()" is not defined then it + * falls back to "__index__()". This method returns "-1.0" upon + * failure, so one should call "PyErr_Occurred()" to check for errors. + * + * Changed in version 3.8: Use "__index__()" if available. + */ +expect inline fun PyFloat_AsDouble(pyfloat: NativePointer): Double + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a structseq instance which contains information about the + * precision, minimum and maximum values of a float. It’s a thin + * wrapper around the header file "float.h". + */ +expect fun PyFloat_GetInfo(): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Return the maximum representable finite float *DBL_MAX* as C + * double. + */ +expect inline fun PyFloat_GetMax(): Double + +/** + * *Part of the Stable ABI.* + * + * Return the minimum normalized positive float *DBL_MIN* as C double. + */ +expect inline fun PyFloat_GetMin(): Double + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 19 +// Bytes Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a new bytes object with a copy of the string *v* as value on + * success, and "NULL" on failure. The parameter *v* must not be + * "NULL"; it will not be checked. + */ +expect fun PyBytes_FromString(v: String): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the bytes representation of object *o* that implements the + * buffer protocol. + */ +expect fun PyBytes_FromObject(o: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Return a pointer to the contents of *o*. The pointer refers to the + * internal buffer of *o*, which consists of "len(o) + 1" bytes. The + * last byte in the buffer is always null, regardless of whether there + * are any other null bytes. The data must not be modified in any + * way, unless the object was just created using + * "PyBytes_FromStringAndSize(NULL, size)". It must not be + * deallocated. If *o* is not a bytes object at all, + * "PyBytes_AsString()" returns "NULL" and raises "TypeError". + */ +expect inline fun PyBytes_AsString(o: NativePointer): String? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 20 +// Byte Array Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a new bytearray object from any object, *o*, that implements + * the buffer protocol. + * + * On failure, return "NULL" with an exception set. + */ +expect fun PyByteArray_FromObject(o: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Concat bytearrays *a* and *b* and return a new bytearray with the + * result. + * + * On failure, return "NULL" with an exception set. + */ +expect fun PyByteArray_Concat(a: NativePointer, b: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Return the contents of *bytearray* as a char array after checking + * for a "NULL" pointer. The returned array always has an extra null + * byte appended. + */ +expect inline fun PyByteArray_AsString(bytearray: NativePointer): String? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 21 +// Unicode Objects and Codecs +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Part of the Stable ABI.* + * + * Return "1" if the string is a valid identifier according to the + * language definition, section Identifiers and keywords. Return "0" + * otherwise. + * + * Changed in version 3.9: The function does not call + * "Py_FatalError()" anymore if the string is not ready. + */ +expect inline fun PyUnicode_IsIdentifier(unicode: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Create a Unicode object from a UTF-8 encoded null-terminated char + * buffer *str*. + */ +expect fun PyUnicode_FromString(str: String): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Copy an instance of a Unicode subtype to a new true Unicode object + * if necessary. If *obj* is already a true Unicode object (not a + * subtype), return a new *strong reference* to the object. + * + * Objects other than Unicode or its subtypes will cause a + * "TypeError". + */ +expect fun PyUnicode_FromObject(obj: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Decode an encoded object *obj* to a Unicode object. + * + * "bytes", "bytearray" and other *bytes-like objects* are decoded + * according to the given *encoding* and using the error handling + * defined by *errors*. Both can be "NULL" to have the interface use + * the default values (see Built-in Codecs for details). + * + * All other objects, including Unicode objects, cause a "TypeError" + * to be set. + * + * The API returns "NULL" if there was an error. The caller is + * responsible for decref’ing the returned objects. + */ +expect fun PyUnicode_FromEncodedObject(obj: NativePointer, encoding: String, errors: String): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.7.* + * + * Similar to "PyUnicode_DecodeLocaleAndSize()", but compute the + * string length using "strlen()". + * + * Added in version 3.3. + */ +expect fun PyUnicode_DecodeLocale(str: String, errors: String): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.7.* + * + * Encode a Unicode object to UTF-8 on Android and VxWorks, or to the + * current locale encoding on other platforms. The supported error + * handlers are ""strict"" and ""surrogateescape"" (**PEP 383**). The + * encoder uses ""strict"" error handler if *errors* is "NULL". Return + * a "bytes" object. *unicode* cannot contain embedded null + * characters. + * + * Use "PyUnicode_EncodeFSDefault()" to encode a string to the + * *filesystem encoding and error handler*. + * + * This function ignores the Python UTF-8 Mode. + * + * See also: The "Py_EncodeLocale()" function. + * + * Added in version 3.3. + * + * Changed in version 3.7: The function now also uses the current + * locale encoding for the "surrogateescape" error handler, except on + * Android. Previously, "Py_EncodeLocale()" was used for the + * "surrogateescape", and the current locale encoding was used for + * "strict". + */ +expect fun PyUnicode_EncodeLocale(unicode: NativePointer, errors: String): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Decode a null-terminated string from the *filesystem encoding and + * error handler*. + * + * If the string length is known, use + * "PyUnicode_DecodeFSDefaultAndSize()". + * + * Changed in version 3.6: The *filesystem error handler* is now used. */ -expect inline fun Py_Initialize() +expect fun PyUnicode_DecodeFSDefault(str: String): NativePointer? /** - * Part of the Stable ABI. + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Encode a Unicode object to the *filesystem encoding and error + * handler*, and return "bytes". Note that the resulting "bytes" + * object can contain null bytes. * - * This function works like Py_Initialize() if initsigs is 1. - * If initsigs is 0, it skips initialization registration of signal handlers, which may be useful when CPython is embedded as part of a larger application. + * If you need to encode a string to the current locale encoding, use + * "PyUnicode_EncodeLocale()". * - * Use Py_InitializeFromConfig() to customize the Python Initialization Configuration. + * See also: The "Py_EncodeLocale()" function. * - * @param initsigs: 0(skip signal handler registration) or 1(normal initialization) + * Added in version 3.2. + * + * Changed in version 3.6: The *filesystem error handler* is now used. */ -expect inline fun Py_InitializeEx(initsigs: Int) +expect fun PyUnicode_EncodeFSDefault(unicode: NativePointer): NativePointer? -//expect inline fun Py_InitializeFromConfig() +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Encode a Unicode object and return the result as Python bytes + * object. *encoding* and *errors* have the same meaning as the + * parameters of the same name in the Unicode "encode()" method. The + * codec to be used is looked up using the Python codec registry. + * Return "NULL" if an exception was raised by the codec. + */ +expect fun PyUnicode_AsEncodedString(unicode: NativePointer, encoding: String, errors: String): NativePointer? /** - * Part of the Stable ABI. + * *Return value: New reference.* + * *Part of the Stable ABI.* * - * Return true (nonzero) when the Python interpreter has been initialized, false (zero) if not. - * After Py_FinalizeEx() is called, this returns false until Py_Initialize() is called again. + * Encode a Unicode object using UTF-8 and return the result as Python + * bytes object. Error handling is “strict”. Return "NULL" if an + * exception was raised by the codec. * - * @return 1(true), 0(false) + * The function fails if the string contains surrogate code points + * ("U+D800" - "U+DFFF"). */ -expect inline fun Py_IsInitialized(): Int +expect fun PyUnicode_AsUTF8String(unicode: NativePointer): NativePointer? + +/** + * As "PyUnicode_AsUTF8AndSize()", but does not store the size. + * + * Added in version 3.3. + * + * Changed in version 3.7: The return type is now "const char *" + * rather of "char *". + */ +expect inline fun PyUnicode_AsUTF8(unicode: NativePointer): String? // 수동 추가 + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a Python byte string using the UTF-32 encoding in native + * byte order. The string always starts with a BOM mark. Error + * handling is “strict”. Return "NULL" if an exception was raised by + * the codec. + */ +expect fun PyUnicode_AsUTF32String(unicode: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a Python byte string using the UTF-16 encoding in native + * byte order. The string always starts with a BOM mark. Error + * handling is “strict”. Return "NULL" if an exception was raised by + * the codec. + */ +expect fun PyUnicode_AsUTF16String(unicode: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Encode a Unicode object using Unicode-Escape and return the result + * as a bytes object. Error handling is “strict”. Return "NULL" if + * an exception was raised by the codec. + */ +expect fun PyUnicode_AsUnicodeEscapeString(unicode: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Encode a Unicode object using Raw-Unicode-Escape and return the + * result as a bytes object. Error handling is “strict”. Return + * "NULL" if an exception was raised by the codec. + */ +expect fun PyUnicode_AsRawUnicodeEscapeString(unicode: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Encode a Unicode object using Latin-1 and return the result as + * Python bytes object. Error handling is “strict”. Return "NULL" if + * an exception was raised by the codec. + */ +expect fun PyUnicode_AsLatin1String(unicode: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Encode a Unicode object using ASCII and return the result as Python + * bytes object. Error handling is “strict”. Return "NULL" if an + * exception was raised by the codec. + */ +expect fun PyUnicode_AsASCIIString(unicode: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Encode a Unicode object using the given *mapping* object and return + * the result as a bytes object. Error handling is “strict”. Return + * "NULL" if an exception was raised by the codec. + * + * The *mapping* object must map Unicode ordinal integers to bytes + * objects, integers in the range from 0 to 255 or "None". Unmapped + * character ordinals (ones which cause a "LookupError") as well as + * mapped to "None" are treated as “undefined mapping” and cause an + * error. + */ +expect fun PyUnicode_AsCharmapString(unicode: NativePointer, mapping: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Translate a string by applying a character mapping table to it and + * return the resulting Unicode object. Return "NULL" if an exception + * was raised by the codec. + * + * The mapping table must map Unicode ordinal integers to Unicode + * ordinal integers or "None" (causing deletion of the character). + * + * Mapping tables need only provide the "__getitem__()" interface; + * dictionaries and sequences work well. Unmapped character ordinals + * (ones which cause a "LookupError") are left untouched and are + * copied as-is. + * + * *errors* has the usual meaning for codecs. It may be "NULL" which + * indicates to use the default error handling. + */ +expect fun PyUnicode_Translate(unicode: NativePointer, table: NativePointer, errors: String): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Concat two strings giving a new Unicode string. + */ +expect fun PyUnicode_Concat(left: NativePointer, right: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Split a Unicode string at line breaks, returning a list of Unicode + * strings. CRLF is considered to be one line break. If *keepends* is + * "0", the Line break characters are not included in the resulting + * strings. + */ +expect fun PyUnicode_Splitlines(unicode: NativePointer, keepends: Int): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Join a sequence of strings using the given *separator* and return + * the resulting Unicode string. + */ +expect fun PyUnicode_Join(separator: NativePointer, seq: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Compare two strings and return "-1", "0", "1" for less than, equal, + * and greater than, respectively. + * + * This function returns "-1" upon failure, so one should call + * "PyErr_Occurred()" to check for errors. + */ +expect inline fun PyUnicode_Compare(left: NativePointer, right: NativePointer): Int /** - * Part of the Stable ABI since version 3.13. + * *Part of the Stable ABI since version 3.13.* * - * Return true (non-zero) if the main Python interpreter is shutting down. - * Return false (zero) otherwise. + * Similar to "PyUnicode_EqualToUTF8AndSize()", but compute *string* + * length using "strlen()". If the Unicode object contains null + * characters, false ("0") is returned. * * Added in version 3.13. */ -expect inline fun Py_IsFinalizing(): Int +expect inline fun PyUnicode_EqualToUTF8(unicode: NativePointer, string: String): Int /** - * Part of the Stable ABI. + * *Part of the Stable ABI.* + * + * Compare a Unicode object, *unicode*, with *string* and return "-1", + * "0", "1" for less than, equal, and greater than, respectively. It + * is best to pass only ASCII-encoded strings, but the function + * interprets the input string as ISO-8859-1 if it contains non-ASCII + * characters. * - * This is a backwards-compatible version of Py_FinalizeEx() that disregards the return value. + * This function does not raise exceptions. */ -expect inline fun Py_Finalize() +expect inline fun PyUnicode_CompareWithASCIIString(unicode: NativePointer, string: String): Int /** - * Part of the Stable ABI since version 3.6. + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Rich compare two Unicode strings and return one of the following: * - * Undo all initializations made by Py_Initialize() and subsequent use of Python/C API functions, and destroy all sub-interpreters (see Py_NewInterpreter() below) that were created and not yet destroyed since the last call to Py_Initialize(). - * Ideally, this frees all memory allocated by the Python interpreter. - * This is a no-op when called for a second time (without calling Py_Initialize() again first). + * * "NULL" in case an exception was raised * - * Since this is the reverse of Py_Initialize(), it should be called in the same thread with the same interpreter active. - * That means the main thread and the main interpreter. - * This should never be called while Py_RunMain() is running. + * * "Py_True" or "Py_False" for successful comparisons * - * Normally the return value is 0. - * If there were errors during finalization (flushing buffered data), -1 is returned. + * * "Py_NotImplemented" in case the type combination is unknown * - * This function is provided for a number of reasons. - * An embedding application might want to restart Python without having to restart the application itself. - * An application that has loaded the Python interpreter from a dynamically loadable library (or DLL) might want to free all memory allocated by Python before unloading the DLL. - * During a hunt for memory leaks in an application a developer might want to free all memory allocated by Python before exiting from the application. + * Possible values for *op* are "Py_GT", "Py_GE", "Py_EQ", "Py_NE", + * "Py_LT", and "Py_LE". + */ +expect fun PyUnicode_RichCompare(left: NativePointer, right: NativePointer, op: Int): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* * - * Bugs and caveats: - * The destruction of modules and objects in modules is done in random order; this may cause destructors (__del__() methods) to fail when they depend on other objects (even functions) or modules. - * Dynamically loaded extension modules loaded by Python are not unloaded. - * Small amounts of memory allocated by the Python interpreter may not be freed (if you find a leak, please report it). - * Memory tied up in circular references between objects is not freed. - * Some memory allocated by extension modules may not be freed. - * Some extensions may not work properly if their initialization routine is called more than once; this can happen if an application calls Py_Initialize() and Py_FinalizeEx() more than once. + * Return a new string object from *format* and *args*; this is + * analogous to "format % args". + */ +expect fun PyUnicode_Format(format: NativePointer, args: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* * - * Raises an auditing event cpython._PySys_ClearAuditHooks with no arguments. + * Check whether *substr* is contained in *unicode* and return true or + * false accordingly. * - * @return 0(success), -1(failure) + * *substr* has to coerce to a one element Unicode string. "-1" is + * returned if there was an error. */ -expect inline fun Py_FinalizeEx(): Int +expect inline fun PyUnicode_Contains(unicode: NativePointer, substr: NativePointer): Int /** - * Part of the Stable ABI since version 3.8. + * *Return value: New reference.* + * *Part of the Stable ABI.* * - * Similar to Py_Main() but argv is an array of bytes strings, allowing the calling application to delegate the text decoding step to the CPython runtime. + * A combination of "PyUnicode_FromString()" and + * "PyUnicode_InternInPlace()", meant for statically allocated + * strings. * - * Added in version 3.8. + * Return a new (“owned”) reference to either a new Unicode string + * object that has been interned, or an earlier interned string object + * with the same value. + * + * Python may keep a reference to the result, or make it *immortal*, + * preventing it from being garbage-collected promptly. For interning + * an unbounded number of different strings, such as ones coming from + * user input, prefer calling "PyUnicode_FromString()" and + * "PyUnicode_InternInPlace()" directly. + * + * **CPython implementation detail:** Strings interned this way are */ -//expect inline fun Py_BytesMain(args: Array): Int +expect fun PyUnicode_InternFromString(str: String): NativePointer? + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 28 +// Tuple Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// NOTE: section numbers are append order, not the C API documentation's chapter order. +// Sections 1-26 follow the docs; 27 (Type Objects), 28 (Tuple Objects) and 29 (Module Objects) +// were added when they were first needed and so sit after Weak Reference Objects instead of at +// their documented positions (Type before Integer Objects, Tuple before List Objects, Module +// before Iterator Objects). Renumbering is a ~370-line pure-comment move with no behavioural +// effect, and it conflicts with anything else editing this file; the target order is recorded in +// ROADMAP §12 instead. Navigate this file by function name, not by section number. +expect fun PyTuple_New(len: Long): NativePointer? +expect inline fun PyTuple_Size(p: NativePointer): Long +expect fun PyTuple_GetItem(p: NativePointer, pos: Long): NativePointer? +expect fun PyTuple_GetSlice(p: NativePointer, low: Long, high: Long): NativePointer? +expect inline fun PyTuple_SetItem(p: NativePointer, pos: Long, o: NativePointer): Int + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 22 +// List Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /** - * Executes the main module in a fully configured CPython runtime. + * *Return value: New reference.* + * *Part of the Stable ABI.* * - * Executes the command (PyConfig.run_command), the script (PyConfig.run_filename) or the module (PyConfig.run_module) specified on the command line or in the configuration. - * If none of these values are set, runs the interactive Python prompt (REPL) using the __main__ module’s global namespace. + * Return a new list of length *len* on success, or "NULL" on + * failure. * - * If PyConfig.inspect is not set (the default), the return value will be 0 if the interpreter exits normally (that is, without raising an exception), or 1 if the interpreter exits due to an exception. - * If an otherwise unhandled SystemExit is raised, the function will immediately exit the process instead of returning 1. + * Note: * - * If PyConfig.inspect is set (such as when the -i option is used), rather than returning when the interpreter exits, execution will instead resume in an interactive Python prompt (REPL) using the __main__ module’s global namespace. - * If the interpreter exited with an exception, it is immediately raised in the REPL session. - * The function return value is then determined by the way the REPL session terminates: returning 0 if the session terminates without raising an unhandled exception, exiting immediately for an unhandled SystemExit, and returning 1 for any other unhandled exception. + * If *len* is greater than zero, the returned list object's items + * are set to "NULL". Thus you cannot use abstract API functions + * such as "PySequence_SetItem()" or expose the object to + * Python code before setting all items to a real object with + * "PyList_SetItem()". + */ +expect fun PyList_New(len: Long): NativePointer? + +/** + * *Part of the Stable ABI.* * - * This function always finalizes the Python interpreter regardless of whether it returns a value or immediately exits the process due to an unhandled SystemExit exception. + * Return the length of the list object in *list*; this is the + * equivalent of "len(list)" on a list object. * - * See Python Configuration for an example of a customized Python that always runs in isolated mode using Py_RunMain(). + * Note: a bad argument (i.e. *list* is not a list) is undefined + * behaviour at the C level; use "PySequence_Size()"/[PyObject_Size] + * for arbitrary, not-necessarily-list objects. */ -expect inline fun Py_RunMain(): Int +expect inline fun PyList_Size(list: NativePointer): Long /** - * Executes the Python source code from command in the __main__ module according to the flags argument. - * If __main__ does not already exist, it is created. - * Returns 0 on success or -1 if an exception was raised. - * If there was an error, there is no way to get the exception information. - * For the meaning of flags, see below. + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* * - * Note that if an otherwise unhandled SystemExit is raised, this function will not return -1, but exit the process, as long as PyConfig.inspect is zero. + * Return the object at position *index* in the list *list*. The + * position must be non-negative; indexing from the end of the list + * is not supported. If *index* is out of bounds, return "NULL" and + * set an "IndexError" exception. */ -expect inline fun PyRun_SimpleString(command: String): Int +expect fun PyList_GetItem(list: NativePointer, index: Long): NativePointer? /** - * Return value: New reference. + * *Part of the Stable ABI.* + * + * Set the item at index *index* in list *list* to *item*. Return + * "0" on success. If *index* is out of bounds, return "-1" and set + * an "IndexError" exception. * - * Execute Python source code from str in the context specified by the objects globals and locals with the compiler flags specified by flags. - * globals must be a dictionary; locals can be any object that implements the mapping protocol. - * The parameter start specifies the start token that should be used to parse the source code. + * Note: * - * Returns the result of executing the code as a Python object, or NULL if an exception was raised. + * This function "steals" a reference to *item* and discards a + * reference to an item already in the list at the affected + * position. */ -expect fun PyRun_String(str: String, start: Int, globals: NativePointer, locals: NativePointer): NativePointer? +expect inline fun PyList_SetItem(list: NativePointer, index: Long, item: NativePointer): Int /** - * Part of the Stable ABI. + * *Part of the Stable ABI.* * - * Return the version of this Python interpreter. This is a string that looks something like - * "3.0a5+ (py3k:63103M, May 12 2008, 00:53:55) \n[GCC 4.2.3]" + * Insert the item *item* into list *list* in front of index + * *index*. Return "0" if successful; return "-1" and set an + * exception if unsuccessful. Analogous to "list.insert(index, item)". * - * The first word (up to the first space character) is the current Python version; the first characters are the major and minor version separated by a period. The returned string points into static storage; the caller should not modify its value. The value is available to Python code as sys.version. + * Unlike "PyList_SetItem()", this function *does not* steal a + * reference to *item*. + */ +expect inline fun PyList_Insert(list: NativePointer, index: Long, item: NativePointer): Int + +/** + * *Part of the Stable ABI.* * - * See also the Py_Version constant. + * Append the object *item* at the end of list *list*. Return "0" if + * successful; return "-1" and set an exception if unsuccessful. + * Analogous to "list.append(item)". */ -expect inline fun Py_GetVersion(): String? +expect inline fun PyList_Append(list: NativePointer, item: NativePointer): Int /** - * Part of the Stable ABI. + * *Part of the Stable ABI.* * - * Return the platform identifier for the current platform. - * On Unix, this is formed from the “official” name of the operating system, converted to lower case, followed by the major revision number; - * e.g., for Solaris 2.x, which is also known as SunOS 5.x, the value is 'sunos5'. - * On macOS, it is 'darwin'. On Windows, it is 'win'. - * The returned string points into static storage; the caller should not modify its value. - * The value is available to Python code as sys.platform. + * Sort the items of *list* in place. Return "0" on success, "-1" on + * failure. This is equivalent to "list.sort()". */ -expect inline fun Py_GetPlatform(): String? +expect inline fun PyList_Sort(list: NativePointer): Int /** - * Part of the Stable ABI. + * *Part of the Stable ABI.* * - * Return the official copyright string for the current Python version, for example + * Reverse the items of *list* in place. Return "0" on success, "-1" + * on failure. This is the equivalent of "list.reverse()". + */ +expect inline fun PyList_Reverse(list: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* * - * 'Copyright 1991-1995 Stichting Mathematisch Centrum, Amsterdam' + * Return a new tuple object containing the contents of *list*; + */ +expect fun PyList_AsTuple(list: NativePointer): NativePointer? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 23 +// Dictionary Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* * - * The returned string points into static storage; the caller should not modify its value. - * The value is available to Python code as sys.copyright. + * Return a new empty dictionary, or "NULL" on failure. */ -expect inline fun Py_GetCopyright(): String? +expect fun PyDict_New(): NativePointer? /** - * Part of the Stable ABI. + * *Part of the Stable ABI.* * - * Return an indication of the compiler used to build the current Python version, in square brackets, for example: - * "[GCC 2.7.2.2]" + * Return the number of items in the dictionary. This is equivalent + * to "len(p)" on a dictionary. + */ +expect inline fun PyDict_Size(p: NativePointer): Long + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* * - * The returned string points into static storage; the caller should not modify its value. - * The value is available to Python code as part of the variable sys.version. + * Return a "types.MappingProxyType" object for a mapping which + * enforces read-only behavior. This is normally used to create a + * view to prevent modification of the dictionary for non-dynamic + * class types. */ -expect inline fun Py_GetCompiler(): String? +expect fun PyDictProxy_New(mapping: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Empty an existing dictionary of all key-value pairs. + */ +expect inline fun PyDict_Clear(p: NativePointer) /** - * Part of the Stable ABI. + * *Part of the Stable ABI.* * - * Return information about the sequence number and build date and time of the current Python interpreter instance, for example - * "#67, Aug 1 1997, 22:34:28" + * Determine if dictionary *p* contains *key*. If an item in *p* is + * matches *key*, return "1", otherwise return "0". On error, return + * "-1". This is equivalent to the Python expression "key in p". + */ +expect inline fun PyDict_Contains(p: NativePointer, key: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* * - * The returned string points into static storage; the caller should not modify its value. - * The value is available to Python code as part of the variable sys.version. + * Return a new dictionary that contains the same key-value pairs as + * *p*. */ -expect inline fun Py_GetBuildInfo(): String? +expect fun PyDict_Copy(p: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Insert *val* into the dictionary *p* with a key of *key*. *key* + * must be *hashable*; if it isn’t, "TypeError" will be raised. Return + * "0" on success or "-1" on failure. This function *does not* steal + * a reference to *val*. + */ +expect inline fun PyDict_SetItem(p: NativePointer, key: NativePointer, v: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * This is the same as "PyDict_SetItem()", but *key* is specified as a + * const char* UTF-8 encoded bytes string, rather than a PyObject*. + */ +expect inline fun PyDict_SetItemString(p: NativePointer, key: String, v: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Remove the entry in dictionary *p* with key *key*. *key* must be + * *hashable*; if it isn’t, "TypeError" is raised. If *key* is not in + * the dictionary, "KeyError" is raised. Return "0" on success or "-1" + * on failure. + */ +expect inline fun PyDict_DelItem(p: NativePointer, key: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * This is the same as "PyDict_DelItem()", but *key* is specified as a + * const char* UTF-8 encoded bytes string, rather than a PyObject*. + */ +expect inline fun PyDict_DelItemString(p: NativePointer, key: String): Int + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * Return a *borrowed reference* to the object from dictionary *p* + * which has a key *key*. Return "NULL" if the key *key* is missing + * *without* setting an exception. + * + * Note: + * + * Exceptions that occur while this calls "__hash__()" and + * "__eq__()" methods are silently ignored. Prefer the + * "PyDict_GetItemWithError()" function instead. + * + * Changed in version 3.10: Calling this API without *GIL* held had + * been allowed for historical reason. It is no longer allowed. + */ +expect fun PyDict_GetItem(p: NativePointer, key: NativePointer): NativePointer? + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * Variant of "PyDict_GetItem()" that does not suppress exceptions. + * Return "NULL" **with** an exception set if an exception occurred. + * Return "NULL" **without** an exception set if the key wasn’t + * present. + */ +expect fun PyDict_GetItemWithError(p: NativePointer, key: NativePointer): NativePointer? + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * This is the same as "PyDict_GetItem()", but *key* is specified as a + * const char* UTF-8 encoded bytes string, rather than a PyObject*. + * + * Note: + * + * Exceptions that occur while this calls "__hash__()" and + * "__eq__()" methods or while creating the temporary "str" object + * are silently ignored. Prefer using the + * "PyDict_GetItemWithError()" function with your own + * "PyUnicode_FromString()" *key* instead. + */ +expect fun PyDict_GetItemString(p: NativePointer, key: String): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a "PyListObject" containing all the items from the + * dictionary. + */ +expect fun PyDict_Items(p: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a "PyListObject" containing all the keys from the + * dictionary. + */ +expect fun PyDict_Keys(p: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a "PyListObject" containing all the values from the + * dictionary *p*. + */ +expect fun PyDict_Values(p: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Iterate over mapping object *b* adding key-value pairs to + * dictionary *a*. *b* may be a dictionary, or any object supporting + * "PyMapping_Keys()" and "PyObject_GetItem()". If *override* is true, + * existing pairs in *a* will be replaced if a matching key is found + * in *b*, otherwise pairs will only be added if there is not a + * matching key in *a*. Return "0" on success or "-1" if an exception + * was raised. + */ +expect inline fun PyDict_Merge(a: NativePointer, b: NativePointer, override: Int): Int + +/** + * *Part of the Stable ABI.* + * + * This is the same as "PyDict_Merge(a, b, 1)" in C, and is similar to + * "a.update(b)" in Python except that "PyDict_Update()" doesn’t fall + * back to the iterating over a sequence of key value pairs if the + * second argument has no “keys” attribute. Return "0" on success or + * "-1" if an exception was raised. + */ +expect inline fun PyDict_Update(a: NativePointer, b: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Update or merge into dictionary *a*, from the key-value pairs in + * *seq2*. *seq2* must be an iterable object producing iterable + * objects of length 2, viewed as key-value pairs. In case of + * duplicate keys, the last wins if *override* is true, else the first + * wins. Return "0" on success or "-1" if an exception was raised. + * Equivalent Python (except for the return value): + * + * def PyDict_MergeFromSeq2(a, seq2, override): + * for key, value in seq2: + * if override or key not in a: + * a[key] = value + */ +expect inline fun PyDict_MergeFromSeq2(a: NativePointer, seq2: NativePointer, override: Int): Int //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// Section 2 -// Exception handling +// Section 24 +// Set Objects //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -expect fun PyErr_Occurred(): NativePointer? -//expect inline fun PyErr_Clear() -//expect inline fun PyErr_Print() +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a new "set" containing objects returned by the *iterable*. + * The *iterable* may be "NULL" to create a new empty set. Return the + * new set on success or "NULL" on failure. Raise "TypeError" if + * *iterable* is not actually iterable. The constructor is also + * useful for copying a set ("c=set(s)"). + */ +expect fun PySet_New(iterable: NativePointer): NativePointer? +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a new "frozenset" containing objects returned by the + * *iterable*. The *iterable* may be "NULL" to create a new empty + * frozenset. Return the new set on success or "NULL" on failure. + * Raise "TypeError" if *iterable* is not actually iterable. + */ +expect fun PyFrozenSet_New(iterable: NativePointer): NativePointer? +/** + * *Part of the Stable ABI.* + * + * Return "1" if found, "0" if not found, and "-1" if an error is + * encountered. Unlike the Python "__contains__()" method, this + * function does not automatically convert unhashable sets into + * temporary frozensets. Raise a "TypeError" if the *key* is + * unhashable. Raise "SystemError" if *anyset* is not a "set", + * "frozenset", or an instance of a subtype. + */ +expect inline fun PySet_Contains(anyset: NativePointer, key: NativePointer): Int -expect fun PyLong_FromLongLong(v: Long): NativePointer? -expect inline fun PyLong_AsLongLong(p: NativePointer): Long -expect inline fun PyLong_AsInt(p: NativePointer): Int +/** + * *Part of the Stable ABI.* + * + * Return the length of a "set" or "frozenset" object. Equivalent to + * "len(anyset)". Raise a "PyExc_SystemError" if *anyset* is not a + * "set", "frozenset", or an instance of a subtype. + */ +expect inline fun PySet_Size(anyset: NativePointer): Long +/** + * *Part of the Stable ABI.* + * + * Add *key* to a "set" instance. Also works with "frozenset" + * instances (like "PyTuple_SetItem()" it can be used to fill in the + * values of brand new frozensets before they are exposed to other + * code). Return "0" on success or "-1" on failure. Raise a + * "TypeError" if the *key* is unhashable. Raise a "MemoryError" if + * there is no room to grow. Raise a "SystemError" if *set* is not an + * instance of "set" or its subtype. + */ +expect inline fun PySet_Add(set: NativePointer, key: NativePointer): Int + +/** + * *Part of the Stable ABI.* + * + * Return "1" if found and removed, "0" if not found (no action + * taken), and "-1" if an error is encountered. Does not raise + * "KeyError" for missing keys. Raise a "TypeError" if the *key* is + * unhashable. Unlike the Python "discard()" method, this function + * does not automatically convert unhashable sets into temporary + * frozensets. Raise "SystemError" if *set* is not an instance of + * "set" or its subtype. + */ +expect inline fun PySet_Discard(set: NativePointer, key: NativePointer): Int + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a new reference to an arbitrary object in the *set*, and + * removes the object from the *set*. Return "NULL" on failure. + * Raise "KeyError" if the set is empty. Raise a "SystemError" if + * *set* is not an instance of "set" or its subtype. + */ +expect fun PySet_Pop(set: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * Empty an existing set of all elements. Return "0" on success. + * Return "-1" and raise "SystemError" if *set* is not an instance of + */ +expect inline fun PySet_Clear(set: NativePointer): Int -expect fun PyUnicode_FromString(str: String): NativePointer? -expect inline fun PyUnicode_AsUTF8(unicode: NativePointer): String? + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 29 +// Module Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// NOTE: this section was added after 27/28; see the ordering note above. Nothing is outstanding. +/** + * *Part of the Stable ABI.* + * + * Return *module*'s "__name__" value. This is equivalent to + * "module.__name__" and, unlike "PyModule_GetNameObject()", returns + * the value already decoded as a UTF-8 encoded "const char*" rather + * than a "PyObject*" -- so, unlike most of this ABI subset's string + * accessors, decoding it costs no separate `PyUnicode_AsUTF8()` call. + * "NULL" on failure (e.g. if *module* is not a module object). + */ +expect inline fun PyModule_GetName(module: NativePointer): String? + +/** + * *Return value: Borrowed reference.* + * *Part of the Stable ABI.* + * + * Return the dictionary object that implements *module*'s namespace; + * this object is the same as the "__dict__" attribute of the module + * object. Raises a "SystemError" and returns "NULL" if *module* is + * not a module object. + */ +expect fun PyModule_GetDict(module: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return the name of the file from which *module* was loaded using + * *module*'s "__file__" attribute, as a "PyObject*". Raise + * "SystemError" and return "NULL" if *module* is not a module + * object; raise "AttributeError" and return "NULL" if the module + * has no "__file__" attribute (typically a built-in or frozen + * module). + */ +expect fun PyModule_GetFilenameObject(module: NativePointer): NativePointer? // @@ -278,3 +4060,102 @@ expect inline fun PyUnicode_AsUTF8(unicode: NativePointer): String? // private val pyTupleSizeHandle: MethodHandle // private val pyTupleGetItemHandle: MethodHandle // private val pyTupleSetItemHandle: MethodHandle + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 25 +// Iterator Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return an iterator that works with a general sequence object, + * *seq*. The iteration ends when the sequence raises "IndexError" + * for the subscripting operation. + */ +expect fun PySeqIter_New(seq: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a new iterator. The first parameter, *callable*, can be any + * Python callable object that can be called with no parameters; each + * call to it should return the next item in the iteration. When + * *callable* returns a value equal to *sentinel*, the iteration will + */ +expect fun PyCallIter_New(callable: NativePointer, sentinel: NativePointer): NativePointer? + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Section 26 +// Weak Reference Objects +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a weak reference object for the object *ob*. This will + * always return a new reference, but is not guaranteed to create a + * new object; an existing reference object may be returned. The + * second parameter, *callback*, can be a callable object that + * receives notification when *ob* is garbage collected; it should + * accept a single parameter, which will be the weak reference object + * itself. *callback* may also be "None" or "NULL". If *ob* is not a + * weakly referenceable object, or if *callback* is not callable, + * "None", or "NULL", this will return "NULL" and raise "TypeError". + */ +expect fun PyWeakref_NewRef(ob: NativePointer, callback: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI.* + * + * Return a weak reference proxy object for the object *ob*. This + * will always return a new reference, but is not guaranteed to create + * a new object; an existing proxy object may be returned. The second + * parameter, *callback*, can be a callable object that receives + * notification when *ob* is garbage collected; it should accept a + * single parameter, which will be the weak reference object itself. + * *callback* may also be "None" or "NULL". If *ob* is not a weakly + * referenceable object, or if *callback* is not callable, "None", or + * "NULL", this will return "NULL" and raise "TypeError". + */ +expect fun PyWeakref_NewProxy(ob: NativePointer, callback: NativePointer): NativePointer? + +/** + * *Return value: New reference.* + * *Part of the Stable ABI since version 3.13.* + * + * Resolve the weak reference *ref* to its referent. + * + * Returns a **new (strong) reference** to the referent, which the caller owns and must release + * 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 + * 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 + * it does not cost support for the version this build defaults to. + */ +expect fun PyWeakref_GetRef(ref: NativePointer): NativePointer? + +/** + * *Part of the Stable ABI.* + * + * This function is called by the "tp_dealloc" handler to clear weak + * references. + * + * This iterates through the weak references for *object* and calls + * callbacks for those references which have one. It returns when all + * callbacks have been attempted. + */ +expect inline fun PyObject_ClearWeakRefs(o: NativePointer) + diff --git a/python-multiplatform/src/commonTest/README.md b/python-multiplatform/src/commonTest/README.md new file mode 100644 index 000000000..60bcf485b --- /dev/null +++ b/python-multiplatform/src/commonTest/README.md @@ -0,0 +1,277 @@ +# Which tests run where, and why + +Test placement in this module determines coverage: a test in `commonTest` runs on every target, +a test in a platform source set runs only there. That placement has drifted from "what is actually +platform-independent" before -- `PythonProxySource.install()` was exercised only in `desktopTest` +until the generated proxies were driven from the sample and found not to install on iOS/androidNative +at all (`9040a8ce`), and `GCLeakTest` used to be duplicated across five source sets because +`src/nativeTest` existed on disk but was never wired into the source-set hierarchy. This file +records the audit that followed: what runs on which of the six execution paths, why each +platform-only file actually needs to be there, and one promotion that was tried and reverted. + +## The six execution paths and how they're wired + +| Execution path | Gradle task | Source-set chain | +|---|---|---| +| desktop | `:python-multiplatform:desktopTest` | `desktopTest -> jvmTest -> commonTest` | +| desktop, free-threaded | same task, `-PpythonFreeThreaded=true` | same chain, linked against `libpython3.14t` instead of `libpython3.14` | +| iOS simulator | `:python-multiplatform:iosSimulatorArm64Test` | `iosSimulatorArm64Test -> nativeTest -> commonTest` | +| androidNative | `:python-multiplatform:androidNative{Arm64,X64}Test` (custom `adb`-driving task; `compileTestKotlinAndroidNativeArm64` for a device-free check) | `androidNative*Test -> artTest -> nativeTest -> commonTest` | +| Android (ART) | `connectedAndroidTest` (instrumented, needs a device/emulator) | `androidInstrumentedTest -> jvmTest -> commonTest` | +| Android (host JVM) | `testDebugUnitTest` / `testReleaseUnitTest` | `androidUnitTest -> commonTest` (default KMP wiring; does **not** go through `jvmTest`) | + +Two things fall out of this that are easy to get backwards: + +- **`jvmTest` has no files of its own.** It exists only to give `desktopTest` and + `androidInstrumentedTest` a shared ancestor below `commonTest`. Anything placed there would run + on both, but nothing currently is (see "not done" below for why `StringMarshallingTest` isn't). +- **`androidUnitTest` and `androidInstrumentedTest` are two separate compilations of `commonTest`**, + not one target with two names. `androidUnitTest` runs on the host JVM, which cannot load the + arm64/x86_64 `.so`, so the interpreter itself is unreachable there -- it exists to satisfy the + `expect`/`actual` surface (`forceGC()` has an `actual` in both), not to run the object-model + suite. `androidInstrumentedTest` is the one that reaches a real (or emulated) CPython, needs a + connected device, and is out of scope for this pass per the task constraints. +- **`nativeTest` was not wired into this hierarchy until `9040a8ce`.** Everything below that used + to have `iosSimulatorArm64Test`, `androidNativeX64Test` and `androidNativeArm64Test` sourced + independently, each with its own copy of anything that needed an `actual`. + +## Survey: what's in each source set + +`commonTest` (runs on all six paths) holds the entire object-model suite: `Python3Test`, +`PyObjectTest`, `PyTypeTest`, every `types/*` test, `GCLeakTest`, `RefCountTest`, +`EvalCheckpointTest`, `OwnershipLeakTest`, the upcall/reflection suites +(`UpcallTrampolineTest`, `PendingCallTest`, `PythonProxySourceTest`, `HandleTableTest`, +`UpcallTableTest`), `GilParkingTest`, `EmbedApiLowLevelTest`, `UpcallBoundaryCostTest`, +`UpcallEntryTest`, the `overhead/Benchmark*` measurement harness, `PlatformIdentityTest`, +`PyValueLazyConversionTest`, `SmokeTest`, `VersionsTest`. This is the baseline the task's +341/303/316 numbers are built from. + +Platform-only source sets, and why each file actually has to be there (not "happens to be"): + +### `desktopTest` (16 files) -- audited file by file, none are commonTest candidates + +| File | Why it cannot move | +|---|---| +| `AsyncCompletionProbeTest` | Its own KDoc: "Desktop only... wasm has no threads at all and this test could not be written for it in any form." Uses `java.lang.Thread`, `System.nanoTime()`. | +| `AsyncUpcallCancellationTest`, `AsyncUpcallDeliveryTest`, `AsyncUpcallEarlyCancellationTest` | `java.util.concurrent.{LinkedBlockingQueue,TimeUnit}` + `java.lang.Thread`. Each has a `nativeTest` counterpart (`AsyncUpcallNative*Test`) built on `NativeThread.kt` instead -- an intentional per-platform mirror, not an oversight. The native mirrors currently cover fewer cases (1 test each vs. 3 on desktop); that gap is real but is new test-writing, not a placement fix. | +| `PythonProxyInstallTest` | Same JVM concurrency primitives as the async tests above (`LinkedBlockingQueue`, `Thread`). No native/wasm mirror exists yet -- this is the test that would have caught `9040a8ce`'s bug had it existed sooner. Writing one needs `NativeThread`-based equivalents of `installLoopHarness`/`runOnLoopThread`; out of scope here (new tests, not a move). | +| `DesktopOnlyLeakTest.desktop.kt` | `java.lang.ref.Cleaner` -- JVM-only API, name says so. | +| `GCLeakTest.desktop.kt` | The `actual` half of `commonTest`'s `expect fun forceGC()` for this target. Platform glue, not a test. | +| `FreeThreadedGCGateTest`, `GCSchedulingMeasurementTest` | Pin CPython's GC-scheduling gates against **JVM process memory** specifically (`gc_should_collect_mem_usage` reads `task_info(TASK_VM_INFO)`, which tracks the JVM's footprint in this embedder). `GCSchedulingMeasurementTest.testReentrancyDuringCheckpoint` additionally calls `python.native.ffi.UpcallStub`, which only exists in `desktopMain`. | +| `CycleCollectionTest` | `sun.misc.Unsafe` via reflection (`Class.forName("sun.misc.Unsafe")`), `Class.forName("python.native.ffi.EmbedAPI_desktopKt")`. Has its own native mirror in `iosSimulatorArm64Test` (see below) and an ART mirror in `androidInstrumentedTest`. | +| `DesktopOverheadBenchmark` | Explicitly "the desktop half of the JNI/Panama overhead comparison," mirrored against Android's `JniOverheadBenchmark`. | +| `DesktopPythonTest` | Not platform-coupled by API, but redundant: `Python3.withPython { Py_GetVersion() }` is exactly `EmbedApiLowLevelTest.versionRoundTripsThroughTheCApi` (`commonTest`), and the version-prefix check duplicates `Python3Test.versionReportsTheConfiguredRelease` (`commonTest`). Left alone -- deleting a duplicate is a different kind of change than moving a misplaced one, and mixing the two was exactly what this pass was told not to do. | +| `ReachabilityMetadataTest` | `java.lang.invoke.MethodHandle`/`MethodType` reflection over the Panama bindings. | +| `SidecarLibraryLookupTest` | `System.getenv`, `System.mapLibraryName`, desktop's native-image sidecar-library resolution. | +| `StringMarshallingTest` | Calls `internedUtf8`/`encodeScratchUtf8`/`ffiReadUtf8`, which are `internal expect` in `jvmMain` (`ShapeDowncalls.kt`) with `actual`s in `desktopMain` and `androidMain` -- not visible outside the `jvmMain` subtree at all, so `commonTest` is not an option. It **could** move to the (currently empty) `jvmTest` to also cover Android, and there is already an near-identical hand-duplicated copy in `androidInstrumentedTest` (different test framework: `kotlin.test` here, raw `org.junit` + `@RunWith(AndroidJUnit4::class)` there, and missing this file's `testMultiSlotScratchReuseLimit` case). That consolidation needs a connected Android device to verify and was out of scope for this pass ("기기는 쓰지 마라"); noted here for the next one. | +| `UpcallEntryBridge.desktop.kt` | The `actual fun bindUpcallOrNull` for this target, built on `ctypes.CFUNCTYPE` over `UpcallStub` -- platform glue, not a test. | + +**Conclusion for `desktopTest`: zero promotion candidates.** Every file either touches a JVM-only +API directly, reads a JVM-specific measurement (process memory, `MethodHandle`, `Unsafe`), or is +the `actual` half of an `expect` declared elsewhere. Nothing was moved. + +### `iosSimulatorArm64Test` (1 file) after a promotion that was tried, reverted, then redone + +- `AsyncioAvailabilityProbeTest` -- deliberately scoped to this one target by its own KDoc: it + measures whether the iOS stdlib archive actually ships `_asyncio.so` (it does, despite the + xcframework itself shipping no `lib-dynload`), and is kept out of `nativeTest` on purpose so a + trap here (wasm's `import asyncio` kills the process rather than raising, per + `AsyncUpcallPortabilityTest`) stays isolated to one file. +- `CycleCollectionTest` moved out of this source set entirely -- it now lives in `nativeTest` (see + below). The first attempt (recorded here previously) moved it, ran + `compileTestKotlinAndroidNativeArm64`, and got: + + ``` + e: .../nativeTest/kotlin/python/multiplatform/ref/CycleCollectionTest.kt:79:48 Candidate 'val CPointer<*>?.rawValue: NativePtr' is inapplicable because of a receiver type mismatch. + e: .../nativeTest/kotlin/python/multiplatform/ref/CycleCollectionTest.kt:474:55 ... + e: .../nativeTest/kotlin/python/multiplatform/ref/CycleCollectionTest.kt:548:55 ... + ``` + + All three sites called `platform.posix.pthread_self()?.rawValue?.toLong()`. On Darwin, + `pthread_self()` returns `CPointer?` (`pthread_t` is `struct _opaque_pthread_t *`), so + `.rawValue` resolves. On Linux/Bionic (androidNative's libc), `pthread_t` is a plain unsigned + integral typedef, not a pointer, so the same cinterop declaration has no `.rawValue` to call. This + is a genuine POSIX ABI difference surfaced through Kotlin/Native's `platform.posix` bindings, not + a copy-paste mistake -- desktop and iOS never had to confront it before because neither had an + androidNative sibling sharing this file. That attempt was reverted, with a note on what it would + take to retry: a `currentThreadId(): Long` `expect`/`actual` seam in `nativeMain` hiding the + pointer-vs-integer difference behind one signature. + + That seam now exists (`python.multiplatform.currentThreadId`, `nativeMain/.../ThreadId.kt`) with + a Darwin `actual` in `iosMain` (`pthread_self()?.rawValue?.toLong() ?: 0L`) and a Bionic `actual` + in `artMain` (`pthread_self().convert()`) -- `internal`, since it exists for this test's benefit + and not as library API. The file now uses it at all three sites instead of the raw + `pthread_self()?.rawValue` calls, moved cleanly into `nativeTest`, and both + `compileTestKotlinAndroidNativeArm64` and `compileTestKotlinAndroidNativeX64` succeeded. + `iosSimulatorArm64Test`, `desktopTest` and `wasmJsNodeTest` still pass at the same 342/318/328 + counts as before the move (desktop unaffected since the file was never there; iOS and wasmJs + identical because nothing in the file's assertions changed, only where the thread ID comes from). + androidNative itself was not run -- no emulator in this pass, compile-only per instruction. + +- `androidInstrumentedTest` (ART) separately carries its own `CycleCollectionTest.kt` and + `StringMarshallingTest.kt` -- a third platform-specific mirror of the same two concepts, using + JNI-reachable APIs instead of Panama or cinterop. Three independent implementations of "does the + cycle collector reach across the FFI boundary" is the established pattern in this codebase for + concepts whose *mechanism* differs materially per platform (Unsafe+MethodHandle reflection on + desktop, `platform.posix`/cinterop on native, JNI on ART) even though the *behaviour* under test is + the same. That is different from "the same code, copy-pasted because a source set was reachable + and nobody noticed" -- which is what `GCLeakTest`'s pre-`9040a8ce` duplication was, and what this + pass looked for and did not otherwise find. + +## The three `CycleCollectionTest` copies, and the cost of them drifting + +The section above argues the three copies are justified. They are -- and they also drifted, in the +one way that matters, within a day of the third one being written. This records what happened, what +it would take to merge them, and what to do until then. + +### What drifted + +`643eaed9` fixed an intermittent failure in the `nativeTest` copy: a test that closes a cycle ends +holding the proxy's last reference in a Kotlin/JVM object, and once `tearDown()` drops the handle +table's root that reference is owed to the platform's cleaner thread, which can only pay it once it +is given the GIL. `testDeallocOnAThreadCPythonCreated` is the only test in the file that gives the +GIL up mid-measurement (`_t.start()`/`_t.join()`), so a leftover landing there removes a reference +from the proxy type between its two readings, and the test reports a `tp_dealloc` imbalance that +never happened. The fix was to release each proxy under the GIL the test already holds +(`disposeProxy`), settle before the first reading, and **assert that nothing was owed** so that a +reintroduction fails loudly instead of intermittently. + +That fix went into `nativeTest` only. The `androidInstrumentedTest` copy carries the same test and +the same three cycle-closing tests, and kept the debt for thirteen hours until it produced +`tp_dealloc ran on a CPython-created thread but did not balance the instance's reference to its +heap type expected:<5> but was:<2>` -- a drop of exactly three, which is exactly how many tests in +that file leave a proxy behind. Both copies now carry the fix. + +The failure is rare: it needs an ART collection to fire *inside* the measuring test's `withGIL`, +after the first reading, so that the cleaner is still blocked when the worker hands the GIL over. +Twenty full-suite instrumented runs (ten each on API 26 and API 36) on the unfixed code produced +none. **The debt behind it is not rare**: with the disposals removed and the settle step left in, +the probe reports 3 owed on both API levels on every run. That asymmetry is the argument for +keeping the settle-and-assert step -- it turns a race that hides for twenty runs into a number that +is the same every time. + +`desktopTest`'s copy also creates the debt (its `Node.close()` existed for this and was never +called) and now disposes of it too. It has no settle step, because it has nothing that could +observe one: no test in that file gives the GIL up between two readings. That is recorded in the +file's KDoc, along with the condition under which it stops being true. + +### The copies are not the same test three times + +| Test | desktop | native (iOS + androidNative) | ART | +|---|---|---|---| +| `testCycleCollectionByGC` | yes | yes | yes | +| `testHandleReleasedWhenProxyDiesWithoutCycle` | yes | yes | yes | +| `testHandleSurvivesWhenTraverseReportsNothing` (negative control) | **no** | yes | yes | +| `testDeallocOnAThreadCPythonCreated` | **no** | yes | yes | +| `testCycleCollectedOnAThreadCPythonCreated` | **no** | yes | yes | +| `traverseReportsTheHeldPointerForARegisteredClass` | no | no | yes (JNI-only concern) | + +Desktop is missing the negative control and both foreign-thread tests. The foreign-thread pair is a +genuinely different question there -- a Panama upcall stub entered from a thread the JVM has never +seen is not the same mechanism as `AttachCurrentThreadAsDaemon` or an unattached Kotlin/Native +callback -- so that is new test-writing rather than a placement fix, and it is not done. It is, +however, the reason desktop currently cannot exhibit this flake, and the reason a settle step there +would be guarding nothing today. + +### Can they be merged? + +Most of the body could be, and less is missing than the three files suggest. Everything the tests +call into CPython with -- `PyObject_CallObject`, `PyRun_SimpleString`, `Py_DecRef`, +`PyImport_ImportModule`, `PyObject_GetAttrString`, `PyDict_SetItemString` -- is already an `expect` +in `commonMain/python/native/ffi/EmbedAPI.kt`, and `PyObject(addr.toNativePointer()!!, borrowed = +false)` already works unchanged on all three (the desktop copy reaches the same constructor through +`Class.forName` reflection, which nothing appears to require -- `Long.toNativePointer()` is a public +`actual` in `desktopMain` and the constructor is public; the other two call both directly). + +Exactly three operations are not common, and they are the ones each copy hand-rolls differently: + +1. **storing a handle into a proxy's relative type data** -- `sun.misc.Unsafe.putLong` on desktop, a + `CPointer` store on native, `ProxyTypeFactory.setHandle` on ART; +2. **reading an object header's `ob_refcnt`** -- `Unsafe.getLong` (plus the free-threaded + two-header form, which only desktop has to handle), a `LongVar` load, `bindings.obRefCnt`; +3. **the calling thread's identity** -- `currentThreadId()` (`nativeMain`, `internal`) versus + `Thread.currentThread().id`. + +(1) and (2) are already implemented inside every platform's `ProxyTypeFactory`/`bindings` -- +`PyObject_GetTypeData` exists in all four `bindings` objects -- so promoting `setHandle`, `handleOf` +and an `obRefCnt` equivalent onto the `expect object ProxyTypeFactory` (which today declares only +`createProxyType`) is wiring, not new mechanism. (3) needs the `nativeMain` seam widened to a +`commonTest` `expect`. + +Two things still block a straight move to `commonTest` afterwards, and both have precedent for how +to handle them: + +- **wasmJs has no threads at all.** The two foreign-thread tests cannot exist there in any form, and + `AsyncUpcallPortabilityTest` is the standing record of what happens when a wasm-hostile test is + placed hopefully. This needs the `expect val` gate `GCLeakTest` already uses for + `cleanerReleasesAutomatically`. +- **`androidUnitTest` is a second compilation of `commonTest` on a host JVM that cannot load the + `.so`.** Anything moved has to compile there; it will not run against an interpreter. + +The desktop free-threaded build is a third wrinkle: its `typeRefCount()` reads a different object +header (`ob_tid` at offset 0, count split across `ob_ref_local`/`ob_ref_shared`) and needs a +`PyGC_Collect()` on either side of the instantiation loop to materialise deferred references. That +belongs behind the `obRefCnt` seam rather than in the test, which is another argument for (2). + +**Assessment: mergeable, worth doing, and not a small change** -- it moves three operations into +production `expect`/`actual` surface, which is a wider blast radius than the flake that prompted it. +Not attempted here. + +### Until then: how to stop the three drifting again + +- The settle-and-assert step is the drift detector, not just the fix. Both copies that can observe + the debt now assert it is zero, with the measured non-zero value in the message, so removing a + `disposeProxy` call fails deterministically on the next run instead of once every few dozen. +- **The invariant to carry to any new test in any of the three files:** if a test reads a reference + count across a point where it releases the GIL, it must first settle pending finalisation and + assert nothing was owed. Every test that closes a cycle must release its own proxy under the GIL + it already holds. +- A fix to one copy is a fix to three. The three files are `desktopTest/`, `nativeTest/` and + `androidInstrumentedTest/` under `kotlin/python/multiplatform/ref/CycleCollectionTest.kt`; the + method names are deliberately identical across them (see the table above), so + `grep -rn "" python-multiplatform/src/*/kotlin/python/multiplatform/ref/CycleCollectionTest.kt` + answers "did this land everywhere" in one command. + +## Reverse direction: commonTest tests that could be meaningless on some platform + +The established gate for this is `commonTest`'s `expect val` / `expect fun` pattern -- +`GCLeakTest.kt` gates on `cleanerReleasesAutomatically` and an `expect class CollectorTestResult` +so wasm's promise-based finalisation and every other target's synchronous one can share one test +body. `WasmFinalizationTest` (`wasmJsTest`) is the standing control that proves the wasm adapter +actually awaits the returned `Promise`, so a regression can't silently stop running those +assertions. + +No new case of this kind was found. The baseline run (341/303/316, all green) covers every +`commonTest` file across desktop, iOS simulator and wasmJs already, including the ones that touch +threading (`GilParkingTest`, `PendingCallTest`, `AsyncUpcallPortabilityTest`, +`PythonProxySourceTest`, `overhead/BenchmarkTest`, `RefCountTest`, `EvalCheckpointTest`) -- if one of +them were meaningless on a given target it would show up as a failure or a hang there, not as a +silent pass, and none did. `AsyncUpcallPortabilityTest` is itself the existing record of the one +trap this category has produced before: `import asyncio` killing the wasm instance outright rather +than raising, which is why the async upcall tests stay off `commonTest` and in per-platform files +instead. + +## What this pass did not touch + +- The `NativeThread`-based async upcall mirrors in `nativeTest` cover fewer cases than their + `desktopTest` originals (1 test vs. 3, and `PythonProxyInstallTest` has no native mirror at all). + Real coverage gap, but writing new tests is not a placement fix. +- `StringMarshallingTest`: `desktopTest` and `androidInstrumentedTest` hand-duplicate the same + four cases, and the Android copy is missing `testMultiSlotScratchReuseLimit`. A `jvmTest` + consolidation is plausible (the underlying `internal expect` already spans both) but needs a + connected device to verify and was out of scope here. +- `DesktopPythonTest` duplicates two `commonTest` assertions already in `EmbedApiLowLevelTest` and + `Python3Test`. Left in place; removing a duplicate is a different change from moving a misplaced + one. + +## Validation + +``` +rm -rf build/test-results/{desktopTest,iosSimulatorArm64Test,wasmJsNodeTest} +./gradlew :python-multiplatform:desktopTest :python-multiplatform:iosSimulatorArm64Test :python-multiplatform:wasmJsNodeTest --console=plain +./gradlew :python-multiplatform:compileTestKotlinAndroidNativeArm64 --console=plain +``` + +Result after the `CycleCollectionTest` revert: desktop 341/0 failed/1 skipped, iOS simulator +303/0, wasmJs 316/0 -- identical to the pre-existing baseline, since nothing was ultimately moved. +`compileTestKotlinAndroidNativeArm64` succeeds. diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/PlatformIdentityTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/PlatformIdentityTest.kt new file mode 100644 index 000000000..1408489b8 --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/PlatformIdentityTest.kt @@ -0,0 +1,173 @@ +package python.multiplatform + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + + +/** + * The contract every [Platform] implementation owes, checked on whichever target is running. + * + * This exists because `AndroidNativePlatform` shipped with `arch = "Kotlin/Native"`, + * `version = "Kotlin/Native"` and `versionCode = 0` -- three `TODO`s that compiled, satisfied the + * interface, and printed `Android Kotlin/Native (SDK 0, Kotlin/Native)` into every measurement + * header the androidNative binary produced. Nothing was checking that these fields say anything + * about the machine, so nothing noticed for as long as no androidNative test actually ran. + * + * The assertions here are the ones that hold on *any* device, so they can live in `commonTest` and + * guard all five targets at once. The device-specific half -- that an API 26 emulator and an API 36 + * emulator report *different* values -- cannot be checked from inside a single test process, which + * only ever sees one device; that check is `AndroidNativeDeviceIdentityTest` in `artTest`, which + * compares against values the Gradle harness read over adb. + */ +class PlatformIdentityTest { + + /** Values that were once used as stand-ins for a real read, in this file's own history. */ + private val placeholders = setOf("kotlin/native", "unknown", "todo", "", "null") + + @Test + fun archNamesAMachineAndNotARuntime() { + val arch = currentPlatform.arch + assertTrue( + arch.lowercase() !in placeholders, + "Platform.arch is a placeholder (\"$arch\"). It has to name the machine the code is " + + "running on -- `Platform.is64Bit`, `isArm` and `isX86` all pattern-match on it, and " + + "`manager.kt` picks which CPython to load from `isArm`." + ) + } + + @Test + fun versionNamesTheOperatingSystemRelease() { + val version = currentPlatform.version + assertTrue( + version.lowercase() !in placeholders, + "Platform.version is a placeholder (\"$version\"). Every other implementation puts the " + + "user-visible OS release here (Build.VERSION.RELEASE, UIDevice.systemVersion, " + + "os.version)." + ) + } + + /** + * `versionText` is `versionCode` with a label on it, so one is present exactly when the other + * is. Both directions matter, and the missing one is what let a defect through: iOS declared + * `versionText = "Build $versionCode"` unconditionally, so a null code was *interpolated* rather + * than propagated and the field read `"Build null"` -- which `Platform.name` then carried into + * every banner as `iOS 26.2 (Build null, arm64) / Native`. + * + * This test used to only check the `code != null && text != null` corner, which is precisely the + * corner that case is not in, so it passed throughout. Asserting the full implication is the + * point of the test; weakening it back to the guarded form re-admits the bug. + */ + @Test + fun versionTextAgreesWithVersionCode() { + val code = currentPlatform.versionCode + val text = currentPlatform.versionText + + if (code == null) { + assertNull( + text, + "versionCode is null but versionText is \"$text\". A platform that cannot name a " + + "version number has nothing to label, so versionText has to be null too -- " + + "which is what Platform.versionText's own default getter does. A non-null " + + "value here means the null was interpolated into a string instead of " + + "propagated." + ) + return + } + + assertNotNull( + text, + "versionCode is $code but versionText is null; a platform that knows the number has " + + "no reason to withhold the labelled form of it." + ) + assertTrue( + text.contains(code.toString()), + "versionText (\"$text\") does not mention versionCode ($code); the two are supposed " + + "to be the same number, one of them labelled." + ) + assertTrue( + "null" !in text.lowercase(), + "versionText (\"$text\") contains the literal word \"null\", so a null was formatted " + + "into it rather than handled." + ) + } + + /** + * [Platform.name] is the user-visible rendering of all of the above, and it is where the + * `"Build null"` defect was actually observable. Its inputs are each checked above; this checks + * that assembling them does not reintroduce a placeholder. + */ + @Test + fun nameDoesNotRenderAnyAbsentField() { + val name = currentPlatform.name + assertTrue( + "null" !in name.lowercase(), + "Platform.name is \"$name\". Absent fields are nullable so that `name` can leave them " + + "out -- it drops versionText when it is null and platformVersion when it is null -- " + + "so the word \"null\" appearing in the rendering means some field formatted a null " + + "instead of omitting it." + ) + } + + @Test + fun nameCarriesTheFieldsItIsBuiltFrom() { + val name = currentPlatform.name + assertTrue(name.contains(currentPlatform.arch), "'$name' omits arch '${currentPlatform.arch}'") + assertTrue(name.contains(currentPlatform.version), "'$name' omits version '${currentPlatform.version}'") + assertTrue(name.contains(currentPlatform.os.toString()), "'$name' omits os '${currentPlatform.os}'") + } + + @Test + fun platformTypePredicatesAgreeWithPlatformType() { + val type = currentPlatform.platformType + assertEquals(type == PlatformType.JVM, currentPlatform.isJvm, "isJvm disagrees with $type") + assertEquals(type == PlatformType.Native, currentPlatform.isNative, "isNative disagrees with $type") + } + + @Test + fun osPredicatesAgreeWithOs() { + val os = currentPlatform.os + assertEquals(os == OSType.Android, currentPlatform.isAndroid, "isAndroid disagrees with $os") + assertEquals(os == OSType.IOS, currentPlatform.isIos, "isIos disagrees with $os") + } + + /** + * Android is the one OS in this tree reached through two entirely separate implementations -- + * `AndroidPlatform` over the ART VM and `AndroidNativePlatform` over cinterop -- and they are + * supposed to describe the same device the same way. Whichever of the two is running has to + * satisfy this, so the two cannot drift. + */ + @Test + fun androidReportsAnApiLevelAndASupportedAbi() { + if (!currentPlatform.isAndroid) return + + val code = assertNotNull( + currentPlatform.versionCode, + "An Android platform must report an API level; Build.VERSION.SDK_INT and " + + "android_get_device_api_level() both have one." + ) + assertTrue( + code >= 21, + "API level $code is below this module's minSdk (21), so it is not a level any " + + "supported device reports -- 0 is what the androidNative placeholder used to return." + ) + assertEquals( + "SDK $code", currentPlatform.versionText, + "Android's versionText spelling is fixed by AndroidPlatform; androidNative has to match it." + ) + + // `abiFilters` and the packaged CPython builds cover exactly arm64-v8a and x86_64, so the + // kernel machine name can only be aarch64 or x86_64 here -- and `manager.kt` routes on + // precisely this distinction. + assertTrue( + currentPlatform.isArm != currentPlatform.isX86, + "arch '${currentPlatform.arch}' is classified as neither arm nor x86, or as both." + ) + assertTrue( + currentPlatform.is64Bit, + "arch '${currentPlatform.arch}' is not 64-bit, but only arm64-v8a and x86_64 are built." + ) + } +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/SmokeTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/SmokeTest.kt new file mode 100644 index 000000000..d124b643f --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/SmokeTest.kt @@ -0,0 +1,22 @@ +package python.multiplatform + +import python.native.ffi.Py_IsInitialized +import kotlin.test.Test +import kotlin.test.assertTrue + + +/** + * The narrowest possible check that the test harness is wired end to end: + * the test binary links against the embedded CPython and can call into it. + * + * [Py_IsInitialized] is one of the few C API functions that is safe to call + * before `Py_Initialize()`, so it verifies linkage without requiring a + * usable `PYTHONHOME`. + */ +class SmokeTest { + @Test + fun embeddedPythonIsLinked() { + val state = Py_IsInitialized() + assertTrue(state == 0 || state == 1, "Py_IsInitialized() returned $state, expected 0 or 1") + } +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/VersionsTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/VersionsTest.kt new file mode 100644 index 000000000..a1d992638 --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/VersionsTest.kt @@ -0,0 +1,61 @@ +package python.multiplatform + +import kotlin.test.Test +import kotlin.test.assertEquals +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` — + * 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. + * + * These assertions are written against [BuildConfig.pythonFreeThreaded] rather than hard-coding one + * flavour, so the same suite is meaningful under `-PpythonFreeThreaded=true`. + */ +class VersionsTest { + + @Test + fun abiFlagsFollowsTheConfiguredFlavour() { + assertEquals( + if (BuildConfig.pythonFreeThreaded) "t" else "", + Versions.currentVersion.abiFlags, + "abiFlags must be CPython's flags suffix: 't' free-threaded (PEP 703), empty otherwise" + ) + } + + @Test + fun taggedVersionStringNamesTheArtefactsOnDisk() { + val v = Versions.currentVersion + assertEquals(v.compactVersionString + v.abiFlags, v.taggedVersionString) + if (BuildConfig.pythonFreeThreaded) { + assertTrue( + v.taggedVersionString.endsWith("t"), + "a free-threaded build must ask for libpython${v.compactVersionString}t, " + + "which is the only shared library such an install contains" + ) + } else { + assertEquals(v.compactVersionString, v.taggedVersionString) + } + } + + @Test + fun parsesAPreReleaseVersion() { + // 3.15.0rc1 exists in python-build-standalone 20260807 and is the first release line with + // an `abi3t` stable ABI. Parsing must not reject it, and the patch component must not be + // read as 0 when it is really "0rc1". + val v = Versions.parse("3.15.0rc1") + assertEquals("3.15", v.compactVersionString) + assertEquals(3, v.majorVersion) + assertEquals(15, v.minorVersion) + assertEquals(0, v.patchVersion) + } + + @Test + fun compactVersionStringIsMajorMinorOnly() { + assertEquals("3.14", Versions.parse("3.14.7").compactVersionString) + } +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/env/InterpreterAvailabilityTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/env/InterpreterAvailabilityTest.kt new file mode 100644 index 000000000..f43c7fc42 --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/env/InterpreterAvailabilityTest.kt @@ -0,0 +1,45 @@ +package python.multiplatform.env + +import python.multiplatform.Versions +import python.multiplatform.ffi.Python3 +import python.native.ffi.Py_GetVersion +import python.native.ffi.Py_IsInitialized +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + + +/** + * Establishes whether a real CPython interpreter can be brought up inside the test binary. + * + * Everything else in the suite depends on the answer: without a live interpreter only the + * 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 + * `extractIosSimulatorStdlib` in `build.gradle.kts`. + */ +class InterpreterAvailabilityTest { + + @Test + fun interpreterInitialises() { + Python3.initialize(silent = false) + + assertEquals(1, Py_IsInitialized(), "Py_IsInitialized() should report 1 after initialize()") + assertTrue(Python3.isInitialized, "Python3.isInitialized should be true after initialize()") + } + + @Test + fun interpreterReportsItsVersion() { + Python3.initialize(silent = true) + + val version = Py_GetVersion() + println("Embedded CPython version: $version") + // The configured version, not a literal -- see EmbedApiLowLevelTest for why. + val expected = Versions.currentVersion.compactVersionString + assertTrue( + version != null && version.startsWith(expected), + "expected a $expected.x version string, got: $version" + ) + } +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/env/PythonHomeCheckSmokeTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/env/PythonHomeCheckSmokeTest.kt new file mode 100644 index 000000000..15b05f06e --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/env/PythonHomeCheckSmokeTest.kt @@ -0,0 +1,30 @@ +package python.multiplatform.env + +import kotlin.test.Test + +/** + * The subset of [PythonHomeCheck]'s behaviour that has to hold on every target, including + * Kotlin/Wasm, where `PYTHONHOME` names nothing a user sets -- the stdlib is preloaded into the + * virtual filesystem at build time (see [readEnvVar]'s wasmJs actual). The platform-specific + * diagnoses for a bad `PYTHONHOME` live in `PythonHomeCheckTest` (desktopTest): they depend on + * real file permissions, which `pathIsAccessible`'s wasmJs actual does not implement. + */ +class PythonHomeCheckSmokeTest { + + @Test + fun unsetOrBlankHomeIsNotDiagnosed() { + PythonHomeCheck.verifyOrThrow(null) + PythonHomeCheck.verifyOrThrow("") + PythonHomeCheck.verifyOrThrow(" ") + } + + @Test + fun realEnvironmentPassesInThisTestBinary() { + // Whatever this test binary's real PYTHONHOME situation is -- set to a valid prefix, or + // unset because the target has no such concept -- Python3.initialize() already relies on + // it not throwing here (PythonTestFixture initialises the shared interpreter every other + // test in this suite uses). If this throws, every functional test downstream would too, + // with a less specific message. + PythonHomeCheck.verifyOrThrow() + } +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/BuiltinCompiledTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/BuiltinCompiledTest.kt new file mode 100644 index 000000000..5d3ef407f --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/BuiltinCompiledTest.kt @@ -0,0 +1,38 @@ +package python.multiplatform.ffi + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Issue #42 / SPEC N-7: `compiled` is a builtin installed at interpreter initialisation. */ +class BuiltinCompiledTest { + + private fun evalStr(expr: String): String = PythonTestFixture.eval(expr).toString() + + @Test + fun compiledResolvesWithoutImportAndIsIdentity() = PythonTestFixture.withInterpreter { + assertEquals("True", evalStr("compiled(len) is len")) + Python3.exec("def _bc_f(): return 1\n_bc_r = compiled(_bc_f) is _bc_f") + assertEquals("True", evalStr("_bc_r")) + // It must live in builtins, not leak into __main__. + assertEquals("False", evalStr("'compiled' in globals()")) + } + + @Test + fun existingBuiltinCompiledIsNotOverwritten() = PythonTestFixture.withInterpreter { + Python3.exec( + """ + import builtins as _b + _bc_orig = _b.compiled + _bc_sentinel = lambda f: f + _b.compiled = _bc_sentinel + """.trimIndent() + ) + try { + Python3.installBuiltinCompiled() + assertEquals("True", evalStr("_b.compiled is _bc_sentinel")) + } finally { + Python3.exec("_b.compiled = _bc_orig") + } + assertEquals("True", evalStr("_b.compiled is _bc_orig")) + } +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/GilParkingTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/GilParkingTest.kt new file mode 100644 index 000000000..02161ac79 --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/GilParkingTest.kt @@ -0,0 +1,48 @@ +package python.multiplatform.ffi + +import python.native.ffi.* +import kotlin.test.Test + +class GilParkingTest { + + @Test + fun parkThenReenterSameThreadIsolatesTheFailure() { + println("--- START OF GilParkingTest ---") + val ready = PythonTestFixture.available + println("Fixture ready: $ready") + + // Step 1: bare re-entry, no Python C API call at all inside the guard except one that + // reads state without touching bytecode. + println("Executing Step 1...") + withGIL { + val after = PyGILState_GetThisThreadState() + println("after Ensure: $after") + } + println("Step 1 completed successfully.") + + // Step 2: a pure-C call that cannot execute Python bytecode. + println("Executing Step 2...") + withGIL { + Py_IsInitialized() + } + println("Step 2 completed successfully.") + + // Step 3: the exact call that crashed today. + println("Executing Step 3...") + withGIL { + PyImport_ImportModule("sys") + } + println("Step 3 completed successfully.") + + // Step 4 (only if Step 3 crashes): bypass import_ensure_initialized's Python-level path + // entirely, staying in pure C, to see whether a plain cache lookup alone is safe: + println("Executing Step 4...") + withGIL { + val modules = PySys_GetObject("modules") + PyDict_GetItemString(modules!!, "sys") + } + println("Step 4 completed successfully.") + + println("--- END OF GilParkingTest ---") + } +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PyObjectTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PyObjectTest.kt new file mode 100644 index 000000000..fa638b884 --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PyObjectTest.kt @@ -0,0 +1,149 @@ +package python.multiplatform.ffi + +import python.multiplatform.ffi.exceptions.PyException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Functional tests for [PyObject]: attribute get/set/delete, equality and + * hashing, `str`/`repr`, the object's Python type, calling via `invoke`, + * `isCallable`, truthiness and `richCompare`. + * + * This header used to say [PyObject.invoke] and friends were `TODO` stubs + * expected to fail with [NotImplementedError]. They are all implemented now, + * so every test here is a regression test and any failure is a real one. + */ +class PyObjectTest { + + /** A fresh, attribute-settable Python object: an instance of a throwaway class. */ + private fun freshInstance(): PyObject { + Python3.exec( + "class _PyObjectTestSubject:\n" + + " pass\n" + ) + return PythonTestFixture.eval("_PyObjectTestSubject()") + } + + @Test + fun getAttrReturnsExistingAttribute() = PythonTestFixture.withInterpreter { + val math = Python3.import("math") + val pi = math.getAttr("pi") + assertTrue(pi.toString().startsWith("3.14")) + } + + @Test + fun getAttrOnMissingAttributeThrowsPyException() = PythonTestFixture.withInterpreter { + val math = Python3.import("math") + assertFailsWith { + math.getAttr("this_attribute_does_not_exist") + } + } + + @Test + fun getAttrOrNullReturnsNullForMissingAttribute() = PythonTestFixture.withInterpreter { + val math = Python3.import("math") + assertNull(math.getAttrOrNull("this_attribute_does_not_exist")) + } + + @Test + fun setAttrThenGetAttrRoundTrips() = PythonTestFixture.withInterpreter { + val obj = freshInstance() + val value = PythonTestFixture.eval("123") + obj.setAttr("custom_field", value) + assertEquals("123", obj.getAttr("custom_field").toString()) + } + + @Test + fun delAttrRemovesAPreviouslySetAttribute() = PythonTestFixture.withInterpreter { + val obj = freshInstance() + obj.setAttr("custom_field", PythonTestFixture.eval("'gone soon'")) + obj.delAttr("custom_field") + assertFailsWith { + obj.getAttr("custom_field") + } + } + + @Test + fun delAttrOnMissingAttributeDoesNotThrow() = PythonTestFixture.withInterpreter { + val obj = freshInstance() + // delAttrOrNull is the non-throwing variant; must be safe to call even when the attribute was never set. + obj.delAttrOrNull("never_existed") + } + + @Test + fun equalsIsTrueForTheSameUnderlyingPythonObject() = PythonTestFixture.withInterpreter { + Python3.exec("_shared_for_equality_test = object()") + val first = PythonTestFixture.eval("_shared_for_equality_test") + val second = PythonTestFixture.eval("_shared_for_equality_test") + assertEquals(first, second, "two PyObject wrappers around the same underlying Python object should be equal") + } + + @Test + fun equalsIsFalseForDifferentPythonObjects() = PythonTestFixture.withInterpreter { + val a = freshInstance() + val b = freshInstance() + assertFalse(a == b, "two distinct Python instances must not compare equal") + } + + @Test + fun toStringMatchesPythonStr() = PythonTestFixture.withInterpreter { + assertEquals("42", PythonTestFixture.eval("42").toString()) + assertEquals("hello", PythonTestFixture.eval("'hello'").toString()) + } + + @Test + fun hashCodeIsConsistentForTheSameObject() = PythonTestFixture.withInterpreter { + val obj = freshInstance() + assertEquals(obj.hashCode(), obj.hashCode()) + } + + @Test + fun getTypeReturnsTheObjectsPythonType() = PythonTestFixture.withInterpreter { + val intType = PythonTestFixture.eval("42").Type + assertEquals("int", intType.name) + } + + @Test + fun invokeCallsAPythonCallable() = PythonTestFixture.withInterpreter { + Python3.exec( + "def _add_one_for_invoke_test(x):\n" + + " return x + 1\n" + ) + val fn = PythonTestFixture.eval("_add_one_for_invoke_test") + val result = fn.invoke(PythonTestFixture.eval("41")) + assertEquals("42", result.toString()) + } + + @Test + fun isCallableDistinguishesFunctionsFromValues() = PythonTestFixture.withInterpreter { + Python3.exec("def _callable_for_test(): pass") + val fn = PythonTestFixture.eval("_callable_for_test") + val value = PythonTestFixture.eval("42") + assertTrue(fn.isCallable) + assertFalse(value.isCallable) + } + + @Test + fun isTruthyMatchesPythonBoolConversion() = PythonTestFixture.withInterpreter { + assertTrue(PythonTestFixture.eval("1").isTruthy) + assertFalse(PythonTestFixture.eval("0").isTruthy) + assertFalse(PythonTestFixture.eval("[]").isTruthy) + } + + @Test + fun reprMatchesPythonRepr() = PythonTestFixture.withInterpreter { + assertEquals("'hello'", PythonTestFixture.eval("'hello'").repr()) + } + + @Test + fun richCompareEvaluatesPythonComparisonOperators() = PythonTestFixture.withInterpreter { + val one = PythonTestFixture.eval("1") + val two = PythonTestFixture.eval("2") + assertTrue(one.richCompare(two, PyCompareOp.LT)) + assertFalse(one.richCompare(two, PyCompareOp.GT)) + } +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PyTypeTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PyTypeTest.kt new file mode 100644 index 000000000..b6e6109a9 --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PyTypeTest.kt @@ -0,0 +1,131 @@ +package python.multiplatform.ffi + +import python.multiplatform.ffi.exceptions.errors.PyTypeError +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Functional tests for [PyType]: name/base-type introspection, MRO, + * `isInstance`/`isSubtypeOf`, construction via `invoke`, and the `__dict__` + * namespace. + * + * This header used to say the members were `TODO` stubs expected to fail with + * [NotImplementedError]. They are all implemented now, so every test here is a + * regression test and any failure is a real one. + */ +class PyTypeTest { + + @Test + fun nameReflectsThePythonTypeName() = PythonTestFixture.withInterpreter { + val intType = PythonTestFixture.eval("42").Type + assertEquals("int", intType.name) + + val strType = PythonTestFixture.eval("'x'").Type + assertEquals("str", strType.name) + } + + @Test + fun baseTypeOfIntIsObject() = PythonTestFixture.withInterpreter { + val intType = PythonTestFixture.eval("42").Type + assertEquals("object", intType.baseType.name) + } + + @Test + fun baseTypesOfBoolIncludesInt() = PythonTestFixture.withInterpreter { + val boolType = PythonTestFixture.eval("True").Type + val baseNames = boolType.baseTypes.map { it.name } + assertTrue("int" in baseNames, "expected bool.__bases__ to include int, got $baseNames") + } + + @Test + fun mroOfBoolIncludesIntAndObject() = PythonTestFixture.withInterpreter { + val boolType = PythonTestFixture.eval("True").Type + val mroNames = boolType.mro.map { it.name } + assertTrue(mroNames.containsAll(listOf("bool", "int", "object")), "expected bool.__mro__ to include bool/int/object, got $mroNames") + } + + @Test + fun isSubtypeOfReflectsPythonSubclassing() = PythonTestFixture.withInterpreter { + val boolType = PythonTestFixture.eval("True").Type + val intType = PythonTestFixture.eval("42").Type + assertTrue(boolType.isSubtypeOf(intType), "bool should be a subtype of int in Python") + } + + @Test + fun isInstanceRecognisesActualInstances() = PythonTestFixture.withInterpreter { + val intType = PythonTestFixture.eval("42").Type + val fortyTwo = PythonTestFixture.eval("42") + val hello = PythonTestFixture.eval("'hello'") + assertTrue(intType.isInstance(fortyTwo)) + assertTrue(!intType.isInstance(hello)) + } + + @Test + fun invokeConstructsANewInstance() = PythonTestFixture.withInterpreter { + val intType = PythonTestFixture.eval("42").Type + val result = intType.invoke(PythonTestFixture.eval("'7'")) + assertEquals("7", result.toString()) + } + + @Test + fun castRaisesForIncompatibleObjects() = PythonTestFixture.withInterpreter { + val intType = PythonTestFixture.eval("42").Type + val notAnInt = PythonTestFixture.eval("'not an int'") + assertFailsWith { + intType.cast(notAnInt) + } + } + + /** + * `type.__dict__` is a **`mappingproxy`**, not a `dict`. Handing that pointer to a [PyDict] + * is not merely inelegant, it is actively corrupting: `PyDict_Size`/`PyDict_Items` reject a + * non-dict with `PyErr_BadInternalCall()`, so every read returns a sentinel (`-1`/`NULL`) + * *and leaves the error indicator set* for whatever Python call runs next -- the same class + * of silent poisoning the `*OrNull` helpers on [PyObject] exist to avoid. + * + * The names asserted here are the ones the probe class declares, so this fails both if the + * mapping comes back empty (the sentinel path) and if it is somebody else's namespace. + */ + @Test + fun dictExposesTheTypesOwnNamespace() = PythonTestFixture.withInterpreter { + Python3.exec( + """ + class PyTypeDictProbe: + probe_marker = 7 + def probe_method(self): + return 1 + py_type_dict_probe_instance = PyTypeDictProbe() + """.trimIndent() + ) + val probeType = PythonTestFixture.eval("py_type_dict_probe_instance").Type + assertEquals("PyTypeDictProbe", probeType.name) + + val namespace = probeType.dict + val keyNames = namespace.keys.map { it.toString() } + assertTrue( + keyNames.containsAll(listOf("probe_marker", "probe_method")), + "expected PyTypeDictProbe.__dict__ to carry the names the class declares, got $keyNames" + ) + assertEquals( + keyNames.size, namespace.size, + "size must agree with the entries actually readable; a mismatch means PyDict_Size " + + "returned its -1 error sentinel" + ) + } + + /** + * A read of `__dict__` must not leave the error indicator set. If it does, the *next* + * unrelated Python call is the one that fails, which is why this asserts on a call made + * afterwards rather than on the read itself. + */ + @Test + fun readingDictLeavesNoPendingError() = PythonTestFixture.withInterpreter { + val intType = PythonTestFixture.eval("42").Type + val namespace = intType.dict + assertTrue(namespace.size > 0, "int.__dict__ is not empty") + // Would surface the leftover SystemError from PyErr_BadInternalCall if one were pending. + assertEquals("3", PythonTestFixture.eval("1 + 2").toString()) + } +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/Python3Test.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/Python3Test.kt new file mode 100644 index 000000000..480ae1dee --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/Python3Test.kt @@ -0,0 +1,140 @@ +package python.multiplatform.ffi + +import python.multiplatform.Versions +import python.multiplatform.ffi.exceptions.PyException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Functional tests for [Python3]: lifecycle (`initialize`/`isInitialized`), + * script execution, evaluation, module import, and the interpreter metadata + * properties (`version`/`platform`/`copyright`/`compiler`/`buildInfo`). + * + * This header used to call these a red phase, on the grounds that the typed + * wrapper layer (PyInt, PyList, ...) covered by the other files in this + * package was still `TODO`. It is implemented now, so every test here is a + * regression test and any failure is a real one. + */ +class Python3Test { + + @Test + fun isInitializedReflectsLifecycle() = PythonTestFixture.withInterpreter { + assertTrue(Python3.isInitialized, "expected Python3.isInitialized to be true once initialize() has run") + } + + @Test + fun initializeIsIdempotent() = PythonTestFixture.withInterpreter { + // Calling initialize() again after it already succeeded must not throw or blow up state. + Python3.initialize(silent = true) + assertTrue(Python3.isInitialized) + } + + @Test + fun execRunsAStatement() = PythonTestFixture.withInterpreter { + Python3.exec("x_for_exec_test = 1 + 2") + val result = PythonTestFixture.eval("x_for_exec_test") + assertTrue(result.toString() == "3", "expected the assigned global to read back as 3, got '$result'") + } + + @Test + fun execOnInvalidSyntaxThrowsPyException() = PythonTestFixture.withInterpreter { + assertFailsWith("exec() of invalid Python source should raise a PyException") { + Python3.exec("this is not valid python !!!") + } + } + + @Test + fun evalReturnsExpressionResult() = PythonTestFixture.withInterpreter { + val result = PythonTestFixture.eval("21 * 2") + assertTrue(result.toString() == "42", "expected '42', got '$result'") + } + + @Test + fun evalOnUndefinedNameThrowsPyException() = PythonTestFixture.withInterpreter { + assertFailsWith { + PythonTestFixture.eval("this_name_is_not_defined_anywhere") + } + } + + @Test + fun importReturnsAUsableModule() = PythonTestFixture.withInterpreter { + val math = Python3.import("math") + val pi = math.getAttr("pi") + assertTrue(pi.toString().startsWith("3.14"), "expected math.pi to start with 3.14, got '$pi'") + } + + @Test + fun importOfUnknownModuleThrows() = PythonTestFixture.withInterpreter { + assertFailsWith { + Python3.import("this_module_does_not_exist_anywhere_xyz") + } + } + + @Test + fun versionReportsTheConfiguredRelease() = PythonTestFixture.withInterpreter { + // Was `versionReportsPython314`, asserting the literal. See EmbedApiLowLevelTest. + val expected = Versions.currentVersion.compactVersionString + assertTrue( + Python3.version.startsWith(expected), + "expected embedded interpreter to be $expected.x, got '${Python3.version}'" + ) + } + + @Test + fun platformIsNotBlank() = PythonTestFixture.withInterpreter { + assertTrue(Python3.platform.isNotBlank()) + } + + @Test + fun copyrightMentionsPythonSoftwareFoundation() = PythonTestFixture.withInterpreter { + assertTrue(Python3.copyright.contains("Python"), "expected copyright string to mention Python, got '${Python3.copyright}'") + } + + @Test + fun compilerIsNotBlank() = PythonTestFixture.withInterpreter { + assertTrue(Python3.compiler.isNotBlank()) + } + + @Test + fun buildInfoIsNotBlank() = PythonTestFixture.withInterpreter { + assertTrue(Python3.buildInfo.isNotBlank()) + } + + /** + * [Python3.runMain] used to be a landmine, and this test used to fix its refusal in place. + * It runs a module now, so what is left here is the one property the refusal existed to + * protect: **the interpreter this whole suite shares is still there afterwards.** + * + * The history, because it is the reason the property is worth a test of its own: the original + * body did `sys.argv[1] = ...` -- an assignment to an index that need not exist -- through + * `PyRun_SimpleString`, which prints and clears the error indicator, so the resulting + * `IndexError` was invisible; and then called `Py_RunMain()`, which **always finalizes the + * interpreter**, leaving the process with a torn-down runtime while [Python3.isInitialized] + * still said `true`. Nothing in `src/` or `sample/` called it, so it never fired. + * + * The replacement goes through `runpy.run_module(..., run_name="__main__", alter_sys=True)`, + * which touches no lifecycle function at all. `RunMainTest` covers what it does; this covers + * what it must not do. + * + * Red phase: the refusal it replaces threw `UnsupportedOperationException` from every call, + * so this failed with that exception before the implementation landed (observed alongside the + * twelve `RunMainTest` cases). The pre-*refusal* body's red phase is still the one thing here + * that cannot be run -- `Py_RunMain()` with no `PyConfig.run_*` set enters the REPL on the + * process's stdin and finalizes the shared interpreter, which hangs the worker or crashes + * every class scheduled after it. + */ + @Test + fun runMainRunsAModuleWithoutFinalizingTheSharedInterpreter() = PythonTestFixture.withInterpreter { + // `json.tool` reads stdin when run with no arguments, so it is not the module to run + // here; `this` (the Zen of Python) is a stdlib module whose whole body is a side effect + // and which needs nothing from argv. + val status = Python3.runMain("this") + + assertEquals(0, status, "a module that runs to completion exits 0") + // The point the refusal was protecting: the interpreter the rest of the suite shares. + assertTrue(Python3.isInitialized) + assertEquals("2", PythonTestFixture.eval("1 + 1").toString()) + } +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PythonTestFixture.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PythonTestFixture.kt new file mode 100644 index 000000000..a31137892 --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/PythonTestFixture.kt @@ -0,0 +1,104 @@ +package python.multiplatform.ffi + +import python.native.ffi.PyImport_AddModule +import python.native.ffi.PyObject_GetAttrString + +/** + * Shared entry point for functional tests that need a live interpreter. + * + * A real CPython interpreter *does* initialise inside this test binary on the + * platforms this suite currently runs on -- the version is whatever + * `pythonVersion` is set to in `gradle.properties` (3.14.7 as this is written), + * not a literal, which is why nothing here names one (see + * `python.multiplatform.env.InterpreterAvailabilityTest`, and the + * `extractIosSimulatorStdlib` task in `build.gradle.kts` that gives the iOS + * simulator run a `PYTHONHOME` with an actual standard library to find). So + * [withInterpreter] is the primary, expected-to-succeed path: it initialises + * eagerly and lets any failure propagate as a real test failure. + * + * [requireInterpreterOrSkip] is a defensive fallback for environments where + * bringing up the interpreter is not guaranteed at all (e.g. a future target + * with no stdlib payload wired up yet) -- it degrades to a clearly-labelled + * skip instead of a wall of unrelated crashes, rather than being the normal + * way tests in this suite are expected to run. + */ +object PythonTestFixture { + + /** Whether [Python3.initialize] succeeded (computed once, lazily, per test binary). */ + val available: Boolean by lazy { attemptInitialize() } + + /** Set when [available] is `false`, describing why initialisation failed. */ + var failureReason: String? = null + private set + + private fun attemptInitialize(): Boolean { + return try { + Python3.initialize(silent = true) + true + } catch (t: Throwable) { + failureReason = "${t::class.simpleName}: ${t.message}" + false + } + } + + /** + * Runs [block] against a live interpreter. This is the normal path for + * functional tests in this suite -- the interpreter is expected to be + * available, so a failure to initialise surfaces as a genuine test + * failure rather than a silent skip. + */ + inline fun withInterpreter(block: () -> Unit) { + check(available) { + "CPython could not be initialized in this environment (${failureReason ?: "unknown reason"})" + } + block() + } + + /** + * Same intent as [withInterpreter], but degrades to a skip (prints a + * notice and returns without running [block]) instead of failing when + * the interpreter is not available. Reserved for tests that specifically + * want to tolerate a missing interpreter; most tests should use + * [withInterpreter] so a regression in interpreter bring-up is caught. + */ + inline fun requireInterpreterOrSkip(testName: String, block: () -> Unit) { + if (!available) { + println("SKIP: $testName -- CPython could not be initialized in this environment (${failureReason ?: "unknown reason"})") + return + } + block() + } + + /** + * The `__main__` module's `__dict__`, usable as both the `globals` and + * `locals` argument to [Python3.eval] -- convenience plumbing for tests + * that need to evaluate a Python expression to obtain a real [PyObject] + * to exercise the typed wrappers against. + * + * The two lines below use two different reference conventions, and they are not + * interchangeable -- this used to get the second one wrong. `PyObject_GetAttrString` returns + * a **new** reference, so the wrapper takes `borrowed = false` and adopts it. With + * `borrowed = true` it took a *second* reference on top of the one it had already been + * handed, and a wrapper only ever releases one: one reference lost per call, and + * [eval] calls this every single time. Measured at exactly +50 over 50 calls; + * `OwnershipLeakTest.theTestFixtureDoesNotLeakMainGlobals` is the guard against it returning. + */ + 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. + // 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. + val dictPtr = python.multiplatform.ffi.Python3.withPython { PyObject_GetAttrString(modulePtr, "__dict__") } ?: error("Could not get __main__.__dict__") + PyObject(dictPtr, false) + } + + /** Evaluates [expression] (e.g. `"1 + 1"`) against [mainGlobals] and returns the resulting [PyObject]. */ + fun eval(expression: String): PyObject { + val globals = mainGlobals() + return Python3.eval(expression, PY_EVAL_INPUT, globals, globals) + } + + /** `Py_eval_input`, for use with [Python3.eval]. */ + const val PY_EVAL_INPUT: Int = 258 +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/RunAppTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/RunAppTest.kt new file mode 100644 index 000000000..340d646a8 --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/RunAppTest.kt @@ -0,0 +1,343 @@ +package python.multiplatform.ffi + +import python.multiplatform.ffi.exceptions.PyException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * [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). + * + * The decided scope, fixed by these tests: + * - `-c ` runs the string in the embedder's shared `__main__`, the same namespace + * [Python3.exec] writes into. + * - `-m ` is a straight delegation to [Python3.runMain]. + * - a bare script path runs the file's contents in the same shared `__main__`. + * - `-` (stdin) and any option that only means something before `Py_Initialize()` (a `PyConfig` + * field) are refused by name rather than silently ignored. + * - `--` forces the next token to be treated as a script path even if it looks like an option, + * matching CPython's observed behaviour (`python -- -c` runs a file literally named `-c`). + * - `sys.exit(n)` is a return value, never a process exit -- mirrors [Python3.runMain]. + */ +class RunAppTest { + + private fun installFinder() = PythonTestFixture.withInterpreter { + Python3.exec(FINDER_SOURCE) + } + + private fun registerModule(name: String, source: String) { + installFinder() + Python3.exec("__pmp_register_test_module__(${name.asPythonStringLiteral()}, ${source.asPythonStringLiteral()})") + } + + /** + * Writes [source] to a fresh temp file using Python's own `tempfile`/`open` and returns its + * path. Done through Python rather than a Kotlin file API so this test compiles and runs + * identically on every target this suite runs on -- exactly the reasoning [RunAppTest] borrows + * from [RunMainTest]'s in-memory module finder, applied to the filesystem instead of the import + * system. It is also, not incidentally, exactly how [Python3.runApp] itself reads a script: via + * Python's own `open()`, not a new per-platform Kotlin file-reading path. + */ + private fun writeTempScript(source: String): String = PythonTestFixture.withInterpreter { + Python3.exec( + "import tempfile as _pmp_tempfile, os as _pmp_os\n" + + "_pmp_fd, __pmp_runapp_tmp_path__ = _pmp_tempfile.mkstemp(suffix='.py')\n" + + "_pmp_os.close(_pmp_fd)\n" + + "with open(__pmp_runapp_tmp_path__, 'w') as __pmp_runapp_tmp_f__:\n" + + " __pmp_runapp_tmp_f__.write(${source.asPythonStringLiteral()})\n" + ) + }.let { PythonTestFixture.eval("__pmp_runapp_tmp_path__").toString() } + + // ------------------------------------------------------------------------------------ + // -c + // ------------------------------------------------------------------------------------ + + @Test + fun dashCReturnsSysExitStatusAndTheInterpreterSurvives() = PythonTestFixture.withInterpreter { + val status = Python3.runApp(listOf("-c", "import sys; sys.exit(3)")) + + assertEquals(3, status) + // The property Py_RunMain cannot offer: proof the interpreter is still usable. + assertTrue(Python3.isInitialized) + assertEquals("2", PythonTestFixture.eval("1 + 1").toString()) + Python3.exec("pmp_runapp_after_dashc = 5 * 5") + assertEquals("25", PythonTestFixture.eval("pmp_runapp_after_dashc").toString()) + } + + @Test + fun dashCWithNoSysExitReturnsZeroAndRunsInSharedMain() = PythonTestFixture.withInterpreter { + val status = Python3.runApp(listOf("-c", "pmp_runapp_dashc_wrote_main = 'yes'")) + + assertEquals(0, status) + assertEquals( + "'yes'", + PythonTestFixture.eval("repr(pmp_runapp_dashc_wrote_main)").toString(), + "-c must run in the embedder's own __main__, the same one Python3.exec writes into" + ) + } + + @Test + fun dashCBareSysExitIsStatusZero() = PythonTestFixture.withInterpreter { + assertEquals(0, Python3.runApp(listOf("-c", "import sys\nsys.exit()"))) + } + + @Test + fun dashCNonIntegerSysExitReportsStatusOneAsCPythonDoes() = PythonTestFixture.withInterpreter { + assertEquals(1, Python3.runApp(listOf("-c", "import sys\nsys.exit('pmp-runapp-message')"))) + assertTrue(Python3.isInitialized) + } + + @Test + fun dashCPropagatesAnOrdinaryExceptionAsPyException() = PythonTestFixture.withInterpreter { + val failure = assertFailsWith { + Python3.runApp(listOf("-c", "raise ValueError('pmp-runapp-dashc-boom')")) + } + assertTrue(failure.message?.contains("pmp-runapp-dashc-boom") == true) + assertTrue(Python3.isInitialized) + assertEquals("2", PythonTestFixture.eval("1 + 1").toString()) + } + + @Test + fun dashCSetsArgvZeroToDashCAndRestoresArgvAfterwards() = PythonTestFixture.withInterpreter { + Python3.exec("import sys\npmp_runapp_argv_before = list(sys.argv) if hasattr(sys, 'argv') else None") + val before = PythonTestFixture.eval("repr(pmp_runapp_argv_before)").toString() + + Python3.runApp(listOf("-c", "import sys, builtins; builtins.__pmp_runapp_dashc_argv__ = list(sys.argv)", "a", "b")) + + assertEquals( + "['-c', 'a', 'b']", + PythonTestFixture.eval("repr(__import__('builtins').__pmp_runapp_dashc_argv__)").toString(), + "sys.argv[0] must be '-c', matching CPython, and the rest verbatim" + ) + assertEquals( + before, + PythonTestFixture.eval("repr(list(sys.argv) if hasattr(sys, 'argv') else None)").toString(), + "sys.argv is process-global state runApp did not own; it must be put back" + ) + } + + @Test + fun dashCArgvIsRestoredEvenWhenTheCommandRaises() = PythonTestFixture.withInterpreter { + Python3.exec("import sys\npmp_runapp_argv_before2 = list(sys.argv) if hasattr(sys, 'argv') else None") + val before = PythonTestFixture.eval("repr(pmp_runapp_argv_before2)").toString() + + assertFailsWith { Python3.runApp(listOf("-c", "raise RuntimeError('pmp-runapp-argv-raise')", "x")) } + + assertEquals( + before, + PythonTestFixture.eval("repr(list(sys.argv) if hasattr(sys, 'argv') else None)").toString() + ) + } + + @Test + fun dashCWithNoCommandRefusesRatherThanMisreadingTheNextArgument() = PythonTestFixture.withInterpreter { + val failure = assertFailsWith { Python3.runApp(listOf("-c")) } + assertTrue(failure.message?.contains("-c") == true, "got: ${failure.message}") + } + + // ------------------------------------------------------------------------------------ + // -m + // ------------------------------------------------------------------------------------ + + @Test + fun dashMDelegatesToRunMain() = PythonTestFixture.withInterpreter { + registerModule( + "pmp_runapp_module_ok", + """ + import builtins, sys + builtins.__pmp_runapp_module_name__ = __name__ + builtins.__pmp_runapp_module_argv__ = list(sys.argv) + """.trimIndent() + ) + + val status = Python3.runApp(listOf("-m", "pmp_runapp_module_ok", "x", "y")) + + assertEquals(0, status) + assertEquals( + "'__main__'", + PythonTestFixture.eval("repr(__import__('builtins').__pmp_runapp_module_name__)").toString(), + "runApp -m must run the module the same way Python3.runMain does: as __main__" + ) + assertEquals( + "['x', 'y']", + PythonTestFixture.eval("repr(__import__('builtins').__pmp_runapp_module_argv__[1:])").toString() + ) + } + + @Test + fun dashMSysExitIsReturnedNotThrown() = PythonTestFixture.withInterpreter { + registerModule("pmp_runapp_module_exit", "import sys\nsys.exit(7)\n") + + assertEquals(7, Python3.runApp(listOf("-m", "pmp_runapp_module_exit"))) + assertTrue(Python3.isInitialized) + } + + @Test + fun dashMWithNoModuleRefuses() = PythonTestFixture.withInterpreter { + val failure = assertFailsWith { Python3.runApp(listOf("-m")) } + assertTrue(failure.message?.contains("-m") == true, "got: ${failure.message}") + } + + // ------------------------------------------------------------------------------------ + // script path + // ------------------------------------------------------------------------------------ + + @Test + fun scriptPathRunsInSharedMainAndSetsArgv() = PythonTestFixture.withInterpreter { + Python3.exec("pmp_runapp_script_sees_this = 'set-before-script'") + val path = writeTempScript( + "import sys, builtins\n" + + "builtins.__pmp_runapp_script_argv__ = list(sys.argv)\n" + + "builtins.__pmp_runapp_script_saw_shared_global__ = pmp_runapp_script_sees_this\n" + ) + + val status = Python3.runApp(listOf(path, "arg1", "arg2")) + + assertEquals(0, status) + assertEquals( + "['$path', 'arg1', 'arg2']", + PythonTestFixture.eval("repr(__import__('builtins').__pmp_runapp_script_argv__)").toString(), + "sys.argv[0] must be the script path exactly as given, matching CPython" + ) + assertEquals( + "'set-before-script'", + PythonTestFixture.eval("repr(__import__('builtins').__pmp_runapp_script_saw_shared_global__)").toString(), + "a script path runs 'in the __main__ namespace' per the decided scope -- the same " + + "shared namespace as -c and Python3.exec, not a fresh one like -m" + ) + Python3.exec("import os; os.remove(${path.asPythonStringLiteral()})") + } + + @Test + fun scriptPathSysExitIsReturnedNotThrown() = PythonTestFixture.withInterpreter { + val path = writeTempScript("import sys\nsys.exit(9)\n") + + assertEquals(9, Python3.runApp(listOf(path))) + assertTrue(Python3.isInitialized) + Python3.exec("import os; os.remove(${path.asPythonStringLiteral()})") + } + + // ------------------------------------------------------------------------------------ + // --