diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9bd4ed5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,147 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + leo3_ref: + description: "leo3 branch/tag/commit to build against" + required: false + default: "main" + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + build-test: + name: Build & Test (${{ matrix.target.os }}, Python ${{ matrix.target.python }}) + runs-on: ${{ matrix.target.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + target: + - os: ubuntu-24.04 + python: "3.12" + - os: ubuntu-24.04 + python: "3.13" + - os: macos-15-intel + python: "3.12" + - os: macos-15 + python: "3.12" + - os: windows-latest + python: "3.12" + steps: + - name: Checkout leotower + uses: actions/checkout@v6 + with: + path: leotower + + # leo3 is a local path dependency (Cargo.toml: leo3 = { path = + # "../leo3/leo3" }), so the leo3 repo must sit next to the leotower + # checkout. It tracks main by default; override with the leo3_ref + # workflow_dispatch input to test against a leo3 branch or commit. + - name: Checkout leo3 (sibling) + uses: actions/checkout@v6 + with: + repository: LeanOxide/leo3 + ref: ${{ inputs.leo3_ref || 'main' }} + fetch-depth: 1 + path: leo3 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: leotower + + - name: Read pinned Lean toolchain + id: lean + shell: bash + run: | + TOOLCHAIN=$(tr -d '\r\n' < leotower/lean-toolchain) + [[ "$TOOLCHAIN" =~ ^leanprover/lean4: ]] || { echo "::error::unexpected lean-toolchain: $TOOLCHAIN" >&2; exit 1; } + echo "toolchain=$TOOLCHAIN" >> "$GITHUB_OUTPUT" + + - name: Install elan + shell: bash + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + curl -sSfL https://github.com/leanprover/elan/releases/latest/download/elan-x86_64-pc-windows-msvc.zip -o elan.zip + unzip elan.zip + ./elan-init.exe -y --default-toolchain ${{ steps.lean.outputs.toolchain }} + rm -f elan-init.exe elan.zip + else + echo "::group::Elan Installation Output" + set -o pipefail + curl https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh -sSf | sh -s -- -y --default-toolchain ${{ steps.lean.outputs.toolchain }} + rm -f elan-init + echo "::endgroup::" + fi + + - name: Setup Lean PATH + shell: bash + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + echo "$USERPROFILE/.elan/bin" >> $GITHUB_PATH + else + echo "$HOME/.elan/bin" >> $GITHUB_PATH + fi + + - name: Verify Lean installation + shell: bash + working-directory: leotower + run: | + lean --version + lake --version + + # Expose the elan toolchain root. On macOS the test step points + # DYLD_LIBRARY_PATH at its lean lib dir (the extension references + # Lean dylibs via @rpath without an embedded LC_RPATH); on + # Windows the leotower package registers $LEAN_HOME/bin (and the + # elan toolchain bin dirs) as a DLL search path at import time. + - name: Locate Lean toolchain + shell: bash + run: | + LEAN_BIN=$(elan which lean) + LEAN_HOME=$(dirname "$(dirname "$LEAN_BIN")") + echo "LEAN_HOME=$LEAN_HOME" >> $GITHUB_ENV + + - name: Setup uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + # --no-install-project: uv would otherwise build the project + # (editable) here; the explicit maturin develop step below is the + # build. + - name: Install dependencies + working-directory: leotower + run: uv sync --frozen --no-install-project --python ${{ matrix.target.python }} + + - name: Build extension + working-directory: leotower + run: uv run --no-sync maturin develop + - name: Run tests + shell: bash + working-directory: leotower + run: | + # The extension references Lean dylibs via @rpath without an + # embedded LC_RPATH, so point dyld at the elan toolchain's + # lean lib dir (the runtime mechanism leo3 documents for macOS). + if [ "$RUNNER_OS" = "macOS" ]; then + export DYLD_LIBRARY_PATH="$LEAN_HOME/lib/lean" + fi + uv run --no-sync pytest -v \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3f9b72a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,437 @@ +name: Release + +# Builds the PyPI artifacts (wheels for each supported platform + sdist) and +# publishes them. Triggered by version tags (`v*`), or manually via +# workflow_dispatch (set publish=true to upload to PyPI). +# +# leo3 dependency strategy for published builds: +# - Default (leo3_pin unset): build against leo3 at `leo3_ref` (default +# main). A dedicated `resolve-leo3` job resolves the ref to a single +# full commit SHA via `git ls-remote` BEFORE any build starts; every +# build job (wheels matrix legs + sdist) checks out that exact SHA as +# a sibling of leotower, so all artifacts in one release are +# guaranteed to use the same leo3 revision (a mutable ref such as +# main can no longer move between legs). Wheels use the path +# dependency as-is; the sdist pins leo3 to the resolved SHA so it +# builds standalone. The resolved leo3 commit is recorded in the job +# summaries and the GitHub release body. +# - Official releases: set `leo3_pin` to a published crates.io version +# (e.g. 0.3.2) — the dependency is rewritten to the crates.io release +# per the Cargo.toml note. NOTE: leotower currently uses APIs that only +# exist on leo3 main (leo3::meta::repl, MetaMContext::meta_state_snapshot +# / replace_env), so no published leo3 version is compatible yet — 0.3.1 +# fails to compile. Use the default git mode until leo3 0.3.2+ ships. +# +# The Cargo.toml rewrite is done with a portable python3 script (no sed: +# BSD sed on macOS rejects `sed -i` without a backup suffix). It runs +# BEFORE `uv sync`, because uv sync would otherwise try to build the +# project and resolve the stale dependency first. `uv sync` runs with +# --no-install-project so it never builds the project; the real build is +# the explicit `maturin build` / `maturin sdist` step (run with +# `uv run --no-sync` so uv cannot re-sync and re-install the project). +# +# Runner notes: +# - macOS x86_64 uses the explicit `macos-15-intel` label (last x86_64 +# image GitHub offers, until Aug 2027); arm64 uses `macos-15`. +# `macos-14` is arm64-only (deprecated, unsupported from Nov 2026) and +# `macos-latest` drifts with platform migrations (macos-26 since +# June 2026) — neither is used. +# - Linux aarch64 is not covered: it needs an ARM runner +# (ubuntu-24.04-arm) or a cross-compile setup (leo3 supports +# LEO3_CROSS_LIB_DIR). +# - The Windows wheel is not published until W-395 (Repl() aborts the +# process on Windows in the leo3 in-process init path) is fixed. + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + publish: + description: "Upload artifacts to PyPI (requires the PYPI_API_TOKEN secret)" + type: boolean + default: false + leo3_ref: + description: "leo3 branch/tag/commit to build against (git mode)" + required: false + default: "main" + leo3_pin: + description: "leo3 crates.io version (empty = build from git at leo3_ref)" + required: false + default: "" + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: false + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + # Resolve the leo3 ref to one full commit SHA before any build, so + # every artifact in the release is built against the same revision + # even if the ref (e.g. main) moves during the run. + resolve-leo3: + name: Resolve leo3 commit + if: ${{ (inputs.leo3_pin || 'git') == 'git' }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + sha: ${{ steps.resolve.outputs.sha }} + ref: ${{ steps.resolve.outputs.ref }} + steps: + - name: Resolve ref to commit SHA + id: resolve + shell: bash + env: + REF: ${{ inputs.leo3_ref || 'main' }} + run: | + set -euo pipefail + if [[ "$REF" =~ ^[0-9a-f]{40}$ ]]; then + # Already a full commit SHA: nothing to resolve. + SHA="$REF" + else + # Ask for the branch, the tag, and the peeled tag (annotated + # tags: use the peeled commit, matching actions/checkout). + OUT=$(git ls-remote https://github.com/LeanOxide/leo3.git \ + "refs/heads/$REF" "refs/tags/$REF" "refs/tags/$REF^{}") + pick() { printf '%s\n' "$OUT" | awk -v r="$1" '$2 == r { print $1 }' | head -n1; } + SHA=$(pick "refs/tags/$REF^{}") + [ -n "$SHA" ] || SHA=$(pick "refs/tags/$REF") + [ -n "$SHA" ] || SHA=$(pick "refs/heads/$REF") + [ -n "$SHA" ] || { echo "::error::leo3 ref '$REF' matched no branch, tag, or SHA" >&2; exit 1; } + fi + [[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::leo3 ref '$REF' resolved to non-SHA: '$SHA'" >&2; exit 1; } + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "ref=$REF" >> "$GITHUB_OUTPUT" + { + echo "## leo3 dependency" + echo "Resolved [leo3 @ $SHA](https://github.com/LeanOxide/leo3/commit/$SHA) (ref: $REF). All build jobs use this exact commit." + } >> "$GITHUB_STEP_SUMMARY" + + wheels: + needs: [resolve-leo3] + # A skipped upstream job (crates.io mode: resolve-leo3 does not run) + # skips downstream jobs that have no overriding if, and a plain + # boolean if does not override that gating — only always() does. + # So: in git mode require resolve-leo3 to have succeeded; in + # crates.io mode (resolve not needed) run unconditionally. + if: ${{ always() && ((inputs.leo3_pin || 'git') != 'git' || needs.resolve-leo3.result == 'success') }} + name: Build wheels (${{ matrix.target.os }}) + runs-on: ${{ matrix.target.os }} + timeout-minutes: 60 + outputs: + leo3_commit: ${{ steps.leo3-verify.outputs.sha }} + strategy: + fail-fast: false + matrix: + target: + - os: ubuntu-24.04 + - os: macos-15-intel + - os: macos-15 + # windows-latest: re-add once W-395 is fixed. + steps: + - name: Checkout leotower + uses: actions/checkout@v6 + with: + path: leotower + + # Git mode: leo3 is a local path dependency (Cargo.toml: leo3 = + # { path = "../leo3/leo3" }), so the leo3 repo must sit next to the + # leotower checkout. + - name: Checkout leo3 (sibling) + id: leo3-checkout + if: ${{ (inputs.leo3_pin || 'git') == 'git' }} + uses: actions/checkout@v6 + with: + repository: LeanOxide/leo3 + ref: ${{ needs.resolve-leo3.outputs.sha }} + fetch-depth: 1 + path: leo3 + + # Record and verify the exact leo3 commit this build uses, so the + # release is reproducible from the recorded ref/SHA. + - name: Verify leo3 commit + id: leo3-verify + if: ${{ (inputs.leo3_pin || 'git') == 'git' }} + shell: bash + env: + COMMIT: ${{ steps.leo3-checkout.outputs.commit }} + run: | + [[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::leo3 did not resolve to a full commit SHA (got: '$COMMIT')" >&2; exit 1; } + echo "sha=$COMMIT" >> "$GITHUB_OUTPUT" + { + echo "## leo3 dependency" + echo "Built against [leo3 @ $COMMIT](https://github.com/LeanOxide/leo3/commit/$COMMIT) (ref: ${{ inputs.leo3_ref || 'main' }})" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: leotower + + # The leo3 build script resolves the Lean toolchain, so it is needed + # in both dependency modes. The toolchain is read from + # leotower/lean-toolchain so the repo pin is the single source of + # truth. + - name: Read pinned Lean toolchain + id: lean + shell: bash + run: | + TOOLCHAIN=$(tr -d '\r\n' < leotower/lean-toolchain) + [[ "$TOOLCHAIN" =~ ^leanprover/lean4: ]] || { echo "::error::unexpected lean-toolchain: $TOOLCHAIN" >&2; exit 1; } + echo "toolchain=$TOOLCHAIN" >> "$GITHUB_OUTPUT" + + - name: Install elan + shell: bash + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + curl -sSfL https://github.com/leanprover/elan/releases/latest/download/elan-x86_64-pc-windows-msvc.zip -o elan.zip + unzip elan.zip + ./elan-init.exe -y --default-toolchain ${{ steps.lean.outputs.toolchain }} + rm -f elan-init.exe elan.zip + else + echo "::group::Elan Installation Output" + set -o pipefail + curl https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh -sSf | sh -s -- -y --default-toolchain ${{ steps.lean.outputs.toolchain }} + rm -f elan-init + echo "::endgroup::" + fi + + - name: Setup Lean PATH + shell: bash + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + echo "$USERPROFILE/.elan/bin" >> $GITHUB_PATH + else + echo "$HOME/.elan/bin" >> $GITHUB_PATH + fi + + - name: Verify Lean installation + shell: bash + working-directory: leotower + run: | + lean --version + lake --version + + # On Windows the Lean DLLs must be on PATH so the embedded runtime + # can load them at test time. + - name: Configure Lean library path (Windows) + if: runner.os == 'Windows' + shell: bash + working-directory: leotower + run: | + LEAN_BIN=$(elan which lean) + LEAN_HOME=$(dirname "$(dirname "$LEAN_BIN")") + echo "$LEAN_HOME/bin" >> $GITHUB_PATH + echo "LEAN_HOME=$LEAN_HOME" >> $GITHUB_ENV + + # Crates.io mode: pin the published leo3 release (official builds). + # Must happen before `uv sync`, which would otherwise build the + # project against the stale path dependency. + - name: Rewrite leo3 dependency (crates.io mode) + if: ${{ (inputs.leo3_pin || 'git') != 'git' }} + shell: bash + working-directory: leotower + env: + LEO3_MODE: crates + LEO3_VERSION: ${{ inputs.leo3_pin }} + run: | + python3 - <<'PYEOF' + import os, re, sys + line = 'leo3 = { version = "' + os.environ["LEO3_VERSION"] + '", features = ["meta"] }' + with open("Cargo.toml") as f: + s = f.read() + s2, n = re.subn(r'^leo3 = .*\n', line + "\n", s, count=1, flags=re.M) + if n != 1: + sys.exit("ERROR: 'leo3 =' line not found in Cargo.toml") + with open("Cargo.toml", "w") as f: + f.write(s2) + print(line) + PYEOF + grep '^leo3' Cargo.toml + + - name: Setup uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + # --no-install-project: uv would otherwise build the project + # (editable) here; the explicit maturin build below is the build. + - name: Install dependencies + working-directory: leotower + run: uv sync --frozen --no-install-project --python 3.12 + + - name: Build wheels + working-directory: leotower + run: uv run --no-sync maturin build --release -o dist + + - name: Upload wheels + uses: actions/upload-artifact@v6 + with: + name: wheels-${{ matrix.target.os }} + path: leotower/dist/*.whl + + sdist: + needs: [resolve-leo3] + # See the if on the wheels job: always() is required to override + # the skipped-dependency skip in crates.io mode. + if: ${{ always() && ((inputs.leo3_pin || 'git') != 'git' || needs.resolve-leo3.result == 'success') }} + name: Build sdist + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout leotower + uses: actions/checkout@v6 + with: + path: leotower + + # Git mode: resolve leo3_ref to a commit so the sdist can pin it. + - name: Checkout leo3 + id: leo3-checkout + if: ${{ (inputs.leo3_pin || 'git') == 'git' }} + uses: actions/checkout@v6 + with: + repository: LeanOxide/leo3 + ref: ${{ needs.resolve-leo3.outputs.sha }} + fetch-depth: 1 + path: leo3 + + - name: Verify leo3 commit + id: leo3-verify + if: ${{ (inputs.leo3_pin || 'git') == 'git' }} + shell: bash + env: + COMMIT: ${{ steps.leo3-checkout.outputs.commit }} + run: | + [[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::leo3 did not resolve to a full commit SHA (got: '$COMMIT')" >&2; exit 1; } + echo "sha=$COMMIT" >> "$GITHUB_OUTPUT" + { + echo "## leo3 dependency" + echo "sdist pins [leo3 @ $COMMIT](https://github.com/LeanOxide/leo3/commit/$COMMIT) (ref: ${{ inputs.leo3_ref || 'main' }})" + } >> "$GITHUB_STEP_SUMMARY" + + # Git mode: the sdist must build standalone, so pin leo3 to the + # resolved git commit (the path dependency is only valid in the + # sibling-checkout dev layout). Runs before `uv sync`. + - name: Rewrite leo3 dependency (git mode) + if: ${{ (inputs.leo3_pin || 'git') == 'git' }} + shell: bash + working-directory: leotower + env: + LEO3_MODE: git + LEO3_SHA: ${{ steps.leo3-verify.outputs.sha }} + run: | + python3 - <<'PYEOF' + import os, re, sys + line = 'leo3 = { git = "https://github.com/LeanOxide/leo3.git", rev = "' + os.environ["LEO3_SHA"] + '", features = ["meta"] }' + with open("Cargo.toml") as f: + s = f.read() + s2, n = re.subn(r'^leo3 = .*\n', line + "\n", s, count=1, flags=re.M) + if n != 1: + sys.exit("ERROR: 'leo3 =' line not found in Cargo.toml") + with open("Cargo.toml", "w") as f: + f.write(s2) + print(line) + PYEOF + grep '^leo3' Cargo.toml + + # Crates.io mode: same pin as the official wheel builds. + - name: Rewrite leo3 dependency (crates.io mode) + if: ${{ (inputs.leo3_pin || 'git') != 'git' }} + shell: bash + working-directory: leotower + env: + LEO3_MODE: crates + LEO3_VERSION: ${{ inputs.leo3_pin }} + run: | + python3 - <<'PYEOF' + import os, re, sys + line = 'leo3 = { version = "' + os.environ["LEO3_VERSION"] + '", features = ["meta"] }' + with open("Cargo.toml") as f: + s = f.read() + s2, n = re.subn(r'^leo3 = .*\n', line + "\n", s, count=1, flags=re.M) + if n != 1: + sys.exit("ERROR: 'leo3 =' line not found in Cargo.toml") + with open("Cargo.toml", "w") as f: + f.write(s2) + print(line) + PYEOF + grep '^leo3' Cargo.toml + + - name: Setup uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + # --no-install-project: uv would otherwise build the project + # (editable) here; `maturin sdist` below does not compile Rust. + - name: Install dependencies + working-directory: leotower + run: uv sync --frozen --no-install-project --python 3.12 + + - name: Build sdist (no Rust/Lean toolchain needed) + working-directory: leotower + run: uv run --no-sync maturin sdist -o dist + + - name: Upload sdist + uses: actions/upload-artifact@v6 + with: + name: sdist + path: leotower/dist/*.tar.gz + + publish: + name: Publish to PyPI + needs: [wheels, sdist] + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'push' || inputs.publish }} + steps: + - name: Download artifacts + uses: actions/download-artifact@v6 + with: + path: dist + merge-multiple: true + + - name: Setup uv + uses: astral-sh/setup-uv@v7 + + - name: Install twine + run: uv tool install twine + + - name: Check artifact metadata + run: twine check dist/* + + - name: Upload to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload --non-interactive dist/* + + github-release: + name: GitHub Release + needs: [wheels, sdist] + runs-on: ubuntu-24.04 + if: github.event_name == 'push' + permissions: + contents: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v6 + with: + path: dist + merge-multiple: true + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true + body: ${{ (inputs.leo3_pin || 'git') == 'git' && format('Built against leo3 @ {0}', needs.wheels.outputs.leo3_commit) || format('leo3 {0} (crates.io)', inputs.leo3_pin) }} \ No newline at end of file diff --git a/README.md b/README.md index bbf42f0..3a8c384 100644 --- a/README.md +++ b/README.md @@ -146,3 +146,53 @@ uv run python bench/benchmark.py `Cargo.toml` pins `leo3` to the local `../leo3/leo3` crate for development; switch to the crates.io release for published builds. + +## CI/CD + +Workflows live in `.github/workflows/`: + +- **`ci.yml`** — on push to `main`, on every PR, and on manual dispatch. + Builds the extension and runs the pytest suite on + Ubuntu (Python 3.12 + 3.13), macOS (x86_64 + arm64), and Windows + (Python 3.12), against the Lean toolchain pinned in `lean-toolchain`. + Because `leo3` is a local path dependency, the workflow checks out the + `leo3` repo as a sibling of the `leotower` checkout (tracking `main`; + override the ref with the `leo3_ref` dispatch input to test against a + leo3 branch or commit). + On macOS the test step sets `DYLD_LIBRARY_PATH` to the elan + toolchain's Lean lib dir: the built extension references Lean's + dylibs via `@rpath` without an embedded `LC_RPATH`, so dyld needs + the hint to find them (the runtime mechanism leo3 documents for + macOS). + On Windows `import leotower` registers the Lean toolchain's `bin` + dir (from `LEAN_HOME`, or the elan toolchains under + `~/.elan/toolchains`) as a DLL search path, since the Windows + loader does not reliably find Lean's DLLs via `PATH`. +- **`release.yml`** — on version tags (`v*`) or manual dispatch. Builds + `maturin` wheels for Linux x86_64 and macOS x86_64/arm64, plus the + sdist, then publishes to PyPI and creates a GitHub Release with the + artifacts. Manual dispatches only publish when the `publish` input is + set. A `resolve-leo3` job resolves the leo3 ref to a single full + commit SHA before any build, and every build job (wheels matrix + + sdist) checks out that exact SHA, so all artifacts in one release use + the same leo3 revision. + No Windows wheel is published yet: constructing `Repl()` aborts the + process on Windows (leo3 issue, tracked as W-395). Windows is + covered by the CI smoke tests only (build + import + session tests; + the Repl test suite is skipped there until W-395 is fixed). + +Published builds resolve the `leo3` dependency two ways: + +- **git mode (default)** — build against `leo3` at `leo3_ref` (default + `main`); the `resolve-leo3` job resolves the ref to a full commit + SHA and every build job checks that SHA out as a sibling. The sdist + pins leo3 to that commit so it builds standalone. +- **crates.io mode** — set the `leo3_pin` input to a published leo3 + version and both wheels and the sdist pin the crates.io release, per + the note in `Cargo.toml`. Note: leotower currently uses APIs that only + exist on leo3 `main` (`leo3::meta::repl`, `MetaMContext` snapshot + methods), so no published leo3 version compiles yet — use git mode + until leo3 ships a release with those APIs. + +PyPI publishing requires a `PYPI_API_TOKEN` repository secret (an API token +for the PyPI project). diff --git a/python/leotower/__init__.py b/python/leotower/__init__.py index 5a8d757..f3ce090 100644 --- a/python/leotower/__init__.py +++ b/python/leotower/__init__.py @@ -13,8 +13,48 @@ to the shared Lean runtime on first use. """ +import os +import sys + from contextlib import contextmanager + +def _extend_windows_dll_search_path() -> None: + """Add the Lean toolchain's bin dir to the DLL search path (Windows). + + The native extension links Lean's shared libraries + (``libleanshared*.dll``, ``libInit_shared.dll``), which live in the + Lean toolchain's ``bin`` dir. The Windows loader does not reliably + resolve them from ``PATH`` (verified on GitHub-hosted runners), + whereas directories registered with :func:`os.add_dll_directory` + always do — and the registration is what end users need too, since + the published wheel does not bundle the Lean DLLs. + + Candidates: ``$LEAN_HOME/bin``, then every elan toolchain dir under + ``%USERPROFILE%\\.elan\\toolchains``. Only directories that + actually contain ``libleanshared.dll`` are added. + """ + if sys.platform != "win32": + return + candidates = [] + lean_home = os.environ.get("LEAN_HOME") + if lean_home: + candidates.append(os.path.join(lean_home, "bin")) + userprofile = os.environ.get("USERPROFILE") + if userprofile: + toolchains = os.path.join(userprofile, ".elan", "toolchains") + if os.path.isdir(toolchains): + candidates.extend( + os.path.join(toolchains, name, "bin") + for name in sorted(os.listdir(toolchains)) + ) + for directory in candidates: + if os.path.isfile(os.path.join(directory, "libleanshared.dll")): + os.add_dll_directory(directory) + + +_extend_windows_dll_search_path() + from leotower._leotower import LeanSession, prepare_freethreaded_lean __all__ = ["with_lean", "LeanSession", "prepare_freethreaded_lean"] diff --git a/tests/test_repl.py b/tests/test_repl.py index d7881e3..2abdeef 100644 --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -1,9 +1,20 @@ """Repl tests: LeanDojo-compatible replay over the embedded Lean runtime.""" +import sys + import pytest from leotower import Repl +# Windows: constructing Repl() aborts the whole process (misaligned pointer +# dereference in leo3-ffi, exit 127, no Python traceback) — tracked in W-395. +# The Windows build + import + LeanSession paths are still exercised by +# tests/test_leotower.py; re-enable once W-395 lands. +pytestmark = pytest.mark.skipif( + sys.platform == "win32", + reason="Repl() aborts the process on Windows (W-395)", +) + ADD_COMM = "∀ n m : Nat, n + m = m + n" diff --git a/uv.lock b/uv.lock index afe252f..7f8867b 100644 --- a/uv.lock +++ b/uv.lock @@ -5,19 +5,19 @@ requires-python = ">=3.12" [[package]] name = "colorama" version = "0.4.6" -source = { registry = "http://localhost:3141/simple" } -sdist = { url = "http://localhost:3141/root/pypi/+f/086/95f5cb7ed6e05/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "http://localhost:3141/root/pypi/+f/4f1/d9991f5acc0ca/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "http://localhost:3141/simple" } -sdist = { url = "http://localhost:3141/root/pypi/+f/c76/315c77db06865/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "http://localhost:3141/root/pypi/+f/f63/1c04d2c48c52b/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -42,55 +42,55 @@ dev = [ [[package]] name = "maturin" version = "1.14.1" -source = { registry = "http://localhost:3141/simple" } -sdist = { url = "http://localhost:3141/root/pypi/+f/9d6/577a62cd08e0c/maturin-1.14.1.tar.gz", hash = "sha256:9d6577a62cd08e0ceba7a0db06fb098e0c9b1b3429bad747a4f3a18215a1b3df" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/b3/addd877f871fb1860d46d3a4f206ecb10b946c85846805e6367631926fd3/maturin-1.14.1.tar.gz", hash = "sha256:9d6577a62cd08e0ceba7a0db06fb098e0c9b1b3429bad747a4f3a18215a1b3df", size = 369637, upload-time = "2026-06-19T05:19:49.774Z" } wheels = [ - { url = "http://localhost:3141/root/pypi/+f/522/292398945442c/maturin-1.14.1-py3-none-linux_armv6l.whl", hash = "sha256:522292398945442cdafa9daeb2271b2340fbde57027b818f923f88eab04174f8" }, - { url = "http://localhost:3141/root/pypi/+f/ffe/5ad71f21d1e66/maturin-1.14.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ffe5ad71f21d1e6603c4dd75f7fee34adf5ed5ebcebb692886549888ebb329ed" }, - { url = "http://localhost:3141/root/pypi/+f/f33/06078070c1508/maturin-1.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3306078070c1508fd715b9116070cbcaff5959024272a9f1e6f5cb29768b86c" }, - { url = "http://localhost:3141/root/pypi/+f/cd4/57cd88961156e/maturin-1.14.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:cd457cd88961156e26379e1155bd287cc0ec1c8b2f1582b0660fb31b87c8842d" }, - { url = "http://localhost:3141/root/pypi/+f/dfc/54ae32e6fcb18/maturin-1.14.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:dfc54ae32e6fcb18302193ab9a30b0b25eefffba994ae13238974805533ef75e" }, - { url = "http://localhost:3141/root/pypi/+f/a13/1d912b5267e64/maturin-1.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a131d912b5267e640bc96d70f4914e10590aed64082ec9abacba7cea52004224" }, - { url = "http://localhost:3141/root/pypi/+f/be1/8fc568fb76884/maturin-1.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:be18fc568fb76884c0205456336892a75105ec398e6b667cd777c6268bd06d69" }, - { url = "http://localhost:3141/root/pypi/+f/994/a0c8ba3ad8a92/maturin-1.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:994a0c8ba3ad8a92b3a9ee1b02645d200d610216b15cff5102b0fe65e8e08666" }, - { url = "http://localhost:3141/root/pypi/+f/be8/0866363e605d1/maturin-1.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be80866363e605d137991b491a741a84cde9ae350183c4c85f49690ca9aaaa65" }, - { url = "http://localhost:3141/root/pypi/+f/528/2dffd4b539d2b/maturin-1.14.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:5282dffd4b539d2be245f4e5b1a5ab6bc1033b58f4a4872f5833f9d43c954aa4" }, - { url = "http://localhost:3141/root/pypi/+f/1a0/4de0a20188f95/maturin-1.14.1-py3-none-win32.whl", hash = "sha256:1a04de0a20188f95c721b5702eed18140bdcccb28c386797093eca3f62f4d4e0" }, - { url = "http://localhost:3141/root/pypi/+f/3c9/f94640ecc4895/maturin-1.14.1-py3-none-win_amd64.whl", hash = "sha256:3c9f94640ecc4895e94abaf834a0684430032c865b2748a36c12461fd9252fdd" }, - { url = "http://localhost:3141/root/pypi/+f/15c/ea8fcb3ba47dd/maturin-1.14.1-py3-none-win_arm64.whl", hash = "sha256:15cea8fcb3ba47dd636f50092bb34baea8b04ac777392f23e6bf8a9a61efb894" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/97c5a5bd9c71653a066c0976a484eaaae50b9369557838a4176b7b0bdaa5/maturin-1.14.1-py3-none-linux_armv6l.whl", hash = "sha256:522292398945442cdafa9daeb2271b2340fbde57027b818f923f88eab04174f8", size = 10207496, upload-time = "2026-06-19T05:19:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/fe/83/294bca639b0e052f1e2f65199b3db258780c7d4e31408b934c9c974a1379/maturin-1.14.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ffe5ad71f21d1e6603c4dd75f7fee34adf5ed5ebcebb692886549888ebb329ed", size = 19680113, upload-time = "2026-06-19T05:19:13.43Z" }, + { url = "https://files.pythonhosted.org/packages/43/b6/79c881410a3b1c187f7eb3d407aecae646c6a4433d630d72200359015e83/maturin-1.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3306078070c1508fd715b9116070cbcaff5959024272a9f1e6f5cb29768b86c", size = 10169205, upload-time = "2026-06-19T05:19:16.615Z" }, + { url = "https://files.pythonhosted.org/packages/93/9d/44b6f26dcb7f7a04c5501ac2dbb6ca1490150682baa525ca5860504f9eab/maturin-1.14.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:cd457cd88961156e26379e1155bd287cc0ec1c8b2f1582b0660fb31b87c8842d", size = 10188098, upload-time = "2026-06-19T05:19:19.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bd/9c0d5d6983905ce2c9edaa073a7e89355a9cf7f396988e05d32f1c37785d/maturin-1.14.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:dfc54ae32e6fcb18302193ab9a30b0b25eefffba994ae13238974805533ef75e", size = 10627576, upload-time = "2026-06-19T05:19:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/e5/33/b096412bd6a7cb399652b260666f901adf88a687181a6dbd6a3f89f0a94e/maturin-1.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a131d912b5267e640bc96d70f4914e10590aed64082ec9abacba7cea52004224", size = 10085181, upload-time = "2026-06-19T05:19:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/56/8d/08c3bf469c38a23c9e6c877e338193001eb604d010fedc08341974e38528/maturin-1.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:be18fc568fb76884c0205456336892a75105ec398e6b667cd777c6268bd06d69", size = 10026363, upload-time = "2026-06-19T05:19:28.904Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/c4d1a92839f8745ab4aab988a7db884a79d6d710bd3b286fcf9316dece1a/maturin-1.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:994a0c8ba3ad8a92b3a9ee1b02645d200d610216b15cff5102b0fe65e8e08666", size = 13321347, upload-time = "2026-06-19T05:19:32.411Z" }, + { url = "https://files.pythonhosted.org/packages/b3/fa/170f04624d03fd07d2a8b1b67de83a127af93aef9eaa425839553347297b/maturin-1.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be80866363e605d137991b491a741a84cde9ae350183c4c85f49690ca9aaaa65", size = 10877609, upload-time = "2026-06-19T05:19:35.448Z" }, + { url = "https://files.pythonhosted.org/packages/61/ad/1ae2e1d0ded282bf2c55ac13f0811d87deb425e200ae64a15785675dede9/maturin-1.14.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:5282dffd4b539d2be245f4e5b1a5ab6bc1033b58f4a4872f5833f9d43c954aa4", size = 10417316, upload-time = "2026-06-19T05:19:38.28Z" }, + { url = "https://files.pythonhosted.org/packages/fb/27/bf677183920718da49cd7982d6a3ffc440aad8919329f571d189f81b7bdf/maturin-1.14.1-py3-none-win32.whl", hash = "sha256:1a04de0a20188f95c721b5702eed18140bdcccb28c386797093eca3f62f4d4e0", size = 8931293, upload-time = "2026-06-19T05:19:41.183Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/585adeb9167b08d3cdff0032a938b0e72655c92003df4f52c3f696a1bcc2/maturin-1.14.1-py3-none-win_amd64.whl", hash = "sha256:3c9f94640ecc4895e94abaf834a0684430032c865b2748a36c12461fd9252fdd", size = 10314067, upload-time = "2026-06-19T05:19:44.389Z" }, + { url = "https://files.pythonhosted.org/packages/51/d4/dac8c0720ae246be1700afb6fbdbbea20fe35b13f6570b2f70faa005df77/maturin-1.14.1-py3-none-win_arm64.whl", hash = "sha256:15cea8fcb3ba47dd636f50092bb34baea8b04ac777392f23e6bf8a9a61efb894", size = 9718943, upload-time = "2026-06-19T05:19:47.49Z" }, ] [[package]] name = "packaging" version = "26.3" -source = { registry = "http://localhost:3141/simple" } -sdist = { url = "http://localhost:3141/root/pypi/+f/94e/dc256424af387/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "http://localhost:3141/root/pypi/+f/d71/93f7c8e4e93f4/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "http://localhost:3141/simple" } -sdist = { url = "http://localhost:3141/root/pypi/+f/7dc/c130b76258d33/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "http://localhost:3141/root/pypi/+f/e92/0276dd6813095/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pygments" -version = "2.20.0" -source = { registry = "http://localhost:3141/simple" } -sdist = { url = "http://localhost:3141/root/pypi/+f/675/7cd03768053ff/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "http://localhost:3141/root/pypi/+f/81a/9e26dd42fd28a/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] name = "pytest" version = "9.1.1" -source = { registry = "http://localhost:3141/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -98,7 +98,7 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "http://localhost:3141/root/pypi/+f/108/8fbde8f2b49d9/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "http://localhost:3141/root/pypi/+f/37a/86b45efb9a47a/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ]