a3s-code 8.0.4__tar.gz → 8.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {a3s_code-8.0.4 → a3s_code-8.2.0}/PKG-INFO +18 -9
- {a3s_code-8.0.4 → a3s_code-8.2.0}/README.md +17 -8
- {a3s_code-8.0.4 → a3s_code-8.2.0}/pyproject.toml +1 -1
- {a3s_code-8.0.4 → a3s_code-8.2.0}/src/a3s_code/_bootstrap.py +238 -35
- {a3s_code-8.0.4 → a3s_code-8.2.0}/src/a3s_code.egg-info/PKG-INFO +18 -9
- {a3s_code-8.0.4 → a3s_code-8.2.0}/tests/test_bootstrap.py +91 -2
- {a3s_code-8.0.4 → a3s_code-8.2.0}/setup.cfg +0 -0
- {a3s_code-8.0.4 → a3s_code-8.2.0}/src/a3s_code/__init__.py +0 -0
- {a3s_code-8.0.4 → a3s_code-8.2.0}/src/a3s_code/py.typed +0 -0
- {a3s_code-8.0.4 → a3s_code-8.2.0}/src/a3s_code.egg-info/SOURCES.txt +0 -0
- {a3s_code-8.0.4 → a3s_code-8.2.0}/src/a3s_code.egg-info/dependency_links.txt +0 -0
- {a3s_code-8.0.4 → a3s_code-8.2.0}/src/a3s_code.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: a3s-code
|
|
3
|
-
Version: 8.0
|
|
3
|
+
Version: 8.2.0
|
|
4
4
|
Summary: A3S Code Python SDK — pure-Python bootstrap that fetches the native wheel from GitHub Releases
|
|
5
5
|
Author: A3S Lab
|
|
6
6
|
License: MIT
|
|
@@ -29,17 +29,23 @@ platform from the project's
|
|
|
29
29
|
the wheel's sha256 against the release manifest, extracts the compiled
|
|
30
30
|
extension into a per-user cache, and exposes the normal `a3s_code` API.
|
|
31
31
|
|
|
32
|
-
The v8.0
|
|
32
|
+
The v8.2.0 release uses one CPython 3.10 stable-ABI (`cp310-abi3`) wheel per
|
|
33
33
|
platform. That wheel is installable by CPython 3.10, 3.11, 3.12, 3.13, and
|
|
34
|
-
3.14.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
3.14. Supported native targets are macOS arm64/x86_64, Linux glibc 2.28+
|
|
35
|
+
arm64/x86_64, and Windows arm64/x86_64. Every supported wheel contains its
|
|
36
|
+
target Moli sidecar and provenance record. Linux musl is intentionally omitted
|
|
37
|
+
because upstream Moli publishes no musl asset; use a system/explicit browser
|
|
38
|
+
or another backend there. The loader still falls back to exact per-minor names
|
|
39
|
+
when reading older release manifests.
|
|
39
40
|
|
|
40
41
|
Subsequent imports use the cached extension. Cache lives under a
|
|
41
42
|
platform-specific directory, `~/.cache/a3s-code/<version>/<platform-tag>/`
|
|
42
43
|
(or `$A3S_CODE_CACHE_DIR/<version>/<platform-tag>/` when overridden).
|
|
44
|
+
The extracted Moli executable is placed in the same version/target cache and
|
|
45
|
+
is selected through `A3S_CODE_MOLI_EXECUTABLE`; atomic extraction and the
|
|
46
|
+
bootstrap's cross-process installer lock make concurrent first imports
|
|
47
|
+
single-flight. Rust, Node.js, and Go use the Core-managed Moli cache when they
|
|
48
|
+
are not using a package-local sidecar.
|
|
43
49
|
|
|
44
50
|
## Why
|
|
45
51
|
|
|
@@ -53,8 +59,8 @@ through PyPI.
|
|
|
53
59
|
|
|
54
60
|
- macOS arm64 (Apple Silicon, macOS 11+)
|
|
55
61
|
- macOS x86_64 (Intel, macOS 12+)
|
|
56
|
-
- Linux x86_64 (glibc 2.28+)
|
|
57
|
-
- Windows x86_64
|
|
62
|
+
- Linux x86_64 and arm64 (glibc 2.28+)
|
|
63
|
+
- Windows x86_64 and arm64
|
|
58
64
|
|
|
59
65
|
CPython 3.10, 3.11, 3.12, 3.13, and 3.14.
|
|
60
66
|
|
|
@@ -74,6 +80,9 @@ python3.14 -m pip install a3s-code
|
|
|
74
80
|
| `A3S_CODE_CACHE_DIR` | Cache root (defaults to `$XDG_CACHE_HOME/a3s-code` or `~/.cache/a3s-code`) |
|
|
75
81
|
| `A3S_CODE_RELEASES_BASE_URL` | Override the release base URL — useful for air-gapped mirrors |
|
|
76
82
|
| `A3S_CODE_SKIP_HASH_CHECK` | `1` skips sha256 verification (do not use in production) |
|
|
83
|
+
| `A3S_CODE_MOLI_EXECUTABLE` | Explicit verified Moli executable; skips package discovery |
|
|
84
|
+
| `A3S_CODE_MOLI_CACHE_DIR` | Core-managed shared Moli cache root (the bootstrap uses its version/target cache for a bundled sidecar) |
|
|
85
|
+
| `A3S_CODE_MOLI_RELEASE_BASE_URL` | HTTPS mirror for the pinned Moli release |
|
|
77
86
|
|
|
78
87
|
## Manual install
|
|
79
88
|
|
|
@@ -7,17 +7,23 @@ platform from the project's
|
|
|
7
7
|
the wheel's sha256 against the release manifest, extracts the compiled
|
|
8
8
|
extension into a per-user cache, and exposes the normal `a3s_code` API.
|
|
9
9
|
|
|
10
|
-
The v8.0
|
|
10
|
+
The v8.2.0 release uses one CPython 3.10 stable-ABI (`cp310-abi3`) wheel per
|
|
11
11
|
platform. That wheel is installable by CPython 3.10, 3.11, 3.12, 3.13, and
|
|
12
|
-
3.14.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
3.14. Supported native targets are macOS arm64/x86_64, Linux glibc 2.28+
|
|
13
|
+
arm64/x86_64, and Windows arm64/x86_64. Every supported wheel contains its
|
|
14
|
+
target Moli sidecar and provenance record. Linux musl is intentionally omitted
|
|
15
|
+
because upstream Moli publishes no musl asset; use a system/explicit browser
|
|
16
|
+
or another backend there. The loader still falls back to exact per-minor names
|
|
17
|
+
when reading older release manifests.
|
|
17
18
|
|
|
18
19
|
Subsequent imports use the cached extension. Cache lives under a
|
|
19
20
|
platform-specific directory, `~/.cache/a3s-code/<version>/<platform-tag>/`
|
|
20
21
|
(or `$A3S_CODE_CACHE_DIR/<version>/<platform-tag>/` when overridden).
|
|
22
|
+
The extracted Moli executable is placed in the same version/target cache and
|
|
23
|
+
is selected through `A3S_CODE_MOLI_EXECUTABLE`; atomic extraction and the
|
|
24
|
+
bootstrap's cross-process installer lock make concurrent first imports
|
|
25
|
+
single-flight. Rust, Node.js, and Go use the Core-managed Moli cache when they
|
|
26
|
+
are not using a package-local sidecar.
|
|
21
27
|
|
|
22
28
|
## Why
|
|
23
29
|
|
|
@@ -31,8 +37,8 @@ through PyPI.
|
|
|
31
37
|
|
|
32
38
|
- macOS arm64 (Apple Silicon, macOS 11+)
|
|
33
39
|
- macOS x86_64 (Intel, macOS 12+)
|
|
34
|
-
- Linux x86_64 (glibc 2.28+)
|
|
35
|
-
- Windows x86_64
|
|
40
|
+
- Linux x86_64 and arm64 (glibc 2.28+)
|
|
41
|
+
- Windows x86_64 and arm64
|
|
36
42
|
|
|
37
43
|
CPython 3.10, 3.11, 3.12, 3.13, and 3.14.
|
|
38
44
|
|
|
@@ -52,6 +58,9 @@ python3.14 -m pip install a3s-code
|
|
|
52
58
|
| `A3S_CODE_CACHE_DIR` | Cache root (defaults to `$XDG_CACHE_HOME/a3s-code` or `~/.cache/a3s-code`) |
|
|
53
59
|
| `A3S_CODE_RELEASES_BASE_URL` | Override the release base URL — useful for air-gapped mirrors |
|
|
54
60
|
| `A3S_CODE_SKIP_HASH_CHECK` | `1` skips sha256 verification (do not use in production) |
|
|
61
|
+
| `A3S_CODE_MOLI_EXECUTABLE` | Explicit verified Moli executable; skips package discovery |
|
|
62
|
+
| `A3S_CODE_MOLI_CACHE_DIR` | Core-managed shared Moli cache root (the bootstrap uses its version/target cache for a bundled sidecar) |
|
|
63
|
+
| `A3S_CODE_MOLI_RELEASE_BASE_URL` | HTTPS mirror for the pinned Moli release |
|
|
55
64
|
|
|
56
65
|
## Manual install
|
|
57
66
|
|
|
@@ -7,7 +7,7 @@ name = "a3s-code"
|
|
|
7
7
|
# Keep in sync with crates/code core release. The bootstrap loader fetches
|
|
8
8
|
# the matching native wheel from `https://github.com/A3S-Lab/Code/releases/tag/v<version>`
|
|
9
9
|
# at import time.
|
|
10
|
-
version = "8.0
|
|
10
|
+
version = "8.2.0"
|
|
11
11
|
description = "A3S Code Python SDK — pure-Python bootstrap that fetches the native wheel from GitHub Releases"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
license = {text = "MIT"}
|
|
@@ -6,7 +6,9 @@ wheel for the current platform, downloads it from the project's GitHub
|
|
|
6
6
|
Releases, verifies the wheel's sha256 against the
|
|
7
7
|
release manifest, extracts the compiled `_native` extension into a
|
|
8
8
|
per-user cache, and registers it explicitly as `a3s_code._native` because it
|
|
9
|
-
lives outside the installed package directory.
|
|
9
|
+
lives outside the installed package directory. A cross-process advisory lock
|
|
10
|
+
keeps the first download and sidecar extraction single-flight for applications
|
|
11
|
+
sharing that cache.
|
|
10
12
|
|
|
11
13
|
Override the cache location via `A3S_CODE_CACHE_DIR`. Override the
|
|
12
14
|
release source via `A3S_CODE_RELEASES_BASE_URL` (default points at the
|
|
@@ -16,28 +18,36 @@ GitHub Releases page for `A3S-Lab/Code`). Skip the integrity check via
|
|
|
16
18
|
|
|
17
19
|
from __future__ import annotations
|
|
18
20
|
|
|
21
|
+
import errno
|
|
19
22
|
import hashlib
|
|
20
23
|
import io
|
|
21
24
|
import json
|
|
22
25
|
import os
|
|
23
26
|
import platform
|
|
27
|
+
import stat
|
|
24
28
|
import sys
|
|
25
29
|
import threading
|
|
30
|
+
import time
|
|
26
31
|
import urllib.error
|
|
27
32
|
import urllib.request
|
|
28
33
|
import zipfile
|
|
34
|
+
from contextlib import contextmanager
|
|
29
35
|
from pathlib import Path
|
|
30
|
-
from typing import Optional
|
|
36
|
+
from typing import Iterator, Optional
|
|
31
37
|
|
|
32
38
|
# Version is the bootstrap's own version, which equals the matching native
|
|
33
39
|
# wheel version on GH Releases. Bumped by the release workflow.
|
|
34
|
-
__version__ = "8.0
|
|
40
|
+
__version__ = "8.2.0"
|
|
35
41
|
|
|
36
42
|
_DEFAULT_BASE_URL = "https://github.com/A3S-Lab/Code/releases/download"
|
|
37
43
|
_REQUEST_TIMEOUT_S = 120
|
|
38
44
|
_USER_AGENT = f"a3s-code-bootstrap/{__version__}"
|
|
39
45
|
_MIN_PYTHON = (3, 10)
|
|
46
|
+
_MAX_WHEEL_BYTES = 512 * 1024 * 1024
|
|
47
|
+
_MAX_MEMBER_BYTES = 256 * 1024 * 1024
|
|
40
48
|
_ABI3_PYTHON_TAG = "cp310-abi3"
|
|
49
|
+
_INSTALL_LOCK_TIMEOUT_S = _REQUEST_TIMEOUT_S + 30
|
|
50
|
+
_INSTALL_LOCK_POLL_S = 0.05
|
|
41
51
|
_LOAD_LOCK = threading.Lock()
|
|
42
52
|
_LOADED = False
|
|
43
53
|
|
|
@@ -55,6 +65,91 @@ class BootstrapHttpError(BootstrapError):
|
|
|
55
65
|
super().__init__(f"GET {url} failed: HTTP {status_code}")
|
|
56
66
|
|
|
57
67
|
|
|
68
|
+
def _lock_contention(error: OSError) -> bool:
|
|
69
|
+
"""Return whether an advisory-lock failure means another process owns it."""
|
|
70
|
+
|
|
71
|
+
# POSIX flock reports EACCES/EAGAIN. Windows msvcrt.locking commonly uses
|
|
72
|
+
# errno 13 (permission denied) for the same contention condition.
|
|
73
|
+
return isinstance(error, BlockingIOError) or getattr(error, "errno", None) in {
|
|
74
|
+
errno.EAGAIN,
|
|
75
|
+
errno.EACCES, # Windows sharing violation uses the same value
|
|
76
|
+
35, # EAGAIN on macOS
|
|
77
|
+
36, # EAGAIN on some BSDs
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@contextmanager
|
|
82
|
+
def _install_lock(
|
|
83
|
+
cache_dir: Path, timeout_s: float = _INSTALL_LOCK_TIMEOUT_S
|
|
84
|
+
) -> Iterator[None]:
|
|
85
|
+
"""Acquire a cross-process lock for native wheel and Moli extraction.
|
|
86
|
+
|
|
87
|
+
The lock is advisory and released by the operating system if a bootstrap
|
|
88
|
+
process exits unexpectedly. Keeping it in the same platform/version cache
|
|
89
|
+
means independent Python applications perform one download and one atomic
|
|
90
|
+
extraction, while the in-process ``_LOAD_LOCK`` still protects module
|
|
91
|
+
registration.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
lock_path = cache_dir / ".install.lock"
|
|
96
|
+
try:
|
|
97
|
+
handle = lock_path.open("a+b")
|
|
98
|
+
except OSError as exc:
|
|
99
|
+
raise BootstrapError(f"could not open bootstrap install lock {lock_path}: {exc}") from exc
|
|
100
|
+
|
|
101
|
+
locked = False
|
|
102
|
+
try:
|
|
103
|
+
# Windows locking operates on a byte range and requires at least one
|
|
104
|
+
# byte in the file. The write is harmless on POSIX and races are safe.
|
|
105
|
+
handle.seek(0, os.SEEK_END)
|
|
106
|
+
if handle.tell() == 0:
|
|
107
|
+
handle.write(b"\0")
|
|
108
|
+
handle.flush()
|
|
109
|
+
deadline = time.monotonic() + timeout_s
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
handle.seek(0)
|
|
113
|
+
if os.name == "nt":
|
|
114
|
+
import msvcrt
|
|
115
|
+
|
|
116
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
117
|
+
else:
|
|
118
|
+
import fcntl
|
|
119
|
+
|
|
120
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
121
|
+
locked = True
|
|
122
|
+
break
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
if not _lock_contention(exc):
|
|
125
|
+
raise BootstrapError(
|
|
126
|
+
f"could not acquire bootstrap install lock {lock_path}: {exc}"
|
|
127
|
+
) from exc
|
|
128
|
+
if time.monotonic() >= deadline:
|
|
129
|
+
raise BootstrapError(
|
|
130
|
+
f"timed out waiting for bootstrap install lock {lock_path}"
|
|
131
|
+
) from exc
|
|
132
|
+
time.sleep(_INSTALL_LOCK_POLL_S)
|
|
133
|
+
yield
|
|
134
|
+
finally:
|
|
135
|
+
if locked:
|
|
136
|
+
try:
|
|
137
|
+
handle.seek(0)
|
|
138
|
+
if os.name == "nt":
|
|
139
|
+
import msvcrt
|
|
140
|
+
|
|
141
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
142
|
+
else:
|
|
143
|
+
import fcntl
|
|
144
|
+
|
|
145
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
146
|
+
except OSError:
|
|
147
|
+
# Closing the descriptor also releases the advisory lock. Do
|
|
148
|
+
# not hide the caller's extraction/import exception here.
|
|
149
|
+
pass
|
|
150
|
+
handle.close()
|
|
151
|
+
|
|
152
|
+
|
|
58
153
|
def _base_url() -> str:
|
|
59
154
|
return os.environ.get("A3S_CODE_RELEASES_BASE_URL", _DEFAULT_BASE_URL).rstrip("/")
|
|
60
155
|
|
|
@@ -95,13 +190,17 @@ def _platform_tag() -> str:
|
|
|
95
190
|
elif sys_plat == "linux":
|
|
96
191
|
if machine in ("x86_64", "amd64"):
|
|
97
192
|
return "manylinux_2_28_x86_64"
|
|
193
|
+
if machine in ("arm64", "aarch64"):
|
|
194
|
+
return "manylinux_2_28_aarch64"
|
|
98
195
|
elif sys_plat == "win32":
|
|
99
196
|
if machine in ("amd64", "x86_64"):
|
|
100
197
|
return "win_amd64"
|
|
198
|
+
if machine in ("arm64", "aarch64"):
|
|
199
|
+
return "win_arm64"
|
|
101
200
|
raise BootstrapError(
|
|
102
201
|
f"a3s-code: no native wheel published for {sys_plat}/{machine}. "
|
|
103
202
|
"Supported platforms: macOS arm64 (11+), macOS Intel (12+), "
|
|
104
|
-
"Linux x86_64 (glibc 2.28+), Windows x86_64."
|
|
203
|
+
"Linux x86_64/arm64 (glibc 2.28+), Windows x86_64/arm64."
|
|
105
204
|
)
|
|
106
205
|
|
|
107
206
|
|
|
@@ -207,24 +306,117 @@ def _download_wheel(version: str) -> tuple[str, bytes]:
|
|
|
207
306
|
)
|
|
208
307
|
|
|
209
308
|
|
|
309
|
+
def _safe_wheel_member(name: str) -> tuple[str, ...]:
|
|
310
|
+
"""Validate and split a wheel member before reading it.
|
|
311
|
+
|
|
312
|
+
Wheels are ZIP files supplied by a remote release. Do not allow an
|
|
313
|
+
archive entry to escape the cache through absolute paths, parent
|
|
314
|
+
components, or Windows separators (which are accepted by some extraction
|
|
315
|
+
tools even on POSIX).
|
|
316
|
+
"""
|
|
317
|
+
if not name or "\\" in name or name.startswith("/"):
|
|
318
|
+
raise BootstrapError(f"downloaded wheel contains an unsafe member path: {name!r}")
|
|
319
|
+
parts = tuple(part for part in name.split("/") if part)
|
|
320
|
+
if not parts or any(part in {".", ".."} for part in parts):
|
|
321
|
+
raise BootstrapError(f"downloaded wheel contains an unsafe member path: {name!r}")
|
|
322
|
+
return parts
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _write_wheel_member(zf: zipfile.ZipFile, info: zipfile.ZipInfo, destination: Path) -> None:
|
|
326
|
+
"""Write one regular, bounded wheel member atomically."""
|
|
327
|
+
mode = (info.external_attr >> 16) & 0o170000
|
|
328
|
+
if mode == stat.S_IFLNK:
|
|
329
|
+
raise BootstrapError(f"downloaded wheel contains a symlink member: {info.filename!r}")
|
|
330
|
+
if info.is_dir() or info.file_size > _MAX_MEMBER_BYTES:
|
|
331
|
+
raise BootstrapError(f"downloaded wheel member is too large or not a file: {info.filename!r}")
|
|
332
|
+
|
|
333
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
temporary = destination.with_name(f".{destination.name}.part-{os.getpid()}-{threading.get_ident()}")
|
|
335
|
+
try:
|
|
336
|
+
with zf.open(info) as source, temporary.open("xb") as target:
|
|
337
|
+
remaining = _MAX_MEMBER_BYTES + 1
|
|
338
|
+
while remaining:
|
|
339
|
+
chunk = source.read(min(1024 * 1024, remaining))
|
|
340
|
+
if not chunk:
|
|
341
|
+
break
|
|
342
|
+
target.write(chunk)
|
|
343
|
+
remaining -= len(chunk)
|
|
344
|
+
if remaining == 0:
|
|
345
|
+
raise BootstrapError(f"downloaded wheel member is too large: {info.filename!r}")
|
|
346
|
+
target.flush()
|
|
347
|
+
os.fsync(target.fileno())
|
|
348
|
+
os.replace(temporary, destination)
|
|
349
|
+
finally:
|
|
350
|
+
try:
|
|
351
|
+
temporary.unlink()
|
|
352
|
+
except FileNotFoundError:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _configure_cached_moli(cache_dir: Path) -> None:
|
|
357
|
+
"""Expose a wheel-bundled Moli executable to the Rust core.
|
|
358
|
+
|
|
359
|
+
The bootstrap package and the native wheel are separate distributions.
|
|
360
|
+
Without copying this sidecar into the bootstrap cache, a normal
|
|
361
|
+
``pip install a3s-code`` would silently discard the bundled browser and
|
|
362
|
+
force every process to download it again. An operator-supplied override
|
|
363
|
+
always wins.
|
|
364
|
+
"""
|
|
365
|
+
if os.environ.get("A3S_CODE_MOLI_EXECUTABLE"):
|
|
366
|
+
return
|
|
367
|
+
executable = "moli.exe" if os.name == "nt" else "moli"
|
|
368
|
+
candidate = cache_dir / "moli" / executable
|
|
369
|
+
try:
|
|
370
|
+
if candidate.is_file() and (os.name == "nt" or os.access(candidate, os.X_OK)):
|
|
371
|
+
os.environ["A3S_CODE_MOLI_EXECUTABLE"] = str(candidate)
|
|
372
|
+
os.environ.setdefault("A3S_CODE_MOLI_DIR", str(candidate.parent))
|
|
373
|
+
except OSError:
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
|
|
210
377
|
def _extract_native(wheel_bytes: bytes, target_dir: Path) -> Path:
|
|
211
|
-
"""Extract the
|
|
212
|
-
|
|
378
|
+
"""Extract the native extension and bundled Moli sidecar.
|
|
379
|
+
|
|
380
|
+
Returns the path to the extracted extension. The sidecar is placed under
|
|
381
|
+
``target_dir/moli`` and is selected by :func:`_configure_cached_moli`.
|
|
213
382
|
"""
|
|
383
|
+
if len(wheel_bytes) > _MAX_WHEEL_BYTES:
|
|
384
|
+
raise BootstrapError("downloaded wheel exceeds the bootstrap size limit")
|
|
214
385
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
native_path: Optional[Path] = None
|
|
387
|
+
moli_path: Optional[Path] = None
|
|
215
388
|
with zipfile.ZipFile(io.BytesIO(wheel_bytes)) as zf:
|
|
216
|
-
for
|
|
217
|
-
|
|
218
|
-
|
|
389
|
+
for info in zf.infolist():
|
|
390
|
+
parts = _safe_wheel_member(info.filename)
|
|
391
|
+
base = parts[-1]
|
|
392
|
+
# Match _native.<abi>.{so,pyd,dylib}; ignore metadata files.
|
|
219
393
|
if base.startswith("_native.") and not base.endswith(".dist-info"):
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
394
|
+
if native_path is not None:
|
|
395
|
+
raise BootstrapError("downloaded wheel contains multiple native extensions")
|
|
396
|
+
native_path = target_dir / base
|
|
397
|
+
_write_wheel_member(zf, info, native_path)
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
# Maturin places the packaged browser under a3s_code/moli/. Keep
|
|
401
|
+
# the match strict so an unrelated file named `moli` is not run.
|
|
402
|
+
if base in {"moli", "moli.exe"} and len(parts) >= 3 and parts[-3:-1] == ("a3s_code", "moli"):
|
|
403
|
+
if moli_path is not None:
|
|
404
|
+
raise BootstrapError("downloaded wheel contains multiple Moli executables")
|
|
405
|
+
moli_path = target_dir / "moli" / base
|
|
406
|
+
_write_wheel_member(zf, info, moli_path)
|
|
407
|
+
|
|
408
|
+
if native_path is None:
|
|
409
|
+
raise BootstrapError(
|
|
410
|
+
"downloaded wheel did not contain a _native extension; "
|
|
411
|
+
"the release artifact appears to be corrupt"
|
|
412
|
+
)
|
|
413
|
+
if moli_path is not None and os.name != "nt":
|
|
414
|
+
try:
|
|
415
|
+
moli_path.chmod(0o755)
|
|
416
|
+
except OSError as exc:
|
|
417
|
+
raise BootstrapError(f"could not mark bundled Moli executable: {exc}") from exc
|
|
418
|
+
_configure_cached_moli(target_dir)
|
|
419
|
+
return native_path
|
|
228
420
|
|
|
229
421
|
|
|
230
422
|
def _find_cached_native(cache_dir: Path) -> Optional[Path]:
|
|
@@ -288,24 +480,35 @@ def ensure_native_loaded(version: str = __version__) -> Path:
|
|
|
288
480
|
|
|
289
481
|
native = _find_cached_native(cache)
|
|
290
482
|
if native is None:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
|
|
483
|
+
# Re-check after taking the process lock: another independent
|
|
484
|
+
# Python application may have completed the download while this
|
|
485
|
+
# process was starting. This keeps both the extension and bundled
|
|
486
|
+
# Moli sidecar single-flight across processes.
|
|
487
|
+
with _install_lock(cache):
|
|
488
|
+
native = _find_cached_native(cache)
|
|
489
|
+
if native is None:
|
|
490
|
+
wheel_name, wheel_bytes = _download_wheel(version)
|
|
491
|
+
url = _release_url(wheel_name, version)
|
|
492
|
+
sys.stderr.write(
|
|
493
|
+
f"a3s-code: fetching native wheel {wheel_name} "
|
|
494
|
+
f"from {url} (first import only)...\n"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if os.environ.get("A3S_CODE_SKIP_HASH_CHECK") != "1":
|
|
498
|
+
expected = _expected_sha256(wheel_name, version)
|
|
499
|
+
if expected is not None:
|
|
500
|
+
actual = hashlib.sha256(wheel_bytes).hexdigest()
|
|
501
|
+
if actual != expected:
|
|
502
|
+
raise BootstrapError(
|
|
503
|
+
f"sha256 mismatch for {wheel_name}: "
|
|
504
|
+
f"expected {expected}, got {actual}"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
native = _extract_native(wheel_bytes, cache)
|
|
508
|
+
else:
|
|
509
|
+
_configure_cached_moli(cache)
|
|
510
|
+
else:
|
|
511
|
+
_configure_cached_moli(cache)
|
|
309
512
|
|
|
310
513
|
_register_native(native)
|
|
311
514
|
_LOADED = True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: a3s-code
|
|
3
|
-
Version: 8.0
|
|
3
|
+
Version: 8.2.0
|
|
4
4
|
Summary: A3S Code Python SDK — pure-Python bootstrap that fetches the native wheel from GitHub Releases
|
|
5
5
|
Author: A3S Lab
|
|
6
6
|
License: MIT
|
|
@@ -29,17 +29,23 @@ platform from the project's
|
|
|
29
29
|
the wheel's sha256 against the release manifest, extracts the compiled
|
|
30
30
|
extension into a per-user cache, and exposes the normal `a3s_code` API.
|
|
31
31
|
|
|
32
|
-
The v8.0
|
|
32
|
+
The v8.2.0 release uses one CPython 3.10 stable-ABI (`cp310-abi3`) wheel per
|
|
33
33
|
platform. That wheel is installable by CPython 3.10, 3.11, 3.12, 3.13, and
|
|
34
|
-
3.14.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
3.14. Supported native targets are macOS arm64/x86_64, Linux glibc 2.28+
|
|
35
|
+
arm64/x86_64, and Windows arm64/x86_64. Every supported wheel contains its
|
|
36
|
+
target Moli sidecar and provenance record. Linux musl is intentionally omitted
|
|
37
|
+
because upstream Moli publishes no musl asset; use a system/explicit browser
|
|
38
|
+
or another backend there. The loader still falls back to exact per-minor names
|
|
39
|
+
when reading older release manifests.
|
|
39
40
|
|
|
40
41
|
Subsequent imports use the cached extension. Cache lives under a
|
|
41
42
|
platform-specific directory, `~/.cache/a3s-code/<version>/<platform-tag>/`
|
|
42
43
|
(or `$A3S_CODE_CACHE_DIR/<version>/<platform-tag>/` when overridden).
|
|
44
|
+
The extracted Moli executable is placed in the same version/target cache and
|
|
45
|
+
is selected through `A3S_CODE_MOLI_EXECUTABLE`; atomic extraction and the
|
|
46
|
+
bootstrap's cross-process installer lock make concurrent first imports
|
|
47
|
+
single-flight. Rust, Node.js, and Go use the Core-managed Moli cache when they
|
|
48
|
+
are not using a package-local sidecar.
|
|
43
49
|
|
|
44
50
|
## Why
|
|
45
51
|
|
|
@@ -53,8 +59,8 @@ through PyPI.
|
|
|
53
59
|
|
|
54
60
|
- macOS arm64 (Apple Silicon, macOS 11+)
|
|
55
61
|
- macOS x86_64 (Intel, macOS 12+)
|
|
56
|
-
- Linux x86_64 (glibc 2.28+)
|
|
57
|
-
- Windows x86_64
|
|
62
|
+
- Linux x86_64 and arm64 (glibc 2.28+)
|
|
63
|
+
- Windows x86_64 and arm64
|
|
58
64
|
|
|
59
65
|
CPython 3.10, 3.11, 3.12, 3.13, and 3.14.
|
|
60
66
|
|
|
@@ -74,6 +80,9 @@ python3.14 -m pip install a3s-code
|
|
|
74
80
|
| `A3S_CODE_CACHE_DIR` | Cache root (defaults to `$XDG_CACHE_HOME/a3s-code` or `~/.cache/a3s-code`) |
|
|
75
81
|
| `A3S_CODE_RELEASES_BASE_URL` | Override the release base URL — useful for air-gapped mirrors |
|
|
76
82
|
| `A3S_CODE_SKIP_HASH_CHECK` | `1` skips sha256 verification (do not use in production) |
|
|
83
|
+
| `A3S_CODE_MOLI_EXECUTABLE` | Explicit verified Moli executable; skips package discovery |
|
|
84
|
+
| `A3S_CODE_MOLI_CACHE_DIR` | Core-managed shared Moli cache root (the bootstrap uses its version/target cache for a bundled sidecar) |
|
|
85
|
+
| `A3S_CODE_MOLI_RELEASE_BASE_URL` | HTTPS mirror for the pinned Moli release |
|
|
77
86
|
|
|
78
87
|
## Manual install
|
|
79
88
|
|
|
@@ -13,8 +13,10 @@ import io
|
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
15
|
import shutil
|
|
16
|
+
import subprocess
|
|
16
17
|
import sys
|
|
17
18
|
import tempfile
|
|
19
|
+
import time
|
|
18
20
|
import unittest
|
|
19
21
|
import unittest.mock as mock
|
|
20
22
|
import zipfile
|
|
@@ -32,12 +34,16 @@ _bootstrap = importlib.util.module_from_spec(_spec)
|
|
|
32
34
|
_spec.loader.exec_module(_bootstrap)
|
|
33
35
|
|
|
34
36
|
|
|
35
|
-
def _make_wheel(
|
|
36
|
-
""
|
|
37
|
+
def _make_wheel(
|
|
38
|
+
native_blob: bytes = b"fake-extension-blob", *, include_moli: bool = False
|
|
39
|
+
) -> bytes:
|
|
40
|
+
"""Build a minimal in-memory wheel containing a native extension."""
|
|
37
41
|
buf = io.BytesIO()
|
|
38
42
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
39
43
|
zf.writestr("a3s_code/__init__.py", "from ._native import *\n")
|
|
40
44
|
zf.writestr("a3s_code/_native.cpython-312-x86_64-linux-gnu.so", native_blob)
|
|
45
|
+
if include_moli:
|
|
46
|
+
zf.writestr("a3s_code/moli/moli", b"#!/bin/sh\nexit 0\n")
|
|
41
47
|
zf.writestr("a3s_code-3.2.1.dist-info/METADATA", "Metadata-Version: 2.1\n")
|
|
42
48
|
zf.writestr("a3s_code-3.2.1.dist-info/WHEEL", "Wheel-Version: 1.0\n")
|
|
43
49
|
return buf.getvalue()
|
|
@@ -69,6 +75,12 @@ class WheelFilenameTests(unittest.TestCase):
|
|
|
69
75
|
"a3s_code-3.2.1-cp312-cp312-manylinux_2_28_x86_64.whl",
|
|
70
76
|
)
|
|
71
77
|
|
|
78
|
+
def test_linux_arm64_cp312(self):
|
|
79
|
+
self.assertEqual(
|
|
80
|
+
self._filename_for("linux", "aarch64", 12),
|
|
81
|
+
"a3s_code-3.2.1-cp312-cp312-manylinux_2_28_aarch64.whl",
|
|
82
|
+
)
|
|
83
|
+
|
|
72
84
|
def test_macos_arm64_cp311(self):
|
|
73
85
|
self.assertEqual(
|
|
74
86
|
self._filename_for("darwin", "arm64", 11),
|
|
@@ -136,6 +148,12 @@ class WheelFilenameTests(unittest.TestCase):
|
|
|
136
148
|
"a3s_code-3.2.1-cp313-cp313-win_amd64.whl",
|
|
137
149
|
)
|
|
138
150
|
|
|
151
|
+
def test_windows_arm64_cp313(self):
|
|
152
|
+
self.assertEqual(
|
|
153
|
+
self._filename_for("win32", "ARM64", 13),
|
|
154
|
+
"a3s_code-3.2.1-cp313-cp313-win_arm64.whl",
|
|
155
|
+
)
|
|
156
|
+
|
|
139
157
|
def test_unsupported_platform_raises(self):
|
|
140
158
|
with self.assertRaises(_bootstrap.BootstrapError) as cm:
|
|
141
159
|
self._filename_for("freebsd", "x86_64", 12)
|
|
@@ -190,6 +208,56 @@ class CacheDirTests(unittest.TestCase):
|
|
|
190
208
|
)
|
|
191
209
|
|
|
192
210
|
|
|
211
|
+
class InstallLockTests(unittest.TestCase):
|
|
212
|
+
def test_lock_releases_for_another_import(self):
|
|
213
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
214
|
+
cache = Path(tmp) / "cache"
|
|
215
|
+
with _bootstrap._install_lock(cache):
|
|
216
|
+
self.assertTrue((cache / ".install.lock").is_file())
|
|
217
|
+
# A normal close must release the advisory lock, not merely leave
|
|
218
|
+
# an apparently reusable lock file behind.
|
|
219
|
+
with _bootstrap._install_lock(cache, timeout_s=0.2):
|
|
220
|
+
self.assertTrue((cache / ".install.lock").is_file())
|
|
221
|
+
|
|
222
|
+
def test_lock_serializes_independent_processes(self):
|
|
223
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
224
|
+
cache = Path(tmp) / "cache"
|
|
225
|
+
ready = Path(tmp) / "ready"
|
|
226
|
+
module_path = str(_BOOTSTRAP_PATH)
|
|
227
|
+
child_code = """
|
|
228
|
+
import importlib.util
|
|
229
|
+
import pathlib
|
|
230
|
+
import sys
|
|
231
|
+
import time
|
|
232
|
+
|
|
233
|
+
module_path, cache_path, ready_path = sys.argv[1:]
|
|
234
|
+
spec = importlib.util.spec_from_file_location('_bootstrap_child', module_path)
|
|
235
|
+
module = importlib.util.module_from_spec(spec)
|
|
236
|
+
spec.loader.exec_module(module)
|
|
237
|
+
with module._install_lock(pathlib.Path(cache_path)):
|
|
238
|
+
pathlib.Path(ready_path).write_text('ready', encoding='utf-8')
|
|
239
|
+
time.sleep(0.35)
|
|
240
|
+
"""
|
|
241
|
+
child = subprocess.Popen(
|
|
242
|
+
[sys.executable, "-c", child_code, module_path, str(cache), str(ready)],
|
|
243
|
+
stdout=subprocess.PIPE,
|
|
244
|
+
stderr=subprocess.PIPE,
|
|
245
|
+
text=True,
|
|
246
|
+
)
|
|
247
|
+
try:
|
|
248
|
+
deadline = time.monotonic() + 5
|
|
249
|
+
while not ready.exists() and time.monotonic() < deadline:
|
|
250
|
+
time.sleep(0.01)
|
|
251
|
+
self.assertTrue(ready.exists(), "child did not acquire install lock")
|
|
252
|
+
started = time.monotonic()
|
|
253
|
+
with _bootstrap._install_lock(cache, timeout_s=2):
|
|
254
|
+
waited = time.monotonic() - started
|
|
255
|
+
self.assertGreaterEqual(waited, 0.25)
|
|
256
|
+
finally:
|
|
257
|
+
stdout, stderr = child.communicate(timeout=5)
|
|
258
|
+
if child.returncode != 0:
|
|
259
|
+
self.fail(f"lock child failed: {stdout}\n{stderr}")
|
|
260
|
+
|
|
193
261
|
class ExtractNativeTests(unittest.TestCase):
|
|
194
262
|
def test_extracts_native_extension(self):
|
|
195
263
|
wheel_bytes = _make_wheel(b"native-bytes")
|
|
@@ -200,6 +268,27 @@ class ExtractNativeTests(unittest.TestCase):
|
|
|
200
268
|
self.assertTrue(out.name.startswith("_native."))
|
|
201
269
|
self.assertEqual(out.read_bytes(), b"native-bytes")
|
|
202
270
|
|
|
271
|
+
def test_extracts_and_configures_bundled_moli_sidecar(self):
|
|
272
|
+
wheel_bytes = _make_wheel(include_moli=True)
|
|
273
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
274
|
+
target = Path(tmp) / "a3s_code"
|
|
275
|
+
with mock.patch.dict(os.environ, {}, clear=False):
|
|
276
|
+
os.environ.pop("A3S_CODE_MOLI_EXECUTABLE", None)
|
|
277
|
+
_bootstrap._extract_native(wheel_bytes, target)
|
|
278
|
+
moli = target / "moli" / "moli"
|
|
279
|
+
self.assertTrue(moli.is_file())
|
|
280
|
+
self.assertEqual(os.environ.get("A3S_CODE_MOLI_EXECUTABLE"), str(moli))
|
|
281
|
+
self.assertTrue(os.access(moli, os.X_OK))
|
|
282
|
+
|
|
283
|
+
def test_rejects_unsafe_wheel_member(self):
|
|
284
|
+
buf = io.BytesIO()
|
|
285
|
+
with zipfile.ZipFile(buf, "w") as zf:
|
|
286
|
+
zf.writestr("../escape", b"bad")
|
|
287
|
+
zf.writestr("a3s_code/_native.so", b"native")
|
|
288
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
289
|
+
with self.assertRaises(_bootstrap.BootstrapError):
|
|
290
|
+
_bootstrap._extract_native(buf.getvalue(), Path(tmp) / "pkg")
|
|
291
|
+
|
|
203
292
|
def test_wheel_without_native_raises(self):
|
|
204
293
|
buf = io.BytesIO()
|
|
205
294
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|