Skip to content

Commit 4b57378

Browse files
committed
feat: bootstrap Python SDK from GitHub Releases
1 parent ae84c16 commit 4b57378

8 files changed

Lines changed: 305 additions & 11 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env python3
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import hashlib
7+
import json
8+
from pathlib import Path
9+
10+
11+
def sha256(path: Path) -> str:
12+
digest = hashlib.sha256()
13+
with path.open("rb") as file:
14+
for chunk in iter(lambda: file.read(1024 * 1024), b""):
15+
digest.update(chunk)
16+
return digest.hexdigest()
17+
18+
19+
def main() -> None:
20+
parser = argparse.ArgumentParser()
21+
parser.add_argument("input_dir")
22+
parser.add_argument("output_file")
23+
parser.add_argument("--version", required=True)
24+
args = parser.parse_args()
25+
26+
input_dir = Path(args.input_dir)
27+
output_file = Path(args.output_file)
28+
wheels = sorted(input_dir.glob("*.whl"))
29+
manifest = {
30+
"version": args.version,
31+
"assets": [{"filename": wheel.name, "sha256": sha256(wheel)} for wheel in wheels],
32+
}
33+
output_file.parent.mkdir(parents=True, exist_ok=True)
34+
output_file.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
35+
36+
37+
if __name__ == "__main__":
38+
main()

.github/workflows/publish-python.yml

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ on:
1414
type: string
1515
description: "Specific target to build (e.g., x86_64-unknown-linux-gnu). If empty, builds all targets."
1616

