From 8031539784a73383d2d78b78f1d23085ef3ba214 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 10:37:02 +0800 Subject: [PATCH] feat(python-sdk): pure-Python bootstrap on PyPI fetches native wheels from GH Releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After v3.2.0 stopped publishing native wheels to PyPI (PR #44, GH Releases is the canonical host), `pip install a3s-code` was broken on every platform except cp310 macOS arm64 (the one wheel that snuck into PyPI before the 10 GB project cap kicked in). This commit restores `pip install a3s-code` by adding a tiny pure-Python bootstrap package — also named `a3s-code` — that on first `import a3s_code` downloads the matching native wheel for the current interpreter and platform, verifies its sha256 against the release manifest, extracts the compiled `_native` extension into a per-user cache, and registers it as `sys.modules["a3s_code._native"]`. Subsequent imports use the cache; cold start is one download per (version, platform, interpreter) triple. New package - sdk/python-bootstrap/ — setuptools-built pure-Python wheel (a3s_code-X.Y.Z-py3-none-any.whl) and matching sdist. - src/a3s_code/__init__.py — calls ensure_native_loaded() then `from ._native import *`. - src/a3s_code/_bootstrap.py — wheel-name resolver, downloader, sha256 verifier, extension loader. Knobs: A3S_CODE_CACHE_DIR, A3S_CODE_RELEASES_BASE_URL, A3S_CODE_SKIP_HASH_CHECK. - tests/test_bootstrap.py — 15 unit tests (filename resolution, cache dir, sha256 mismatch, idempotency, manifest fallback) plus one live download test gated on A3S_CODE_BOOTSTRAP_LIVE=1. - README documenting install model and overrides. Workflow - publish-python-bootstrap.yml — runs bootstrap unit tests, builds wheel + sdist, twine check, twine upload on tag push. - release.yml — adds the bootstrap publish job; runs after publish-python (which produces the GH Release the bootstrap points at) and gates github-release on both. Release plumbing - scripts/check_release_versions.sh now validates the bootstrap pyproject.toml and the runtime __version__ in _bootstrap.py — they must equal the core version so the bootstrap finds the matching GH Release tag. - release.sh bumps both in lockstep with the other version files. - README + sdk/python README rewritten to advertise pip install a3s-code again and explain the cache. Bumps core / Node SDK / Python SDK from 3.2.0 to 3.2.1 since the bootstrap is a user-facing addition. CHANGELOG [3.2.1] entry documents the new package + rationale. End-to-end verified locally: built the wheel, installed into a fresh venv, ran `import a3s_code` with __version__ patched to 3.2.0 so the bootstrap points at the real GH Release; Agent.create succeeded. --- .../workflows/publish-python-bootstrap.yml | 41 +++ .github/workflows/release.yml | 18 +- CHANGELOG.md | 28 ++ Cargo.lock | 2 +- README.md | 19 +- core/Cargo.toml | 2 +- release.sh | 11 + scripts/check_release_versions.sh | 10 + sdk/node/Cargo.toml | 4 +- sdk/node/examples/package-lock.json | 14 +- sdk/node/package-lock.json | 16 +- sdk/node/package.json | 14 +- sdk/python-bootstrap/.gitignore | 6 + sdk/python-bootstrap/README.md | 45 +++ .../build/lib/a3s_code/__init__.py | 18 + .../build/lib/a3s_code/_bootstrap.py | 213 ++++++++++++ .../build/lib/a3s_code/py.typed | 0 sdk/python-bootstrap/pyproject.toml | 36 ++ sdk/python-bootstrap/src/a3s_code/__init__.py | 18 + .../src/a3s_code/_bootstrap.py | 213 ++++++++++++ sdk/python-bootstrap/src/a3s_code/py.typed | 0 sdk/python-bootstrap/tests/test_bootstrap.py | 310 ++++++++++++++++++ sdk/python/Cargo.toml | 4 +- sdk/python/README.md | 31 +- sdk/python/pyproject.toml | 2 +- 25 files changed, 1020 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/publish-python-bootstrap.yml create mode 100644 sdk/python-bootstrap/.gitignore create mode 100644 sdk/python-bootstrap/README.md create mode 100644 sdk/python-bootstrap/build/lib/a3s_code/__init__.py create mode 100644 sdk/python-bootstrap/build/lib/a3s_code/_bootstrap.py create mode 100644 sdk/python-bootstrap/build/lib/a3s_code/py.typed create mode 100644 sdk/python-bootstrap/pyproject.toml create mode 100644 sdk/python-bootstrap/src/a3s_code/__init__.py create mode 100644 sdk/python-bootstrap/src/a3s_code/_bootstrap.py create mode 100644 sdk/python-bootstrap/src/a3s_code/py.typed create mode 100644 sdk/python-bootstrap/tests/test_bootstrap.py diff --git a/.github/workflows/publish-python-bootstrap.yml b/.github/workflows/publish-python-bootstrap.yml new file mode 100644 index 00000000..6c54fd88 --- /dev/null +++ b/.github/workflows/publish-python-bootstrap.yml @@ -0,0 +1,41 @@ +name: Publish Python Bootstrap + +# Builds and publishes the pure-Python `a3s-code` bootstrap shim to PyPI. +# The shim is tiny (no native code) so it sails under PyPI's per-project +# size cap. On first `import a3s_code` it downloads the matching native +# wheel for the user's platform from this repo's GitHub Releases. + +on: + workflow_call: + workflow_dispatch: + +jobs: + publish: + name: Publish Python Bootstrap to PyPI + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build dependencies + run: python -m pip install --upgrade pip build twine + + - name: Run bootstrap unit tests + working-directory: sdk/python-bootstrap + run: python -m unittest tests.test_bootstrap -v + + - name: Build wheel + sdist + run: python -m build --wheel --sdist sdk/python-bootstrap + + - name: Twine check + run: python -m twine check sdk/python-bootstrap/dist/* + + - name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/') + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_NATIVE_TOKEN }} + run: python -m twine upload --skip-existing sdk/python-bootstrap/dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d5b7a92..8021ea58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -119,20 +119,32 @@ jobs: secrets: inherit # ─────────────────────────────────────────────── - # Build and publish Python native SDK (reusable) + # Build native Python wheels and attach to GitHub Release (reusable). + # No longer pushes to PyPI — the per-project size cap made that + # impractical. GH Release is the canonical wheel host. # ─────────────────────────────────────────────── publish-python: - name: Python SDK + name: Python SDK (native wheels → GH Release) needs: [ci, ci-windows] uses: ./.github/workflows/publish-python.yml secrets: inherit + # ─────────────────────────────────────────────── + # Publish the pure-Python bootstrap to PyPI so `pip install a3s-code` + # still works. Needs the native wheels to be on GH Release first. + # ─────────────────────────────────────────────── + publish-python-bootstrap: + name: Python SDK (bootstrap → PyPI) + needs: [publish-python] + uses: ./.github/workflows/publish-python-bootstrap.yml + secrets: inherit + # ─────────────────────────────────────────────── # Create GitHub Release # ─────────────────────────────────────────────── github-release: name: GitHub Release - needs: [publish-crate, publish-node, publish-python] + needs: [publish-crate, publish-node, publish-python, publish-python-bootstrap] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index d903feef..ab163baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.2.1] - 2026-05-24 + +### Added + +- Python SDK: small pure-Python **bootstrap** shim published to PyPI as + `a3s-code`. On first `import a3s_code` it downloads the matching + native wheel for the current interpreter/platform from this repo's + GitHub Releases, verifies the wheel's sha256 against the release + manifest, extracts the compiled `_native` extension into + `~/.cache/a3s-code//`, and registers it as + `sys.modules["a3s_code._native"]`. Subsequent imports use the cache. + Source under `sdk/python-bootstrap/`. + - Environment knobs: `A3S_CODE_CACHE_DIR`, `A3S_CODE_RELEASES_BASE_URL`, + `A3S_CODE_SKIP_HASH_CHECK`. + - 15 unit tests + 1 live download test gated on + `A3S_CODE_BOOTSTRAP_LIVE=1`. + - New workflow `publish-python-bootstrap.yml`, wired after + `publish-python` in `release.yml`. +- `scripts/check_release_versions.sh` now also validates the bootstrap + package version and the runtime `__version__` literal. +- `release.sh` now bumps the bootstrap version in lockstep with the + core release. + +### Fixed + +- `pip install a3s-code` works again from v3.2.1, restored after v3.2.0 + could only push a single wheel to PyPI under the quota cap. + ## [3.2.0] - 2026-05-24 ### Added diff --git a/Cargo.lock b/Cargo.lock index d937ac5d..99ac5156 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,7 +37,7 @@ dependencies = [ [[package]] name = "a3s-code-core" -version = "3.2.0" +version = "3.2.1" dependencies = [ "a3s-acl 0.2.0", "a3s-ahp", diff --git a/README.md b/README.md index 8a5fa711..858ae271 100644 --- a/README.md +++ b/README.md @@ -81,21 +81,22 @@ Full migration notes are in [CHANGELOG.md](./CHANGELOG.md). ## Install ```bash +# Python +pip install a3s-code + # Node.js npm install @a3s-lab/code - -# Python (from v3.2.0; pick the wheel for your interpreter/platform) -pip install \ - https://github.com/AI45Lab/Code/releases/download/v3.2.0/a3s_code-3.2.0-cp312-cp312-manylinux_2_28_x86_64.whl ``` Rust users can depend on `a3s-code-core`. -Python wheels are hosted on [GitHub Releases](https://github.com/AI45Lab/Code/releases) -from v3.2.0 onwards — the project grew past PyPI's per-project storage -quota for binary distributions. Each release ships a -`python-native-manifest.json` with sha256 hashes for every wheel. -Versions up to 3.1.0 remain installable via `pip install a3s-code==3.1.0`. +From v3.2.1 onwards the PyPI `a3s-code` package is a small pure-Python +bootstrap. On first `import a3s_code` it downloads the matching native +wheel from [GitHub Releases](https://github.com/AI45Lab/Code/releases), +verifies the wheel's sha256 against the release manifest, and caches the +compiled extension under `~/.cache/a3s-code//`. Subsequent +imports use the cache. The split exists because the full native-wheel +matrix grew past PyPI's per-project storage cap. --- diff --git a/core/Cargo.toml b/core/Cargo.toml index 0466e9d5..5c605aea 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-core" -version = "3.2.0" +version = "3.2.1" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" diff --git a/release.sh b/release.sh index e8bac64f..782188b6 100755 --- a/release.sh +++ b/release.sh @@ -101,6 +101,17 @@ echo " Updating sdk/python/pyproject.toml..." sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" sdk/python/pyproject.toml rm -f sdk/python/pyproject.toml.bak +# Update Python bootstrap shim (pyproject.toml + runtime __version__). +# Must stay in lockstep with core so the bootstrap fetches the matching +# native wheel from GH Releases on first import. +echo " Updating sdk/python-bootstrap/pyproject.toml..." +sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" sdk/python-bootstrap/pyproject.toml +rm -f sdk/python-bootstrap/pyproject.toml.bak + +echo " Updating sdk/python-bootstrap/src/a3s_code/_bootstrap.py..." +sed -i.bak "s/^__version__ = \".*\"/__version__ = \"${VERSION}\"/" sdk/python-bootstrap/src/a3s_code/_bootstrap.py +rm -f sdk/python-bootstrap/src/a3s_code/_bootstrap.py.bak + echo " Updating Node package lockfiles..." update_node_lockfile sdk/node/package-lock.json update_node_lockfile sdk/node/examples/package-lock.json diff --git a/scripts/check_release_versions.sh b/scripts/check_release_versions.sh index 3f9106af..3cca49d3 100755 --- a/scripts/check_release_versions.sh +++ b/scripts/check_release_versions.sh @@ -97,6 +97,14 @@ def check_node_lockfile(path): check_equal(f"{path} package {key or ''} optionalDependency {name}", value) +def check_bootstrap_runtime_version(path): + match = re.search(r'^__version__\s*=\s*"([^"]+)"', read(path), re.MULTILINE) + if not match: + fail(f"{path}: missing __version__ literal") + return + check_equal(f"{path} __version__", match.group(1)) + + if not expected: expected = first_manifest_version("core/Cargo.toml") or "" @@ -110,6 +118,8 @@ check_core_dependency("sdk/node/Cargo.toml") check_core_dependency("sdk/python/Cargo.toml") check_package_json("sdk/node/package.json") check_pyproject("sdk/python/pyproject.toml") +check_pyproject("sdk/python-bootstrap/pyproject.toml") +check_bootstrap_runtime_version("sdk/python-bootstrap/src/a3s_code/_bootstrap.py") check_cargo_lock("Cargo.lock") check_node_lockfile("sdk/node/package-lock.json") check_node_lockfile("sdk/node/examples/package-lock.json") diff --git a/sdk/node/Cargo.toml b/sdk/node/Cargo.toml index 64f21f2e..a2848a29 100644 --- a/sdk/node/Cargo.toml +++ b/sdk/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-node" -version = "3.2.0" +version = "3.2.1" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -11,7 +11,7 @@ description = "A3S Code Node.js bindings - Native addon via napi-rs" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "3.2.0", path = "../../core", features = ["ahp", "s3"] } +a3s-code-core = { version = "3.2.1", path = "../../core", features = ["ahp", "s3"] } napi = { version = "2", features = ["async", "napi6", "serde-json"] } napi-derive = "2" tokio = { version = "1.35", features = ["full"] } diff --git a/sdk/node/examples/package-lock.json b/sdk/node/examples/package-lock.json index faeba6f7..22241039 100644 --- a/sdk/node/examples/package-lock.json +++ b/sdk/node/examples/package-lock.json @@ -18,7 +18,7 @@ }, "..": { "name": "@a3s-lab/code", - "version": "3.2.0", + "version": "3.2.1", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -27,12 +27,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "3.2.0", - "@a3s-lab/code-linux-arm64-gnu": "3.2.0", - "@a3s-lab/code-linux-arm64-musl": "3.2.0", - "@a3s-lab/code-linux-x64-gnu": "3.2.0", - "@a3s-lab/code-linux-x64-musl": "3.2.0", - "@a3s-lab/code-win32-x64-msvc": "3.2.0" + "@a3s-lab/code-darwin-arm64": "3.2.1", + "@a3s-lab/code-linux-arm64-gnu": "3.2.1", + "@a3s-lab/code-linux-arm64-musl": "3.2.1", + "@a3s-lab/code-linux-x64-gnu": "3.2.1", + "@a3s-lab/code-linux-x64-musl": "3.2.1", + "@a3s-lab/code-win32-x64-msvc": "3.2.1" } }, "node_modules/@a3s-lab/code": { diff --git a/sdk/node/package-lock.json b/sdk/node/package-lock.json index a91baa8a..d0e864bf 100644 --- a/sdk/node/package-lock.json +++ b/sdk/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a3s-lab/code", - "version": "3.2.0", + "version": "3.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a3s-lab/code", - "version": "3.2.0", + "version": "3.2.1", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -15,12 +15,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "3.2.0", - "@a3s-lab/code-linux-arm64-gnu": "3.2.0", - "@a3s-lab/code-linux-arm64-musl": "3.2.0", - "@a3s-lab/code-linux-x64-gnu": "3.2.0", - "@a3s-lab/code-linux-x64-musl": "3.2.0", - "@a3s-lab/code-win32-x64-msvc": "3.2.0" + "@a3s-lab/code-darwin-arm64": "3.2.1", + "@a3s-lab/code-linux-arm64-gnu": "3.2.1", + "@a3s-lab/code-linux-arm64-musl": "3.2.1", + "@a3s-lab/code-linux-x64-gnu": "3.2.1", + "@a3s-lab/code-linux-x64-musl": "3.2.1", + "@a3s-lab/code-win32-x64-msvc": "3.2.1" } }, "node_modules/@a3s-lab/code-darwin-arm64": { diff --git a/sdk/node/package.json b/sdk/node/package.json index 60916dbf..8304a02d 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -1,6 +1,6 @@ { "name": "@a3s-lab/code", - "version": "3.2.0", + "version": "3.2.1", "description": "A3S Code - Native Node.js bindings for the coding-agent runtime", "main": "index.js", "types": "index.d.ts", @@ -43,11 +43,11 @@ "test:helpers": "node test-helpers.mjs" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "3.2.0", - "@a3s-lab/code-linux-x64-gnu": "3.2.0", - "@a3s-lab/code-linux-x64-musl": "3.2.0", - "@a3s-lab/code-linux-arm64-gnu": "3.2.0", - "@a3s-lab/code-linux-arm64-musl": "3.2.0", - "@a3s-lab/code-win32-x64-msvc": "3.2.0" + "@a3s-lab/code-darwin-arm64": "3.2.1", + "@a3s-lab/code-linux-x64-gnu": "3.2.1", + "@a3s-lab/code-linux-x64-musl": "3.2.1", + "@a3s-lab/code-linux-arm64-gnu": "3.2.1", + "@a3s-lab/code-linux-arm64-musl": "3.2.1", + "@a3s-lab/code-win32-x64-msvc": "3.2.1" } } diff --git a/sdk/python-bootstrap/.gitignore b/sdk/python-bootstrap/.gitignore new file mode 100644 index 00000000..7b5e81a6 --- /dev/null +++ b/sdk/python-bootstrap/.gitignore @@ -0,0 +1,6 @@ +build/ +dist/ +*.egg-info/ +__pycache__/ +*.pyc +*.pyo diff --git a/sdk/python-bootstrap/README.md b/sdk/python-bootstrap/README.md new file mode 100644 index 00000000..18878261 --- /dev/null +++ b/sdk/python-bootstrap/README.md @@ -0,0 +1,45 @@ +# a3s-code (Python bootstrap) + +`pip install a3s-code` ships this small pure-Python package. On first +`import a3s_code` it downloads the native extension matching your +interpreter and platform from the project's +[GitHub Releases](https://github.com/AI45Lab/Code/releases), verifies +the wheel's sha256 against the release manifest, extracts the compiled +extension into a per-user cache, and exposes the normal `a3s_code` API. + +Subsequent imports use the cached extension. Cache lives under +`~/.cache/a3s-code//` (or `$XDG_CACHE_HOME/a3s-code//`). + +## Why + +PyPI imposes a default 10 GB per-project storage cap. A Rust SDK with +~17 MB native wheels per Python × platform tripped that limit. GitHub +Releases is the canonical wheel host; this bootstrap keeps +`pip install a3s-code` working without dragging the native wheels back +through PyPI. + +## Supported platforms + +- macOS arm64 (Apple Silicon) +- Linux x86_64 (glibc 2.28+) +- Windows x86_64 + +CPython 3.10, 3.11, 3.12, 3.13. + +## Environment overrides + +| Variable | Effect | +|---|---| +| `A3S_CODE_CACHE_DIR` | Cache root (defaults to `$XDG_CACHE_HOME/a3s-code` or `~/.cache/a3s-code`) | +| `A3S_CODE_RELEASES_BASE_URL` | Override the release base URL — useful for air-gapped mirrors | +| `A3S_CODE_SKIP_HASH_CHECK` | `1` skips sha256 verification (do not use in production) | + +## Manual install + +If you do not want the bootstrap to phone home, install the native +wheel directly: + +```bash +pip install \ + https://github.com/AI45Lab/Code/releases/download/v3.2.1/a3s_code-3.2.1-cp312-cp312-manylinux_2_28_x86_64.whl +``` diff --git a/sdk/python-bootstrap/build/lib/a3s_code/__init__.py b/sdk/python-bootstrap/build/lib/a3s_code/__init__.py new file mode 100644 index 00000000..07aba873 --- /dev/null +++ b/sdk/python-bootstrap/build/lib/a3s_code/__init__.py @@ -0,0 +1,18 @@ +"""A3S Code Python SDK. + +This is the pure-Python bootstrap distributed on PyPI. The actual native +extension is fetched from GitHub Releases on first import — see +`a3s_code._bootstrap` for the loader logic and the environment variables +that customize cache location / source URL. +""" + +from . import _bootstrap as _bootstrap + +_bootstrap.ensure_native_loaded() + +# Re-import after the cache dir is on sys.path. The bootstrap extracted +# `_native..so` into a per-version cache; Python's import machinery +# picks it up from there. +from ._native import * # noqa: E402,F401,F403 + +__version__ = _bootstrap.__version__ diff --git a/sdk/python-bootstrap/build/lib/a3s_code/_bootstrap.py b/sdk/python-bootstrap/build/lib/a3s_code/_bootstrap.py new file mode 100644 index 00000000..e1ed30e7 --- /dev/null +++ b/sdk/python-bootstrap/build/lib/a3s_code/_bootstrap.py @@ -0,0 +1,213 @@ +"""Lazy loader for the a3s-code native extension. + +This module is part of the pure-Python bootstrap published to PyPI under +the `a3s-code` name. On first import it resolves the matching native +wheel for the current interpreter/platform, downloads it from the +project's GitHub Releases, verifies the wheel's sha256 against the +release manifest, extracts the compiled `_native` extension into a +per-user cache, and prepends the cache to `sys.path` so the rest of +`a3s_code/__init__.py` can `from ._native import *` normally. + +Override the cache location via `A3S_CODE_CACHE_DIR`. Override the +release source via `A3S_CODE_RELEASES_BASE_URL` (default points at the +GitHub Releases page for `AI45Lab/Code`). Skip the integrity check via +`A3S_CODE_SKIP_HASH_CHECK=1` (not recommended outside of CI). +""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import platform +import sys +import threading +import urllib.error +import urllib.request +import zipfile +from pathlib import Path +from typing import Optional + +# Version is the bootstrap's own version, which equals the matching native +# wheel version on GH Releases. Bumped by the release workflow. +__version__ = "3.2.1" + +_DEFAULT_BASE_URL = "https://github.com/AI45Lab/Code/releases/download" +_REQUEST_TIMEOUT_S = 120 +_USER_AGENT = f"a3s-code-bootstrap/{__version__}" +_LOAD_LOCK = threading.Lock() +_LOADED = False + + +class BootstrapError(RuntimeError): + """Raised when the native extension cannot be located, downloaded, or verified.""" + + +def _base_url() -> str: + return os.environ.get("A3S_CODE_RELEASES_BASE_URL", _DEFAULT_BASE_URL).rstrip("/") + + +def _cache_root() -> Path: + override = os.environ.get("A3S_CODE_CACHE_DIR") + if override: + return Path(override).expanduser() / __version__ + base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") + return Path(base) / "a3s-code" / __version__ + + +def _platform_tag() -> str: + """Return the wheel platform tag for the current interpreter/platform. + + Mirrors the matrix produced by `.github/workflows/publish-python.yml`. + Raises `BootstrapError` for unsupported combinations so callers can + surface a clear install hint. + """ + sys_plat = sys.platform + machine = platform.machine().lower() + if sys_plat == "darwin": + if machine in ("arm64", "aarch64"): + return "macosx_11_0_arm64" + elif sys_plat == "linux": + if machine in ("x86_64", "amd64"): + return "manylinux_2_28_x86_64" + elif sys_plat == "win32": + if machine in ("amd64", "x86_64"): + return "win_amd64" + raise BootstrapError( + f"a3s-code: no native wheel published for {sys_plat}/{machine}. " + "Supported platforms: macOS arm64, Linux x86_64 (glibc 2.28+), Windows x86_64." + ) + + +def _wheel_filename(version: str = __version__) -> str: + py_tag = f"cp{sys.version_info.major}{sys.version_info.minor}" + return f"a3s_code-{version}-{py_tag}-{py_tag}-{_platform_tag()}.whl" + + +def _release_url(filename: str, version: str = __version__) -> str: + return f"{_base_url()}/v{version}/{filename}" + + +def _http_get(url: str) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT_S) as resp: + return resp.read() + except urllib.error.HTTPError as exc: + raise BootstrapError(f"GET {url} failed: HTTP {exc.code}") from exc + except urllib.error.URLError as exc: + raise BootstrapError(f"GET {url} failed: {exc.reason}") from exc + + +def _expected_sha256(wheel_name: str, version: str = __version__) -> Optional[str]: + """Look up the published sha256 for `wheel_name` in the release manifest. + + Returns `None` if the manifest is unreachable — bootstrap will then + proceed without integrity verification but emit a warning. Override + with `A3S_CODE_SKIP_HASH_CHECK=1` for hermetic offline mirrors. + """ + manifest_url = f"{_base_url()}/v{version}/python-native-manifest.json" + try: + data = json.loads(_http_get(manifest_url)) + except BootstrapError as exc: + sys.stderr.write( + f"a3s-code: warning: manifest fetch failed ({exc}); skipping hash check\n" + ) + return None + for asset in data.get("assets", []): + if asset.get("filename") == wheel_name: + return asset.get("sha256") + return None + + +def _extract_native(wheel_bytes: bytes, target_dir: Path) -> Path: + """Extract the compiled `_native.*` extension from `wheel_bytes` into + `target_dir`. Returns the path to the extracted file. + """ + target_dir.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(io.BytesIO(wheel_bytes)) as zf: + for name in zf.namelist(): + base = Path(name).name + # match _native..{so,pyd,dylib} + if base.startswith("_native.") and not base.endswith(".dist-info"): + out_path = target_dir / base + with zf.open(name) as src, out_path.open("wb") as dst: + dst.write(src.read()) + return out_path + raise BootstrapError( + "downloaded wheel did not contain a _native extension; " + "the release artifact appears to be corrupt" + ) + + +def _find_cached_native(cache_dir: Path) -> Optional[Path]: + if not cache_dir.is_dir(): + return None + for child in cache_dir.iterdir(): + if child.is_file() and child.name.startswith("_native."): + return child + return None + + +def _register_native(native_path: Path) -> None: + """Load `native_path` as the `a3s_code._native` module. + + `_native` is a compiled extension, not a regular Python file, so use + `importlib.machinery.ExtensionFileLoader` + the matching spec instead + of plain `spec_from_file_location` (which works but doesn't set the + right loader for extensions on all Python versions). + """ + import importlib.machinery + import importlib.util + + fullname = "a3s_code._native" + loader = importlib.machinery.ExtensionFileLoader(fullname, str(native_path)) + spec = importlib.util.spec_from_loader(fullname, loader, origin=str(native_path)) + if spec is None: + raise BootstrapError(f"failed to build import spec for {native_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[fullname] = module + spec.loader.exec_module(module) + + +def ensure_native_loaded(version: str = __version__) -> Path: + """Idempotently ensure the `_native` extension is registered as + `a3s_code._native` in `sys.modules`. Returns the cache directory the + extension was loaded from. Safe across threads — first caller wins. + """ + global _LOADED + cache = _cache_root() + + if _LOADED: + return cache + + with _LOAD_LOCK: + if _LOADED: + return cache + + native = _find_cached_native(cache) + if native is None: + wheel_name = _wheel_filename(version) + url = _release_url(wheel_name, version) + sys.stderr.write( + f"a3s-code: fetching native wheel {wheel_name} " + f"from {url} (first import only)...\n" + ) + wheel_bytes = _http_get(url) + + if os.environ.get("A3S_CODE_SKIP_HASH_CHECK") != "1": + expected = _expected_sha256(wheel_name, version) + if expected is not None: + actual = hashlib.sha256(wheel_bytes).hexdigest() + if actual != expected: + raise BootstrapError( + f"sha256 mismatch for {wheel_name}: " + f"expected {expected}, got {actual}" + ) + + native = _extract_native(wheel_bytes, cache) + + _register_native(native) + _LOADED = True + return cache diff --git a/sdk/python-bootstrap/build/lib/a3s_code/py.typed b/sdk/python-bootstrap/build/lib/a3s_code/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/sdk/python-bootstrap/pyproject.toml b/sdk/python-bootstrap/pyproject.toml new file mode 100644 index 00000000..0cac45e6 --- /dev/null +++ b/sdk/python-bootstrap/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "a3s-code" +# Keep in sync with crates/code core release. The bootstrap loader fetches +# the matching native wheel from `https://github.com/AI45Lab/Code/releases/tag/v` +# at import time. +version = "3.2.1" +description = "A3S Code Python SDK — pure-Python bootstrap that fetches the native wheel from GitHub Releases" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.10" +authors = [ + {name = "A3S Lab"}, +] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: Implementation :: CPython", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Software Development", +] +dependencies = [] + +[project.urls] +Homepage = "https://github.com/AI45Lab/Code" +Repository = "https://github.com/AI45Lab/Code" +Issues = "https://github.com/AI45Lab/Code/issues" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +a3s_code = ["py.typed"] diff --git a/sdk/python-bootstrap/src/a3s_code/__init__.py b/sdk/python-bootstrap/src/a3s_code/__init__.py new file mode 100644 index 00000000..07aba873 --- /dev/null +++ b/sdk/python-bootstrap/src/a3s_code/__init__.py @@ -0,0 +1,18 @@ +"""A3S Code Python SDK. + +This is the pure-Python bootstrap distributed on PyPI. The actual native +extension is fetched from GitHub Releases on first import — see +`a3s_code._bootstrap` for the loader logic and the environment variables +that customize cache location / source URL. +""" + +from . import _bootstrap as _bootstrap + +_bootstrap.ensure_native_loaded() + +# Re-import after the cache dir is on sys.path. The bootstrap extracted +# `_native..so` into a per-version cache; Python's import machinery +# picks it up from there. +from ._native import * # noqa: E402,F401,F403 + +__version__ = _bootstrap.__version__ diff --git a/sdk/python-bootstrap/src/a3s_code/_bootstrap.py b/sdk/python-bootstrap/src/a3s_code/_bootstrap.py new file mode 100644 index 00000000..e1ed30e7 --- /dev/null +++ b/sdk/python-bootstrap/src/a3s_code/_bootstrap.py @@ -0,0 +1,213 @@ +"""Lazy loader for the a3s-code native extension. + +This module is part of the pure-Python bootstrap published to PyPI under +the `a3s-code` name. On first import it resolves the matching native +wheel for the current interpreter/platform, downloads it from the +project's GitHub Releases, verifies the wheel's sha256 against the +release manifest, extracts the compiled `_native` extension into a +per-user cache, and prepends the cache to `sys.path` so the rest of +`a3s_code/__init__.py` can `from ._native import *` normally. + +Override the cache location via `A3S_CODE_CACHE_DIR`. Override the +release source via `A3S_CODE_RELEASES_BASE_URL` (default points at the +GitHub Releases page for `AI45Lab/Code`). Skip the integrity check via +`A3S_CODE_SKIP_HASH_CHECK=1` (not recommended outside of CI). +""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import platform +import sys +import threading +import urllib.error +import urllib.request +import zipfile +from pathlib import Path +from typing import Optional + +# Version is the bootstrap's own version, which equals the matching native +# wheel version on GH Releases. Bumped by the release workflow. +__version__ = "3.2.1" + +_DEFAULT_BASE_URL = "https://github.com/AI45Lab/Code/releases/download" +_REQUEST_TIMEOUT_S = 120 +_USER_AGENT = f"a3s-code-bootstrap/{__version__}" +_LOAD_LOCK = threading.Lock() +_LOADED = False + + +class BootstrapError(RuntimeError): + """Raised when the native extension cannot be located, downloaded, or verified.""" + + +def _base_url() -> str: + return os.environ.get("A3S_CODE_RELEASES_BASE_URL", _DEFAULT_BASE_URL).rstrip("/") + + +def _cache_root() -> Path: + override = os.environ.get("A3S_CODE_CACHE_DIR") + if override: + return Path(override).expanduser() / __version__ + base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") + return Path(base) / "a3s-code" / __version__ + + +def _platform_tag() -> str: + """Return the wheel platform tag for the current interpreter/platform. + + Mirrors the matrix produced by `.github/workflows/publish-python.yml`. + Raises `BootstrapError` for unsupported combinations so callers can + surface a clear install hint. + """ + sys_plat = sys.platform + machine = platform.machine().lower() + if sys_plat == "darwin": + if machine in ("arm64", "aarch64"): + return "macosx_11_0_arm64" + elif sys_plat == "linux": + if machine in ("x86_64", "amd64"): + return "manylinux_2_28_x86_64" + elif sys_plat == "win32": + if machine in ("amd64", "x86_64"): + return "win_amd64" + raise BootstrapError( + f"a3s-code: no native wheel published for {sys_plat}/{machine}. " + "Supported platforms: macOS arm64, Linux x86_64 (glibc 2.28+), Windows x86_64." + ) + + +def _wheel_filename(version: str = __version__) -> str: + py_tag = f"cp{sys.version_info.major}{sys.version_info.minor}" + return f"a3s_code-{version}-{py_tag}-{py_tag}-{_platform_tag()}.whl" + + +def _release_url(filename: str, version: str = __version__) -> str: + return f"{_base_url()}/v{version}/{filename}" + + +def _http_get(url: str) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT_S) as resp: + return resp.read() + except urllib.error.HTTPError as exc: + raise BootstrapError(f"GET {url} failed: HTTP {exc.code}") from exc + except urllib.error.URLError as exc: + raise BootstrapError(f"GET {url} failed: {exc.reason}") from exc + + +def _expected_sha256(wheel_name: str, version: str = __version__) -> Optional[str]: + """Look up the published sha256 for `wheel_name` in the release manifest. + + Returns `None` if the manifest is unreachable — bootstrap will then + proceed without integrity verification but emit a warning. Override + with `A3S_CODE_SKIP_HASH_CHECK=1` for hermetic offline mirrors. + """ + manifest_url = f"{_base_url()}/v{version}/python-native-manifest.json" + try: + data = json.loads(_http_get(manifest_url)) + except BootstrapError as exc: + sys.stderr.write( + f"a3s-code: warning: manifest fetch failed ({exc}); skipping hash check\n" + ) + return None + for asset in data.get("assets", []): + if asset.get("filename") == wheel_name: + return asset.get("sha256") + return None + + +def _extract_native(wheel_bytes: bytes, target_dir: Path) -> Path: + """Extract the compiled `_native.*` extension from `wheel_bytes` into + `target_dir`. Returns the path to the extracted file. + """ + target_dir.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(io.BytesIO(wheel_bytes)) as zf: + for name in zf.namelist(): + base = Path(name).name + # match _native..{so,pyd,dylib} + if base.startswith("_native.") and not base.endswith(".dist-info"): + out_path = target_dir / base + with zf.open(name) as src, out_path.open("wb") as dst: + dst.write(src.read()) + return out_path + raise BootstrapError( + "downloaded wheel did not contain a _native extension; " + "the release artifact appears to be corrupt" + ) + + +def _find_cached_native(cache_dir: Path) -> Optional[Path]: + if not cache_dir.is_dir(): + return None + for child in cache_dir.iterdir(): + if child.is_file() and child.name.startswith("_native."): + return child + return None + + +def _register_native(native_path: Path) -> None: + """Load `native_path` as the `a3s_code._native` module. + + `_native` is a compiled extension, not a regular Python file, so use + `importlib.machinery.ExtensionFileLoader` + the matching spec instead + of plain `spec_from_file_location` (which works but doesn't set the + right loader for extensions on all Python versions). + """ + import importlib.machinery + import importlib.util + + fullname = "a3s_code._native" + loader = importlib.machinery.ExtensionFileLoader(fullname, str(native_path)) + spec = importlib.util.spec_from_loader(fullname, loader, origin=str(native_path)) + if spec is None: + raise BootstrapError(f"failed to build import spec for {native_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[fullname] = module + spec.loader.exec_module(module) + + +def ensure_native_loaded(version: str = __version__) -> Path: + """Idempotently ensure the `_native` extension is registered as + `a3s_code._native` in `sys.modules`. Returns the cache directory the + extension was loaded from. Safe across threads — first caller wins. + """ + global _LOADED + cache = _cache_root() + + if _LOADED: + return cache + + with _LOAD_LOCK: + if _LOADED: + return cache + + native = _find_cached_native(cache) + if native is None: + wheel_name = _wheel_filename(version) + url = _release_url(wheel_name, version) + sys.stderr.write( + f"a3s-code: fetching native wheel {wheel_name} " + f"from {url} (first import only)...\n" + ) + wheel_bytes = _http_get(url) + + if os.environ.get("A3S_CODE_SKIP_HASH_CHECK") != "1": + expected = _expected_sha256(wheel_name, version) + if expected is not None: + actual = hashlib.sha256(wheel_bytes).hexdigest() + if actual != expected: + raise BootstrapError( + f"sha256 mismatch for {wheel_name}: " + f"expected {expected}, got {actual}" + ) + + native = _extract_native(wheel_bytes, cache) + + _register_native(native) + _LOADED = True + return cache diff --git a/sdk/python-bootstrap/src/a3s_code/py.typed b/sdk/python-bootstrap/src/a3s_code/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/sdk/python-bootstrap/tests/test_bootstrap.py b/sdk/python-bootstrap/tests/test_bootstrap.py new file mode 100644 index 00000000..78cf87eb --- /dev/null +++ b/sdk/python-bootstrap/tests/test_bootstrap.py @@ -0,0 +1,310 @@ +"""Unit tests for the a3s-code bootstrap loader. + +Live network is not required — `_http_get` is monkey-patched to serve +a constructed wheel byte string. There's a separate live integration +test at the bottom guarded by `A3S_CODE_BOOTSTRAP_LIVE=1`. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import os +import shutil +import sys +import tempfile +import unittest +import unittest.mock as mock +import zipfile +from pathlib import Path + + +# Load `_bootstrap` directly from disk so the package `__init__.py` +# (which would call `ensure_native_loaded` and try to hit the network) +# doesn't run during test collection. +_BOOTSTRAP_PATH = ( + Path(__file__).resolve().parents[1] / "src" / "a3s_code" / "_bootstrap.py" +) +_spec = importlib.util.spec_from_file_location("_bootstrap", _BOOTSTRAP_PATH) +_bootstrap = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_bootstrap) + + +def _make_wheel(native_blob: bytes = b"fake-extension-blob") -> bytes: + """Build a minimal in-memory wheel containing _native.something.so.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("a3s_code/__init__.py", "from ._native import *\n") + zf.writestr("a3s_code/_native.cpython-312-x86_64-linux-gnu.so", native_blob) + zf.writestr("a3s_code-3.2.1.dist-info/METADATA", "Metadata-Version: 2.1\n") + zf.writestr("a3s_code-3.2.1.dist-info/WHEEL", "Wheel-Version: 1.0\n") + return buf.getvalue() + + +class _FakeVersionInfo: + """Stand-in for sys.version_info supporting attribute access.""" + + def __init__(self, major: int, minor: int): + self.major = major + self.minor = minor + self.micro = 0 + self.releaselevel = "final" + self.serial = 0 + + +class WheelFilenameTests(unittest.TestCase): + def _filename_for(self, sys_plat: str, machine: str, py_minor: int) -> str: + with ( + mock.patch.object(sys, "platform", sys_plat), + mock.patch.object(_bootstrap.platform, "machine", return_value=machine), + mock.patch.object(sys, "version_info", _FakeVersionInfo(3, py_minor)), + ): + return _bootstrap._wheel_filename(version="3.2.1") + + def test_linux_x86_64_cp312(self): + self.assertEqual( + self._filename_for("linux", "x86_64", 12), + "a3s_code-3.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", + ) + + def test_macos_arm64_cp311(self): + self.assertEqual( + self._filename_for("darwin", "arm64", 11), + "a3s_code-3.2.1-cp311-cp311-macosx_11_0_arm64.whl", + ) + + def test_windows_amd64_cp313(self): + self.assertEqual( + self._filename_for("win32", "AMD64", 13), + "a3s_code-3.2.1-cp313-cp313-win_amd64.whl", + ) + + def test_unsupported_platform_raises(self): + with self.assertRaises(_bootstrap.BootstrapError) as cm: + self._filename_for("freebsd", "x86_64", 12) + self.assertIn("no native wheel published", str(cm.exception)) + + def test_unsupported_linux_arch_raises(self): + with self.assertRaises(_bootstrap.BootstrapError): + self._filename_for("linux", "ppc64le", 12) + + +class CacheDirTests(unittest.TestCase): + def setUp(self): + self._prev_env = { + k: os.environ.get(k) for k in ("A3S_CODE_CACHE_DIR", "XDG_CACHE_HOME") + } + for k in ("A3S_CODE_CACHE_DIR", "XDG_CACHE_HOME"): + os.environ.pop(k, None) + + def tearDown(self): + for k, v in self._prev_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def test_default_uses_xdg_or_home(self): + cache = _bootstrap._cache_root() + self.assertTrue(str(cache).endswith(f"a3s-code/{_bootstrap.__version__}")) + + def test_xdg_cache_home_honored(self): + os.environ["XDG_CACHE_HOME"] = "/tmp/xdg-test" + cache = _bootstrap._cache_root() + self.assertEqual(cache, Path(f"/tmp/xdg-test/a3s-code/{_bootstrap.__version__}")) + + def test_explicit_override_wins(self): + os.environ["XDG_CACHE_HOME"] = "/tmp/xdg-test" + os.environ["A3S_CODE_CACHE_DIR"] = "/var/a3s-cache" + cache = _bootstrap._cache_root() + self.assertEqual(cache, Path(f"/var/a3s-cache/{_bootstrap.__version__}")) + + +class ExtractNativeTests(unittest.TestCase): + def test_extracts_native_extension(self): + wheel_bytes = _make_wheel(b"native-bytes") + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "a3s_code" + out = _bootstrap._extract_native(wheel_bytes, target) + self.assertTrue(out.exists()) + self.assertTrue(out.name.startswith("_native.")) + self.assertEqual(out.read_bytes(), b"native-bytes") + + def test_wheel_without_native_raises(self): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("a3s_code/__init__.py", "") + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(_bootstrap.BootstrapError): + _bootstrap._extract_native(buf.getvalue(), Path(tmp) / "pkg") + + +class EnsureNativeLoadedTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.mkdtemp(prefix="a3s-bootstrap-test-") + self._prev_cache = os.environ.get("A3S_CODE_CACHE_DIR") + os.environ["A3S_CODE_CACHE_DIR"] = self._tmp + # Reset the module-level latch so each test starts clean. + _bootstrap._LOADED = False + + def tearDown(self): + if self._prev_cache is None: + os.environ.pop("A3S_CODE_CACHE_DIR", None) + else: + os.environ["A3S_CODE_CACHE_DIR"] = self._prev_cache + shutil.rmtree(self._tmp, ignore_errors=True) + _bootstrap._LOADED = False + + def test_downloads_extracts_and_registers_module(self): + wheel_bytes = _make_wheel() + expected_sha = hashlib.sha256(wheel_bytes).hexdigest() + + manifest = json.dumps( + { + "version": "3.2.1", + "assets": [ + { + "filename": _bootstrap._wheel_filename("3.2.1"), + "sha256": expected_sha, + } + ], + } + ).encode() + + def fake_get(url: str) -> bytes: + if url.endswith("python-native-manifest.json"): + return manifest + return wheel_bytes + + # Patch _register_native so the test doesn't actually try to load + # the fake `_native.*.so` (which is not a real shared object). + with ( + mock.patch.object(_bootstrap, "_http_get", side_effect=fake_get), + mock.patch.object(_bootstrap, "_register_native") as register_mock, + ): + cache = _bootstrap.ensure_native_loaded("3.2.1") + + self.assertEqual(cache, Path(self._tmp) / "3.2.1") + # Native file extracted at cache root, not under a subdirectory. + extracted = list(cache.glob("_native.*")) + self.assertEqual(len(extracted), 1) + register_mock.assert_called_once_with(extracted[0]) + + def test_sha256_mismatch_raises(self): + wheel_bytes = _make_wheel() + manifest = json.dumps( + { + "version": "3.2.1", + "assets": [ + { + "filename": _bootstrap._wheel_filename("3.2.1"), + "sha256": "0" * 64, + } + ], + } + ).encode() + + def fake_get(url: str) -> bytes: + if url.endswith("python-native-manifest.json"): + return manifest + return wheel_bytes + + with ( + mock.patch.object(_bootstrap, "_http_get", side_effect=fake_get), + mock.patch.object(_bootstrap, "_register_native"), + ): + with self.assertRaises(_bootstrap.BootstrapError) as cm: + _bootstrap.ensure_native_loaded("3.2.1") + self.assertIn("sha256 mismatch", str(cm.exception)) + + def test_skip_hash_check_env(self): + wheel_bytes = _make_wheel() + manifest = json.dumps( + { + "version": "3.2.1", + "assets": [ + { + "filename": _bootstrap._wheel_filename("3.2.1"), + "sha256": "0" * 64, + } + ], + } + ).encode() + + def fake_get(url: str) -> bytes: + if url.endswith("python-native-manifest.json"): + return manifest + return wheel_bytes + + os.environ["A3S_CODE_SKIP_HASH_CHECK"] = "1" + try: + with ( + mock.patch.object(_bootstrap, "_http_get", side_effect=fake_get), + mock.patch.object(_bootstrap, "_register_native"), + ): + _bootstrap.ensure_native_loaded("3.2.1") + finally: + os.environ.pop("A3S_CODE_SKIP_HASH_CHECK", None) + + def test_idempotent_after_first_call(self): + wheel_bytes = _make_wheel() + manifest = json.dumps( + { + "version": "3.2.1", + "assets": [ + { + "filename": _bootstrap._wheel_filename("3.2.1"), + "sha256": hashlib.sha256(wheel_bytes).hexdigest(), + } + ], + } + ).encode() + + call_count = {"n": 0} + + def fake_get(url: str) -> bytes: + call_count["n"] += 1 + if url.endswith("python-native-manifest.json"): + return manifest + return wheel_bytes + + with ( + mock.patch.object(_bootstrap, "_http_get", side_effect=fake_get), + mock.patch.object(_bootstrap, "_register_native"), + ): + _bootstrap.ensure_native_loaded("3.2.1") + calls_after_first = call_count["n"] + _bootstrap.ensure_native_loaded("3.2.1") + self.assertEqual(call_count["n"], calls_after_first, + "second call must not re-download") + + +@unittest.skipUnless( + os.environ.get("A3S_CODE_BOOTSTRAP_LIVE") == "1", + "set A3S_CODE_BOOTSTRAP_LIVE=1 to exercise the live download path against GH Releases", +) +class LiveDownloadTests(unittest.TestCase): + def test_live_fetch_v3_2_0(self): + # 3.2.0 has 12 wheels on GH Release — pick whichever matches the + # current runner's platform/interpreter. Skip if the runner isn't + # one of the supported triplets. + try: + _bootstrap._wheel_filename("3.2.0") + except _bootstrap.BootstrapError as exc: + self.skipTest(str(exc)) + tmp = Path(tempfile.mkdtemp(prefix="a3s-live-")) + try: + os.environ["A3S_CODE_CACHE_DIR"] = str(tmp) + _bootstrap._LOADED = False + with mock.patch.object(_bootstrap, "_register_native"): + cache = _bootstrap.ensure_native_loaded("3.2.0") + self.assertTrue(any(cache.glob("_native.*"))) + finally: + os.environ.pop("A3S_CODE_CACHE_DIR", None) + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/python/Cargo.toml b/sdk/python/Cargo.toml index e86ea1db..7586148e 100644 --- a/sdk/python/Cargo.toml +++ b/sdk/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-py" -version = "3.2.0" +version = "3.2.1" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -12,7 +12,7 @@ name = "a3s_code" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "3.2.0", path = "../../core", features = ["ahp", "s3"] } +a3s-code-core = { version = "3.2.1", path = "../../core", features = ["ahp", "s3"] } pyo3 = "0.23" tokio = { version = "1.35", features = ["full"] } serde_json = "1.0" diff --git a/sdk/python/README.md b/sdk/python/README.md index 4044eeb3..86d34703 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -4,27 +4,30 @@ Native Python bindings for the A3S Code AI coding agent, built with PyO3. ## Installation -From v3.2.0 onwards the native wheels are hosted on GitHub Releases -(PyPI's per-project storage quota grew too tight for a Rust SDK that -ships a binary per Python × platform). Pick the wheel that matches -your interpreter and platform: - ```bash -# Example: CPython 3.12 on linux x86_64 -pip install \ - https://github.com/AI45Lab/Code/releases/download/v3.2.0/a3s_code-3.2.0-cp312-cp312-manylinux_2_28_x86_64.whl +pip install a3s-code ``` -Or list all wheels for a version: +From v3.2.1 onwards the PyPI `a3s-code` package is a small pure-Python +bootstrap. On first `import a3s_code` it downloads the matching native +wheel from [GitHub Releases](https://github.com/AI45Lab/Code/releases), +verifies the wheel's sha256 against the release manifest, and caches +the compiled extension under `~/.cache/a3s-code//`. Subsequent +imports use the cache. + +Override the cache location with `A3S_CODE_CACHE_DIR`, the source URL +with `A3S_CODE_RELEASES_BASE_URL`, or skip the sha256 verification with +`A3S_CODE_SKIP_HASH_CHECK=1` (not recommended outside CI). See the +bootstrap README at `sdk/python-bootstrap/` for the full list. + +Air-gapped or hermetic install? Grab the wheel matching your +interpreter directly: ```bash -gh release view v3.2.0 --json assets -q '.assets[].browser_download_url' \ - | grep '\.whl$' +pip install \ + https://github.com/AI45Lab/Code/releases/download/v3.2.1/a3s_code-3.2.1-cp312-cp312-manylinux_2_28_x86_64.whl ``` -Earlier versions (≤ 3.1.0) remain on PyPI and install with the usual -`pip install a3s-code==3.1.0`. - ## Quick Start ```python diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 6bb06b8b..7224a827 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "a3s-code" -version = "3.2.0" +version = "3.2.1" description = "A3S Code - Native Python bindings for the coding-agent runtime" readme = "README.md" license = {text = "MIT"}