17+
permissions:
18+
contents: write
19+
1720
jobs:
1821
build:
1922
strategy:
@@ -79,10 +82,35 @@ jobs:
7982
path: dist/*.whl
8083
if-no-files-found: error
8184

85+
build-bootstrap:
86+
name: Build bootstrap package
87+
runs-on: ubuntu-latest
88+
steps:
89+
- uses: actions/checkout@v4
90+
91+
- name: Setup Python
92+
uses: actions/setup-python@v5
93+
with:
94+
python-version: "3.12"
95+
96+
- name: Build bootstrap dist
97+
run: |
98+
python -m pip install --upgrade pip build
99+
python -m build sdk/python-bootstrap --outdir sdk/python-bootstrap/dist
100+
101+
- name: Upload bootstrap dist
102+
uses: actions/upload-artifact@v4
103+
with:
104+
name: python-bootstrap-dist
105+
path: sdk/python-bootstrap/dist/*
106+
if-no-files-found: error
107+
82108
publish:
83109
name: Publish to PyPI
84110
runs-on: ubuntu-latest
85-
needs: build
111+
needs:
112+
- build
113+
- build-bootstrap
86114
steps:
87115
- name: Download all artifacts
88116
uses: actions/download-artifact@v4
@@ -91,13 +119,39 @@ jobs:
91119
path: dist
92120
merge-multiple: true
93121

122+
- name: Download bootstrap dist
123+
uses: actions/download-artifact@v4
124+
with:
125+
name: python-bootstrap-dist
126+
path: bootstrap-dist
127+
94128
- name: List wheels
95129
run: ls -la dist/
96130

97-
- name: Publish to PyPI
98-
uses: PyO3/maturin-action@v1
131+
- name: Generate native wheel manifest
132+
run: |
133+
python .github/scripts/generate_python_release_manifest.py \
134+
dist \
135+
dist/python-native-manifest.json \
136+
--version "${GITHUB_REF_NAME#v}"
137+
138+
- name: Ensure release exists and upload native assets
99139
env:
100-
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_NATIVE_TOKEN }}
101-
with:
102-
command: upload
103-
args: --skip-existing dist/*
140+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
141+
run: |
142+
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
143+
echo "Release $GITHUB_REF_NAME already exists"
144+
else
145+
gh release create "$GITHUB_REF_NAME" \
146+
--title "$GITHUB_REF_NAME" \
147+
--notes "Native Python wheel assets"
148+
fi
149+
gh release upload "$GITHUB_REF_NAME" dist/*.whl dist/python-native-manifest.json --clobber
150+
151+
- name: Publish bootstrap package to PyPI
152+
env:
153+
TWINE_USERNAME: __token__
154+
TWINE_PASSWORD: ${{ secrets.PYPI_NATIVE_TOKEN }}
155+
run: |
156+
python -m pip install --upgrade pip twine
157+
python -m twine upload --skip-existing bootstrap-dist/*

.github/workflows/release.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ jobs:
133133
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
134134
run: |
135135
if gh release view "$GITHUB_REF_NAME" &>/dev/null; then
136-
echo "Release $GITHUB_REF_NAME already exists"
136+
gh release edit "$GITHUB_REF_NAME" \
137+
--title "$GITHUB_REF_NAME" \
138+
--notes-file /tmp/release-notes.md
137139
else
138140
gh release create "$GITHUB_REF_NAME" \
139141
--title "$GITHUB_REF_NAME" \
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
[build-system]
2+
requires = ["hatchling>=1.25"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "a3s-code"
7+
version = "1.7.2"
8+
description = "A3S Code Python bootstrap package that fetches platform-native binaries from GitHub Releases"
9+
readme = "../python/README.md"
10+
license = { text = "MIT" }
11+
requires-python = ">=3.10"
12+
dependencies = [
13+
"packaging>=24.0",
14+
]
15+
classifiers = [
16+
"Programming Language :: Python :: 3",
17+
"License :: OSI Approved :: MIT License",
18+
"Operating System :: OS Independent",
19+
]
20+
21+
[project.urls]
22+
Homepage = "https://github.com/A3S-Lab/Code"
23+
Repository = "https://github.com/A3S-Lab/Code"
24+
25+
[tool.hatch.build.targets.wheel]
26+
packages = ["src/a3s_code"]
27+
28+
[tool.hatch.build.targets.sdist]
29+
include = [
30+
"src/a3s_code",
31+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from ._bootstrap import load_native_module
2+
3+
_native = load_native_module()
4+
5+
__doc__ = getattr(_native, "__doc__", None)
6+
__all__ = [name for name in dir(_native) if not name.startswith("_")]
7+
8+
for _name in __all__:
9+
globals()[_name] = getattr(_native, _name)
10+
11+
12+
def __getattr__(name: str):
13+
return getattr(_native, name)
14+
15+
16+
def __dir__():
17+
return sorted(set(globals()) | set(dir(_native)))
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import importlib.metadata
5+
import importlib.util
6+
import json
7+
import os
8+
import shutil
9+
import sys
10+
import tempfile
11+
import urllib.request
12+
import zipfile
13+
from pathlib import Path
14+
15+
from packaging.tags import sys_tags
16+
from packaging.utils import canonicalize_name, parse_wheel_filename
17+
18+
PACKAGE_NAME = "a3s-code"
19+
DIST_NAME = "a3s_code"
20+
REPO = "A3S-Lab/Code"
21+
MANIFEST_NAME = "python-native-manifest.json"
22+
ENV_BASE_URL = "A3S_CODE_RELEASE_BASE_URL"
23+
ENV_CACHE_DIR = "A3S_CODE_CACHE_DIR"
24+
ENV_OFFLINE = "A3S_CODE_OFFLINE"
25+
26+
27+
def _package_version() -> str:
28+
return importlib.metadata.version(PACKAGE_NAME)
29+
30+
31+
def _release_tag() -> str:
32+
return f"v{_package_version()}"
33+
34+
35+
def _release_base_url() -> str:
36+
override = os.environ.get(ENV_BASE_URL)
37+
if override:
38+
return override.rstrip("/")
39+
return f"https://github.com/{REPO}/releases/download/{_release_tag()}"
40+
41+
42+
def _cache_root() -> Path:
43+
override = os.environ.get(ENV_CACHE_DIR)
44+
if override:
45+
return Path(override).expanduser()
46+
if sys.platform == "win32":
47+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local"))
48+
elif sys.platform == "darwin":
49+
base = Path.home() / "Library/Caches"
50+
else:
51+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
52+
return base / "a3s-code"
53+
54+
55+
def _download_bytes(url: str) -> bytes:
56+
request = urllib.request.Request(url, headers={"User-Agent": "a3s-code-bootstrap/1"})
57+
with urllib.request.urlopen(request) as response:
58+
return response.read()
59+
60+
61+
def _load_manifest() -> dict:
62+
manifest_url = f"{_release_base_url()}/{MANIFEST_NAME}"
63+
return json.loads(_download_bytes(manifest_url).decode("utf-8"))
64+
65+
66+
def _wheel_assets(manifest: dict) -> list[dict]:
67+
return list(manifest.get("assets", []))
68+
69+
70+
def _select_asset(assets: list[dict]) -> dict:
71+
tag_list = list(sys_tags())
72+
for tag in tag_list:
73+
for asset in assets:
74+
filename = asset["filename"]
75+
try:
76+
name, _, _, wheel_tags = parse_wheel_filename(filename)
77+
except Exception:
78+
continue
79+
if canonicalize_name(name) != canonicalize_name(DIST_NAME):
80+
continue
81+
if tag in wheel_tags:
82+
return asset
83+
raise ImportError("No compatible native a3s-code wheel found for this platform")
84+
85+
86+
def _sha256(path: Path) -> str:
87+
digest = hashlib.sha256()
88+
with path.open("rb") as file:
89+
for chunk in iter(lambda: file.read(1024 * 1024), b""):
90+
digest.update(chunk)
91+
return digest.hexdigest()
92+
93+
94+
def _download_wheel(asset: dict, destination: Path) -> Path:
95+
destination.mkdir(parents=True, exist_ok=True)
96+
wheel_path = destination / asset["filename"]
97+
if wheel_path.exists():
98+
if _sha256(wheel_path) == asset["sha256"]:
99+
return wheel_path
100+
wheel_path.unlink()
101+
102+
if os.environ.get(ENV_OFFLINE) == "1":
103+
raise ImportError(f"Offline mode enabled and native wheel is missing: {wheel_path}")
104+
105+
tmp_dir = Path(tempfile.mkdtemp(prefix="a3s-code-wheel-"))
106+
try:
107+
tmp_path = tmp_dir / asset["filename"]
108+
tmp_path.write_bytes(_download_bytes(f"{_release_base_url()}/{asset['filename']}"))
109+
if _sha256(tmp_path) != asset["sha256"]:
110+
raise ImportError(f"SHA256 mismatch for {asset['filename']}")
111+
tmp_path.replace(wheel_path)
112+
finally:
113+
shutil.rmtree(tmp_dir, ignore_errors=True)
114+
return wheel_path
115+
116+
117+
def _extract_native_module(wheel_path: Path, destination: Path) -> Path:
118+
destination.mkdir(parents=True, exist_ok=True)
119+
with zipfile.ZipFile(wheel_path) as archive:
120+
native_members = [
121+
member
122+
for member in archive.namelist()
123+
if member.startswith("a3s_code/")
124+
and "/_native" in member
125+
and member.endswith((".so", ".pyd"))
126+
]
127+
if not native_members:
128+
raise ImportError(f"No native module found in wheel: {wheel_path.name}")
129+
member = native_members[0]
130+
extracted = destination / Path(member).name
131+
if not extracted.exists():
132+
with archive.open(member) as src, extracted.open("wb") as dst:
133+
shutil.copyfileobj(src, dst)
134+
return extracted
135+
136+
137+
def load_native_module():
138+
version = _package_version()
139+
cache_dir = _cache_root() / version
140+
assets = _wheel_assets(_load_manifest())
141+
asset = _select_asset(assets)
142+
wheel_path = _download_wheel(asset, cache_dir / "wheels")
143+
native_path = _extract_native_module(wheel_path, cache_dir / "native")
144+
145+
spec = importlib.util.spec_from_file_location("a3s_code._native", native_path)
146+
if spec is None or spec.loader is None:
147+
raise ImportError(f"Unable to load native module from {native_path}")
148+
module = importlib.util.module_from_spec(spec)
149+
sys.modules["a3s_code._native"] = module
150+
spec.loader.exec_module(module)
151+
return module

sdk/python/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ classifiers = [
1818
]
1919

2020
[tool.maturin]
21+
python-source = "../python-bootstrap/src"
2122
features = ["extension-module"]
22-
module-name = "a3s_code"
23+
module-name = "a3s_code._native"

sdk/python/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5817,8 +5817,8 @@ impl PyOrchestrator {
58175817
// ============================================================================
58185818

58195819
/// A3S Code - Native AI coding agent library for Python.
5820-
#[pymodule]
5821-
fn a3s_code(m: &Bound<'_, PyModule>) -> PyResult<()> {
5820+
#[pymodule(name = "_native")]
5821+
fn a3s_code_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
58225822
m.add_class::<PyAgent>()?;
58235823
m.add_class::<PySession>()?;
58245824
m.add_class::<PyAgentResult>()?;

0 commit comments

Comments
 (0)