fastjs 0.1.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.
- fastjs-0.1.0/LICENSE +21 -0
- fastjs-0.1.0/MANIFEST.in +2 -0
- fastjs-0.1.0/PKG-INFO +123 -0
- fastjs-0.1.0/README.md +110 -0
- fastjs-0.1.0/_quickjs_build.py +88 -0
- fastjs-0.1.0/licenses/QuickJS.txt +22 -0
- fastjs-0.1.0/pyproject.toml +26 -0
- fastjs-0.1.0/setup.cfg +4 -0
- fastjs-0.1.0/setup.py +47 -0
- fastjs-0.1.0/src/fastjs/__init__.py +9 -0
- fastjs-0.1.0/src/fastjs/__init__.pyi +14 -0
- fastjs-0.1.0/src/fastjs/_native.c +225 -0
- fastjs-0.1.0/src/fastjs/py.typed +0 -0
- fastjs-0.1.0/src/fastjs.egg-info/PKG-INFO +123 -0
- fastjs-0.1.0/src/fastjs.egg-info/SOURCES.txt +18 -0
- fastjs-0.1.0/src/fastjs.egg-info/dependency_links.txt +1 -0
- fastjs-0.1.0/src/fastjs.egg-info/requires.txt +3 -0
- fastjs-0.1.0/src/fastjs.egg-info/top_level.txt +1 -0
- fastjs-0.1.0/tests/test_fastjs.py +149 -0
- fastjs-0.1.0/tests/test_quickjs_build.py +91 -0
fastjs-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fastjs contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
18
|
+
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
fastjs-0.1.0/MANIFEST.in
ADDED
fastjs-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastjs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python bindings for Bellard's QuickJS engine
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
License-File: licenses/QuickJS.txt
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# fastjs
|
|
15
|
+
|
|
16
|
+
Python bindings for [Bellard's QuickJS](https://github.com/bellard/quickjs).
|
|
17
|
+
Source builds download QuickJS revision
|
|
18
|
+
`e585640ffa2cf88ce7c2d587583c6fe15928ec2f` (version 2026-06-04), verify its
|
|
19
|
+
SHA-256 checksum, and compile it into the CPython extension. This requires a
|
|
20
|
+
compiler supporting GNU C11 and Python development headers. Linux, macOS, and
|
|
21
|
+
Windows x86_64 builds are configured; Windows uses MinGW-w64 UCRT64 GCC rather
|
|
22
|
+
than MSVC. The release workflow tests wheels on each platform.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
python -m pip install .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import fastjs
|
|
30
|
+
|
|
31
|
+
assert fastjs.eval('1 + 2') == 3
|
|
32
|
+
ctx = fastjs.Context(memory_limit=64 * 1024 * 1024, time_limit=0.5)
|
|
33
|
+
ctx.set('name', 'Python')
|
|
34
|
+
assert ctx.eval('`Hello, ${name}!`') == 'Hello, Python!'
|
|
35
|
+
add = ctx.eval('(a, b) => a + b')
|
|
36
|
+
assert add(20, 22) == 42
|
|
37
|
+
|
|
38
|
+
obj = ctx.parse_json('{"items": [1, 2, 3]}')
|
|
39
|
+
ctx.set('data', obj)
|
|
40
|
+
assert ctx.eval('data.items.length') == 3
|
|
41
|
+
assert obj.json() == '{"items":[1,2,3]}'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`Context.eval(source, filename='<eval>')` runs a global script. JavaScript
|
|
45
|
+
exceptions become `fastjs.JSException`. Global lexical declarations (`let`,
|
|
46
|
+
`const`) are available to subsequent scripts, but `get(name)` reads properties
|
|
47
|
+
of the global object, such as those created by `var`.
|
|
48
|
+
|
|
49
|
+
JavaScript null and undefined both become Python `None`; booleans, numbers,
|
|
50
|
+
strings and BigInts become Python scalars. Other values become `Object` handles.
|
|
51
|
+
Functions support positional calls with undefined as their `this` value.
|
|
52
|
+
`Object.json()` returns a JSON string, or `None` when JSON.stringify returns
|
|
53
|
+
undefined. Symbols remain opaque handles. Python arguments and globals support
|
|
54
|
+
`None`, bool, float, str, safe integers and objects from the same context.
|
|
55
|
+
Python integers outside ±(2**53 - 1) raise OverflowError; create BigInts through
|
|
56
|
+
JavaScript expressions. Use `parse_json(json.dumps(value))` for Python lists and
|
|
57
|
+
dicts. Python callbacks are not implemented yet.
|
|
58
|
+
|
|
59
|
+
`execute_pending_job()` runs one queued promise job and returns whether one ran:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
ctx.eval('var answer; Promise.resolve(42).then(v => { answer = v; })')
|
|
63
|
+
while ctx.execute_pending_job():
|
|
64
|
+
pass
|
|
65
|
+
assert ctx.get('answer') == 42
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Contexts and their objects must be used on the thread that created the context.
|
|
69
|
+
Objects keep their context alive. The extension holds the GIL during execution.
|
|
70
|
+
`time_limit` is seconds per operation (zero disables it); `memory_limit` is bytes
|
|
71
|
+
(zero disables it); `stack_limit` defaults to 1 MiB and must be positive.
|
|
72
|
+
The interrupt handler also checks Python signals. Limits help bound execution;
|
|
73
|
+
this package is not a security boundary for hostile code. No filesystem,
|
|
74
|
+
network, module loader, timers, console or QuickJS libc helpers are installed.
|
|
75
|
+
Optional Intl/Temporal support is not built.
|
|
76
|
+
|
|
77
|
+
Development:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
uv venv --python 3.14
|
|
81
|
+
uv pip install -e '.[test]'
|
|
82
|
+
.venv/bin/python -m pytest
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Downloaded archives are cached under `$XDG_CACHE_HOME/fastjs` (default:
|
|
86
|
+
`~/.cache/fastjs`). Set `FASTJS_CACHE_DIR` to override that directory. The checksum
|
|
87
|
+
is checked on every build, including cache hits. Source builds need network
|
|
88
|
+
access on the first build; subsequent builds can reuse the cached archive
|
|
89
|
+
offline. Generating package metadata and source distributions does not download
|
|
90
|
+
QuickJS. Installing a prebuilt wheel needs neither QuickJS sources nor a compiler.
|
|
91
|
+
|
|
92
|
+
QuickJS retains its upstream MIT license in `licenses/QuickJS.txt`, which is also
|
|
93
|
+
included in distribution metadata.
|
|
94
|
+
|
|
95
|
+
The `.github/workflows/test.yml` workflow builds and installs the extension and
|
|
96
|
+
runs the tests on branch pushes and pull requests. It covers CPython 3.10–3.15
|
|
97
|
+
on Linux (x86_64 and aarch64), macOS (Intel and Apple Silicon), and Windows
|
|
98
|
+
(x86_64). It does not publish packages.
|
|
99
|
+
|
|
100
|
+
Releases are published to PyPI by `.github/workflows/release.yml` when a version
|
|
101
|
+
tag is pushed. Set `project.version` in `pyproject.toml` to the release version,
|
|
102
|
+
commit the changes, then push the commit and matching tag:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
git tag 0.1.0
|
|
106
|
+
git push origin HEAD
|
|
107
|
+
git push origin 0.1.0
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The tag must exactly match the package version (no `v` prefix). The workflow
|
|
111
|
+
builds a source distribution and tests CPython 3.10–3.15 wheels on Linux
|
|
112
|
+
(manylinux, x86_64 and aarch64), macOS (Intel and Apple Silicon), and Windows
|
|
113
|
+
(x86_64) before publishing. Windows ARM64, 32-bit, and free-threaded Python
|
|
114
|
+
wheels are not built.
|
|
115
|
+
PyPI trusted publishing must name the GitHub repository and `release.yml`, with
|
|
116
|
+
no environment specified. Publishing uses OIDC and needs no PyPI API token.
|
|
117
|
+
|
|
118
|
+
For a Windows source build, install MSYS2's `mingw-w64-ucrt-x86_64-gcc` package
|
|
119
|
+
and add the MSYS2 `ucrt64\bin` directory to PATH. Use standard Windows CPython,
|
|
120
|
+
then run `python -m pip wheel .`. The build selects setuptools' `mingw32`
|
|
121
|
+
compiler automatically. Published wheels need neither MSYS2 nor GCC installed;
|
|
122
|
+
the extension statically links compiler runtime support and winpthreads. The
|
|
123
|
+
compiler itself is used only during builds.
|
fastjs-0.1.0/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# fastjs
|
|
2
|
+
|
|
3
|
+
Python bindings for [Bellard's QuickJS](https://github.com/bellard/quickjs).
|
|
4
|
+
Source builds download QuickJS revision
|
|
5
|
+
`e585640ffa2cf88ce7c2d587583c6fe15928ec2f` (version 2026-06-04), verify its
|
|
6
|
+
SHA-256 checksum, and compile it into the CPython extension. This requires a
|
|
7
|
+
compiler supporting GNU C11 and Python development headers. Linux, macOS, and
|
|
8
|
+
Windows x86_64 builds are configured; Windows uses MinGW-w64 UCRT64 GCC rather
|
|
9
|
+
than MSVC. The release workflow tests wheels on each platform.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
python -m pip install .
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import fastjs
|
|
17
|
+
|
|
18
|
+
assert fastjs.eval('1 + 2') == 3
|
|
19
|
+
ctx = fastjs.Context(memory_limit=64 * 1024 * 1024, time_limit=0.5)
|
|
20
|
+
ctx.set('name', 'Python')
|
|
21
|
+
assert ctx.eval('`Hello, ${name}!`') == 'Hello, Python!'
|
|
22
|
+
add = ctx.eval('(a, b) => a + b')
|
|
23
|
+
assert add(20, 22) == 42
|
|
24
|
+
|
|
25
|
+
obj = ctx.parse_json('{"items": [1, 2, 3]}')
|
|
26
|
+
ctx.set('data', obj)
|
|
27
|
+
assert ctx.eval('data.items.length') == 3
|
|
28
|
+
assert obj.json() == '{"items":[1,2,3]}'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`Context.eval(source, filename='<eval>')` runs a global script. JavaScript
|
|
32
|
+
exceptions become `fastjs.JSException`. Global lexical declarations (`let`,
|
|
33
|
+
`const`) are available to subsequent scripts, but `get(name)` reads properties
|
|
34
|
+
of the global object, such as those created by `var`.
|
|
35
|
+
|
|
36
|
+
JavaScript null and undefined both become Python `None`; booleans, numbers,
|
|
37
|
+
strings and BigInts become Python scalars. Other values become `Object` handles.
|
|
38
|
+
Functions support positional calls with undefined as their `this` value.
|
|
39
|
+
`Object.json()` returns a JSON string, or `None` when JSON.stringify returns
|
|
40
|
+
undefined. Symbols remain opaque handles. Python arguments and globals support
|
|
41
|
+
`None`, bool, float, str, safe integers and objects from the same context.
|
|
42
|
+
Python integers outside ±(2**53 - 1) raise OverflowError; create BigInts through
|
|
43
|
+
JavaScript expressions. Use `parse_json(json.dumps(value))` for Python lists and
|
|
44
|
+
dicts. Python callbacks are not implemented yet.
|
|
45
|
+
|
|
46
|
+
`execute_pending_job()` runs one queued promise job and returns whether one ran:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
ctx.eval('var answer; Promise.resolve(42).then(v => { answer = v; })')
|
|
50
|
+
while ctx.execute_pending_job():
|
|
51
|
+
pass
|
|
52
|
+
assert ctx.get('answer') == 42
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Contexts and their objects must be used on the thread that created the context.
|
|
56
|
+
Objects keep their context alive. The extension holds the GIL during execution.
|
|
57
|
+
`time_limit` is seconds per operation (zero disables it); `memory_limit` is bytes
|
|
58
|
+
(zero disables it); `stack_limit` defaults to 1 MiB and must be positive.
|
|
59
|
+
The interrupt handler also checks Python signals. Limits help bound execution;
|
|
60
|
+
this package is not a security boundary for hostile code. No filesystem,
|
|
61
|
+
network, module loader, timers, console or QuickJS libc helpers are installed.
|
|
62
|
+
Optional Intl/Temporal support is not built.
|
|
63
|
+
|
|
64
|
+
Development:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
uv venv --python 3.14
|
|
68
|
+
uv pip install -e '.[test]'
|
|
69
|
+
.venv/bin/python -m pytest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Downloaded archives are cached under `$XDG_CACHE_HOME/fastjs` (default:
|
|
73
|
+
`~/.cache/fastjs`). Set `FASTJS_CACHE_DIR` to override that directory. The checksum
|
|
74
|
+
is checked on every build, including cache hits. Source builds need network
|
|
75
|
+
access on the first build; subsequent builds can reuse the cached archive
|
|
76
|
+
offline. Generating package metadata and source distributions does not download
|
|
77
|
+
QuickJS. Installing a prebuilt wheel needs neither QuickJS sources nor a compiler.
|
|
78
|
+
|
|
79
|
+
QuickJS retains its upstream MIT license in `licenses/QuickJS.txt`, which is also
|
|
80
|
+
included in distribution metadata.
|
|
81
|
+
|
|
82
|
+
The `.github/workflows/test.yml` workflow builds and installs the extension and
|
|
83
|
+
runs the tests on branch pushes and pull requests. It covers CPython 3.10–3.15
|
|
84
|
+
on Linux (x86_64 and aarch64), macOS (Intel and Apple Silicon), and Windows
|
|
85
|
+
(x86_64). It does not publish packages.
|
|
86
|
+
|
|
87
|
+
Releases are published to PyPI by `.github/workflows/release.yml` when a version
|
|
88
|
+
tag is pushed. Set `project.version` in `pyproject.toml` to the release version,
|
|
89
|
+
commit the changes, then push the commit and matching tag:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
git tag 0.1.0
|
|
93
|
+
git push origin HEAD
|
|
94
|
+
git push origin 0.1.0
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The tag must exactly match the package version (no `v` prefix). The workflow
|
|
98
|
+
builds a source distribution and tests CPython 3.10–3.15 wheels on Linux
|
|
99
|
+
(manylinux, x86_64 and aarch64), macOS (Intel and Apple Silicon), and Windows
|
|
100
|
+
(x86_64) before publishing. Windows ARM64, 32-bit, and free-threaded Python
|
|
101
|
+
wheels are not built.
|
|
102
|
+
PyPI trusted publishing must name the GitHub repository and `release.yml`, with
|
|
103
|
+
no environment specified. Publishing uses OIDC and needs no PyPI API token.
|
|
104
|
+
|
|
105
|
+
For a Windows source build, install MSYS2's `mingw-w64-ucrt-x86_64-gcc` package
|
|
106
|
+
and add the MSYS2 `ucrt64\bin` directory to PATH. Use standard Windows CPython,
|
|
107
|
+
then run `python -m pip wheel .`. The build selects setuptools' `mingw32`
|
|
108
|
+
compiler automatically. Published wheels need neither MSYS2 nor GCC installed;
|
|
109
|
+
the extension statically links compiler runtime support and winpthreads. The
|
|
110
|
+
compiler itself is used only during builds.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Fetch the exact QuickJS input used by source builds (stdlib only)."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import tarfile
|
|
7
|
+
import tempfile
|
|
8
|
+
from urllib.error import URLError
|
|
9
|
+
from urllib.request import urlopen
|
|
10
|
+
|
|
11
|
+
REVISION = "e585640ffa2cf88ce7c2d587583c6fe15928ec2f"
|
|
12
|
+
VERSION = "2026-06-04"
|
|
13
|
+
SHA256 = "56d9d9d7e206ea77a1a0badc560536e455d50639db6c0cc6e6ca8a8c5a12d47f"
|
|
14
|
+
URL = f"https://codeload.github.com/bellard/quickjs/tar.gz/{REVISION}"
|
|
15
|
+
ARCHIVE_NAME = f"quickjs-{REVISION}.tar.gz"
|
|
16
|
+
SOURCES = ("quickjs", "dtoa", "libregexp", "libunicode", "cutils")
|
|
17
|
+
FILES = (
|
|
18
|
+
"quickjs.c", "quickjs.h", "quickjs-atom.h", "quickjs-opcode.h",
|
|
19
|
+
"dtoa.c", "dtoa.h", "libregexp.c", "libregexp.h", "libregexp-opcode.h",
|
|
20
|
+
"libunicode.c", "libunicode.h", "libunicode-table.h", "cutils.c", "cutils.h",
|
|
21
|
+
"list.h", "LICENSE", "VERSION",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cache_dir():
|
|
26
|
+
override = os.environ.get("FASTJS_CACHE_DIR")
|
|
27
|
+
if override:
|
|
28
|
+
return Path(override).expanduser()
|
|
29
|
+
return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "fastjs"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def verify(data):
|
|
33
|
+
if hashlib.sha256(data).hexdigest() != SHA256:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
f"QuickJS SHA-256 mismatch for revision {REVISION}; "
|
|
36
|
+
f"expected {SHA256}. Remove any corrupt cached {ARCHIVE_NAME} and retry."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fetch_archive():
|
|
41
|
+
"""Verify every cache hit; publish new downloads atomically."""
|
|
42
|
+
archive = cache_dir() / ARCHIVE_NAME
|
|
43
|
+
if archive.is_file():
|
|
44
|
+
data = archive.read_bytes()
|
|
45
|
+
verify(data)
|
|
46
|
+
return data
|
|
47
|
+
try:
|
|
48
|
+
with urlopen(URL, timeout=60) as response:
|
|
49
|
+
data = response.read()
|
|
50
|
+
except (OSError, URLError) as exc:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"Could not download QuickJS from {URL}. Source builds need network "
|
|
53
|
+
f"access or a verified archive cached at {archive}. "
|
|
54
|
+
"Set FASTJS_CACHE_DIR to select another cache directory."
|
|
55
|
+
) from exc
|
|
56
|
+
verify(data)
|
|
57
|
+
archive.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
temp_path = None
|
|
59
|
+
try:
|
|
60
|
+
with tempfile.NamedTemporaryFile(dir=archive.parent, delete=False) as temp:
|
|
61
|
+
temp_path = Path(temp.name)
|
|
62
|
+
temp.write(data)
|
|
63
|
+
temp_path.replace(archive)
|
|
64
|
+
finally:
|
|
65
|
+
if temp_path is not None:
|
|
66
|
+
temp_path.unlink(missing_ok=True)
|
|
67
|
+
return data
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def prepare_sources(destination):
|
|
71
|
+
"""Extract only known regular files, never archive paths or links."""
|
|
72
|
+
data = fetch_archive()
|
|
73
|
+
destination = Path(destination)
|
|
74
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
prefix = f"quickjs-{REVISION}"
|
|
76
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
|
|
77
|
+
for name in FILES:
|
|
78
|
+
member = archive.getmember(f"{prefix}/{name}")
|
|
79
|
+
if not member.isfile():
|
|
80
|
+
raise RuntimeError(f"QuickJS archive member is not a regular file: {name}")
|
|
81
|
+
with archive.extractfile(member) as source:
|
|
82
|
+
content = source.read()
|
|
83
|
+
target = destination / name
|
|
84
|
+
if not target.is_file() or target.read_bytes() != content:
|
|
85
|
+
target.write_bytes(content)
|
|
86
|
+
if (destination / "VERSION").read_text().strip() != VERSION:
|
|
87
|
+
raise RuntimeError("QuickJS archive version does not match the build configuration")
|
|
88
|
+
return destination
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
QuickJS Javascript Engine
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2017-2021 Fabrice Bellard
|
|
4
|
+
Copyright (c) 2017-2021 Charlie Gordon
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in
|
|
14
|
+
all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
19
|
+
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
22
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fastjs"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python bindings for Bellard's QuickJS engine"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE", "licenses/QuickJS.txt"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
test = ["pytest>=7"]
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["src"]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.package-data]
|
|
21
|
+
fastjs = ["*.pyi", "py.typed"]
|
|
22
|
+
|
|
23
|
+
[tool.cibuildwheel]
|
|
24
|
+
build = "cp{310,311,312,313,314,315}-{manylinux,macosx}_* cp{310,311,312,313,314,315}-win_amd64"
|
|
25
|
+
test-requires = ["pytest>=7"]
|
|
26
|
+
test-command = "python -m pytest {package}/tests -q"
|
fastjs-0.1.0/setup.cfg
ADDED
fastjs-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import runpy
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from setuptools import Extension, setup
|
|
6
|
+
from setuptools.command.build_ext import build_ext
|
|
7
|
+
|
|
8
|
+
# PEP 517 does not guarantee that the source root is on sys.path.
|
|
9
|
+
_quickjs = runpy.run_path(str(Path(__file__).with_name("_quickjs_build.py")))
|
|
10
|
+
|
|
11
|
+
WINDOWS = sys.platform == "win32"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QuickJSBuildExt(build_ext):
|
|
15
|
+
def finalize_options(self):
|
|
16
|
+
# QuickJS needs GNU C extensions and winpthreads on Windows.
|
|
17
|
+
if WINDOWS and self.compiler is None:
|
|
18
|
+
self.compiler = "mingw32"
|
|
19
|
+
super().finalize_options()
|
|
20
|
+
|
|
21
|
+
def build_extensions(self):
|
|
22
|
+
if WINDOWS and self.compiler.compiler_type != "mingw32":
|
|
23
|
+
raise RuntimeError("Windows builds require MinGW-w64 (UCRT64) GCC on PATH")
|
|
24
|
+
root = _quickjs["prepare_sources"](Path(self.build_temp) / "quickjs")
|
|
25
|
+
for extension in self.extensions:
|
|
26
|
+
if extension.name == "fastjs._native":
|
|
27
|
+
extension.sources = ["src/fastjs/_native.c"] + [
|
|
28
|
+
str(root / f"{name}.c") for name in _quickjs["SOURCES"]
|
|
29
|
+
]
|
|
30
|
+
extension.include_dirs = [str(root)]
|
|
31
|
+
super().build_extensions()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
setup(
|
|
35
|
+
cmdclass={"build_ext": QuickJSBuildExt},
|
|
36
|
+
ext_modules=[Extension(
|
|
37
|
+
"fastjs._native",
|
|
38
|
+
sources=["src/fastjs/_native.c"],
|
|
39
|
+
define_macros=[("_GNU_SOURCE", None), ("CONFIG_VERSION", '"{}"'.format(_quickjs["VERSION"]))]
|
|
40
|
+
+ ([("__USE_MINGW_ANSI_STDIO", "1")] if WINDOWS else []),
|
|
41
|
+
libraries=["m"],
|
|
42
|
+
extra_compile_args=["-std=gnu11", "-fwrapv", "-Wno-unused-parameter"],
|
|
43
|
+
# Keep wheels independent of MinGW DLLs on the user's PATH.
|
|
44
|
+
extra_link_args=["-static-libgcc", "-Wl,-Bstatic", "-lwinpthread", "-Wl,-Bdynamic"]
|
|
45
|
+
if WINDOWS else [],
|
|
46
|
+
)],
|
|
47
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Embed Bellard's QuickJS JavaScript engine in Python."""
|
|
2
|
+
from ._native import Context, JSException, Object
|
|
3
|
+
|
|
4
|
+
__all__ = ["Context", "JSException", "Object", "eval"]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def eval(source: str):
|
|
8
|
+
"""Evaluate JavaScript in a fresh context."""
|
|
9
|
+
return Context().eval(source)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
class JSException(Exception): ...
|
|
4
|
+
class Object:
|
|
5
|
+
def __call__(self, *args: None | bool | int | float | str | Object) -> Any: ...
|
|
6
|
+
def json(self) -> str | None: ...
|
|
7
|
+
class Context:
|
|
8
|
+
def __new__(cls, memory_limit: int = 0, stack_limit: int = 1048576, time_limit: float = 0) -> Context: ...
|
|
9
|
+
def eval(self, source: str, filename: str = '<eval>') -> Any: ...
|
|
10
|
+
def get(self, name: str) -> Any: ...
|
|
11
|
+
def set(self, name: str, value: None | bool | int | float | str | Object) -> None: ...
|
|
12
|
+
def parse_json(self, source: str) -> Any: ...
|
|
13
|
+
def execute_pending_job(self) -> bool: ...
|
|
14
|
+
def eval(source: str) -> Any: ...
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#define PY_SSIZE_T_CLEAN
|
|
2
|
+
#include <Python.h>
|
|
3
|
+
#include <math.h>
|
|
4
|
+
#include <time.h>
|
|
5
|
+
#ifdef _WIN32
|
|
6
|
+
#include <windows.h>
|
|
7
|
+
#endif
|
|
8
|
+
#include "quickjs.h"
|
|
9
|
+
|
|
10
|
+
typedef struct {
|
|
11
|
+
PyObject_HEAD
|
|
12
|
+
JSRuntime *rt;
|
|
13
|
+
JSContext *ctx;
|
|
14
|
+
double timeout, deadline;
|
|
15
|
+
unsigned long thread;
|
|
16
|
+
} Context;
|
|
17
|
+
typedef struct { PyObject_HEAD Context *owner; JSValue value; } Object;
|
|
18
|
+
static PyTypeObject ContextType, ObjectType;
|
|
19
|
+
static PyObject *JSException;
|
|
20
|
+
|
|
21
|
+
static double now(void) {
|
|
22
|
+
#ifdef _WIN32
|
|
23
|
+
LARGE_INTEGER counter, frequency;
|
|
24
|
+
QueryPerformanceFrequency(&frequency);
|
|
25
|
+
QueryPerformanceCounter(&counter);
|
|
26
|
+
return (double)counter.QuadPart / (double)frequency.QuadPart;
|
|
27
|
+
#else
|
|
28
|
+
struct timespec t;
|
|
29
|
+
clock_gettime(CLOCK_MONOTONIC, &t);
|
|
30
|
+
return t.tv_sec + t.tv_nsec / 1e9;
|
|
31
|
+
#endif
|
|
32
|
+
}
|
|
33
|
+
static int interrupt(JSRuntime *rt, void *opaque) {
|
|
34
|
+
Context *c = opaque;
|
|
35
|
+
return (c->deadline > 0 && now() >= c->deadline) || PyErr_CheckSignals() < 0;
|
|
36
|
+
}
|
|
37
|
+
static int enter(Context *c) {
|
|
38
|
+
if (c->thread != PyThread_get_thread_ident()) {
|
|
39
|
+
PyErr_SetString(PyExc_RuntimeError, "Context must be used on its creating thread");
|
|
40
|
+
return -1;
|
|
41
|
+
}
|
|
42
|
+
c->deadline = c->timeout > 0 ? now() + c->timeout : 0;
|
|
43
|
+
JS_UpdateStackTop(c->rt);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
static PyObject *error(Context *c) {
|
|
47
|
+
JSValue exc = JS_GetException(c->ctx);
|
|
48
|
+
const char *s = JS_ToCString(c->ctx, exc);
|
|
49
|
+
if (!PyErr_Occurred()) PyErr_SetString(JSException, s ? s : "JavaScript exception");
|
|
50
|
+
if (s) JS_FreeCString(c->ctx, s);
|
|
51
|
+
JS_FreeValue(c->ctx, exc);
|
|
52
|
+
return NULL;
|
|
53
|
+
}
|
|
54
|
+
static PyObject *convert(Context *c, JSValue v) {
|
|
55
|
+
PyObject *result = NULL;
|
|
56
|
+
if (JS_IsException(v)) return error(c);
|
|
57
|
+
if (JS_IsNull(v) || JS_IsUndefined(v)) { result = Py_NewRef(Py_None); }
|
|
58
|
+
else if (JS_IsBool(v)) result = PyBool_FromLong(JS_ToBool(c->ctx, v));
|
|
59
|
+
else if (JS_IsNumber(v)) {
|
|
60
|
+
if (JS_VALUE_GET_TAG(v) == JS_TAG_INT) result = PyLong_FromLong(JS_VALUE_GET_INT(v));
|
|
61
|
+
else { double d; JS_ToFloat64(c->ctx, &d, v); result = PyFloat_FromDouble(d); }
|
|
62
|
+
} else if (JS_IsString(v) || JS_IsBigInt(c->ctx, v)) {
|
|
63
|
+
size_t len;
|
|
64
|
+
const char *s = JS_ToCStringLen(c->ctx, &len, v);
|
|
65
|
+
if (!s) { JS_FreeValue(c->ctx, v); return error(c); }
|
|
66
|
+
if (JS_IsBigInt(c->ctx, v)) result = PyLong_FromString((char *)s, NULL, 10);
|
|
67
|
+
else result = PyUnicode_DecodeUTF8(s, len, "surrogatepass");
|
|
68
|
+
JS_FreeCString(c->ctx, s);
|
|
69
|
+
} else {
|
|
70
|
+
Object *o = PyObject_New(Object, &ObjectType);
|
|
71
|
+
if (o) { o->owner = (Context *)Py_NewRef((PyObject *)c); o->value = v; return (PyObject *)o; }
|
|
72
|
+
}
|
|
73
|
+
JS_FreeValue(c->ctx, v);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
static JSValue from_python(Context *c, PyObject *p) {
|
|
77
|
+
if (p == Py_None) return JS_NULL;
|
|
78
|
+
if (PyBool_Check(p)) return JS_NewBool(c->ctx, p == Py_True);
|
|
79
|
+
if (PyObject_TypeCheck(p, &ObjectType)) {
|
|
80
|
+
Object *o = (Object *)p;
|
|
81
|
+
if (o->owner != c) { PyErr_SetString(PyExc_ValueError, "Objects cannot cross contexts"); return JS_EXCEPTION; }
|
|
82
|
+
return JS_DupValue(c->ctx, o->value);
|
|
83
|
+
}
|
|
84
|
+
if (PyLong_Check(p)) {
|
|
85
|
+
int overflow;
|
|
86
|
+
long long n = PyLong_AsLongLongAndOverflow(p, &overflow);
|
|
87
|
+
if (!overflow && n >= -9007199254740991LL && n <= 9007199254740991LL)
|
|
88
|
+
return JS_NewInt64(c->ctx, n);
|
|
89
|
+
PyErr_SetString(PyExc_OverflowError, "Use a JavaScript BigInt expression for integers outside the safe range");
|
|
90
|
+
return JS_EXCEPTION;
|
|
91
|
+
}
|
|
92
|
+
if (PyFloat_Check(p)) return JS_NewFloat64(c->ctx, PyFloat_AS_DOUBLE(p));
|
|
93
|
+
if (PyUnicode_Check(p)) {
|
|
94
|
+
Py_ssize_t len;
|
|
95
|
+
const char *s = PyUnicode_AsUTF8AndSize(p, &len);
|
|
96
|
+
return s ? JS_NewStringLen(c->ctx, s, len) : JS_EXCEPTION;
|
|
97
|
+
}
|
|
98
|
+
PyErr_SetString(PyExc_TypeError, "Expected None, bool, int, float, str, or fastjs.Object");
|
|
99
|
+
return JS_EXCEPTION;
|
|
100
|
+
}
|
|
101
|
+
static PyObject *context_new(PyTypeObject *type, PyObject *args, PyObject *kw) {
|
|
102
|
+
static char *names[] = {"memory_limit", "stack_limit", "time_limit", NULL};
|
|
103
|
+
Py_ssize_t memory = 0, stack = 1024 * 1024;
|
|
104
|
+
double timeout = 0;
|
|
105
|
+
if (!PyArg_ParseTupleAndKeywords(args, kw, "|nnd", names, &memory, &stack, &timeout)) return NULL;
|
|
106
|
+
if (memory < 0 || stack <= 0 || timeout < 0 || !isfinite(timeout)) {
|
|
107
|
+
PyErr_SetString(PyExc_ValueError, "Limits must be nonnegative and finite; stack_limit must be positive"); return NULL;
|
|
108
|
+
}
|
|
109
|
+
Context *c = (Context *)type->tp_alloc(type, 0);
|
|
110
|
+
if (!c) return NULL;
|
|
111
|
+
c->thread = PyThread_get_thread_ident(); c->timeout = timeout;
|
|
112
|
+
c->rt = JS_NewRuntime();
|
|
113
|
+
if (!c->rt) { Py_DECREF(c); return PyErr_NoMemory(); }
|
|
114
|
+
if (memory) JS_SetMemoryLimit(c->rt, memory);
|
|
115
|
+
JS_SetMaxStackSize(c->rt, stack);
|
|
116
|
+
c->ctx = JS_NewContext(c->rt);
|
|
117
|
+
if (!c->ctx) { Py_DECREF(c); return PyErr_NoMemory(); }
|
|
118
|
+
JS_SetInterruptHandler(c->rt, interrupt, c);
|
|
119
|
+
return (PyObject *)c;
|
|
120
|
+
}
|
|
121
|
+
static void context_dealloc(Context *c) {
|
|
122
|
+
if (c->ctx) JS_FreeContext(c->ctx);
|
|
123
|
+
if (c->rt) JS_FreeRuntime(c->rt);
|
|
124
|
+
Py_TYPE(c)->tp_free((PyObject *)c);
|
|
125
|
+
}
|
|
126
|
+
static PyObject *context_eval(Context *c, PyObject *args, PyObject *kw) {
|
|
127
|
+
const char *source, *filename = "<eval>"; Py_ssize_t len;
|
|
128
|
+
static char *names[] = {"source", "filename", NULL};
|
|
129
|
+
if (!PyArg_ParseTupleAndKeywords(args, kw, "s#|s", names, &source, &len, &filename) || enter(c)) return NULL;
|
|
130
|
+
return convert(c, JS_Eval(c->ctx, source, len, filename, JS_EVAL_TYPE_GLOBAL));
|
|
131
|
+
}
|
|
132
|
+
static PyObject *context_parse(Context *c, PyObject *arg) {
|
|
133
|
+
Py_ssize_t len; const char *s = PyUnicode_AsUTF8AndSize(arg, &len);
|
|
134
|
+
if (!s || enter(c)) return NULL;
|
|
135
|
+
return convert(c, JS_ParseJSON(c->ctx, s, len, "<json>"));
|
|
136
|
+
}
|
|
137
|
+
static PyObject *context_get(Context *c, PyObject *arg) {
|
|
138
|
+
const char *name = PyUnicode_AsUTF8(arg);
|
|
139
|
+
if (!name || enter(c)) return NULL;
|
|
140
|
+
if (PyUnicode_FindChar(arg, 0, 0, PyUnicode_GET_LENGTH(arg), 1) >= 0) {
|
|
141
|
+
PyErr_SetString(PyExc_ValueError, "Global names cannot contain NUL"); return NULL;
|
|
142
|
+
}
|
|
143
|
+
JSValue global = JS_GetGlobalObject(c->ctx);
|
|
144
|
+
JSValue v = JS_GetPropertyStr(c->ctx, global, name);
|
|
145
|
+
JS_FreeValue(c->ctx, global);
|
|
146
|
+
return convert(c, v);
|
|
147
|
+
}
|
|
148
|
+
static PyObject *context_set(Context *c, PyObject *args) {
|
|
149
|
+
const char *name; PyObject *p;
|
|
150
|
+
if (!PyArg_ParseTuple(args, "sO", &name, &p) || enter(c)) return NULL;
|
|
151
|
+
JSValue v = from_python(c, p);
|
|
152
|
+
if (JS_IsException(v)) return PyErr_Occurred() ? NULL : error(c);
|
|
153
|
+
JSValue global = JS_GetGlobalObject(c->ctx);
|
|
154
|
+
int status = JS_SetPropertyStr(c->ctx, global, name, v);
|
|
155
|
+
JS_FreeValue(c->ctx, global);
|
|
156
|
+
if (status < 0) return error(c);
|
|
157
|
+
Py_RETURN_NONE;
|
|
158
|
+
}
|
|
159
|
+
static PyObject *context_job(Context *c, PyObject *unused) {
|
|
160
|
+
if (enter(c)) return NULL;
|
|
161
|
+
JSContext *ctx;
|
|
162
|
+
int status = JS_ExecutePendingJob(c->rt, &ctx);
|
|
163
|
+
if (status < 0) return error(c);
|
|
164
|
+
return PyBool_FromLong(status);
|
|
165
|
+
}
|
|
166
|
+
static void object_dealloc(Object *o) {
|
|
167
|
+
JS_FreeValue(o->owner->ctx, o->value);
|
|
168
|
+
Py_DECREF(o->owner); PyObject_Del(o);
|
|
169
|
+
}
|
|
170
|
+
static PyObject *object_json(Object *o, PyObject *unused) {
|
|
171
|
+
if (enter(o->owner)) return NULL;
|
|
172
|
+
return convert(o->owner, JS_JSONStringify(o->owner->ctx, o->value, JS_UNDEFINED, JS_UNDEFINED));
|
|
173
|
+
}
|
|
174
|
+
static PyObject *object_call(Object *o, PyObject *args, PyObject *kw) {
|
|
175
|
+
Context *c = o->owner;
|
|
176
|
+
if (kw && PyDict_Size(kw)) { PyErr_SetString(PyExc_TypeError, "JavaScript calls accept positional arguments only"); return NULL; }
|
|
177
|
+
if (enter(c)) return NULL;
|
|
178
|
+
if (!JS_IsFunction(c->ctx, o->value)) { PyErr_SetString(PyExc_TypeError, "JavaScript object is not callable"); return NULL; }
|
|
179
|
+
Py_ssize_t n = PyTuple_GET_SIZE(args);
|
|
180
|
+
JSValue *argv = PyMem_Calloc(n ? n : 1, sizeof(JSValue));
|
|
181
|
+
if (!argv) return PyErr_NoMemory();
|
|
182
|
+
Py_ssize_t i;
|
|
183
|
+
for (i = 0; i < n; i++) {
|
|
184
|
+
argv[i] = from_python(c, PyTuple_GET_ITEM(args, i));
|
|
185
|
+
if (JS_IsException(argv[i])) break;
|
|
186
|
+
}
|
|
187
|
+
PyObject *result = NULL;
|
|
188
|
+
if (i == n) result = convert(c, JS_Call(c->ctx, o->value, JS_UNDEFINED, (int)n, argv));
|
|
189
|
+
else if (!PyErr_Occurred()) error(c);
|
|
190
|
+
for (Py_ssize_t j = 0; j < i; j++) JS_FreeValue(c->ctx, argv[j]);
|
|
191
|
+
PyMem_Free(argv);
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
static PyMethodDef context_methods[] = {
|
|
195
|
+
{"eval", (PyCFunction)(void(*)(void))context_eval, METH_VARARGS | METH_KEYWORDS, "Evaluate a JavaScript script."},
|
|
196
|
+
{"parse_json", (PyCFunction)context_parse, METH_O, "Parse JSON into this context."},
|
|
197
|
+
{"get", (PyCFunction)context_get, METH_O, "Read a global property."},
|
|
198
|
+
{"set", (PyCFunction)context_set, METH_VARARGS, "Set a global property."},
|
|
199
|
+
{"execute_pending_job", (PyCFunction)context_job, METH_NOARGS, "Execute one queued promise job."},
|
|
200
|
+
{NULL}
|
|
201
|
+
};
|
|
202
|
+
static PyMethodDef object_methods[] = {
|
|
203
|
+
{"json", (PyCFunction)object_json, METH_NOARGS, "Serialize to JSON (or None for undefined)."}, {NULL}
|
|
204
|
+
};
|
|
205
|
+
static PyTypeObject ContextType = {
|
|
206
|
+
PyVarObject_HEAD_INIT(NULL, 0)
|
|
207
|
+
.tp_name = "fastjs.Context", .tp_basicsize = sizeof(Context), .tp_flags = Py_TPFLAGS_DEFAULT,
|
|
208
|
+
.tp_new = context_new, .tp_dealloc = (destructor)context_dealloc, .tp_methods = context_methods,
|
|
209
|
+
};
|
|
210
|
+
static PyTypeObject ObjectType = {
|
|
211
|
+
PyVarObject_HEAD_INIT(NULL, 0)
|
|
212
|
+
.tp_name = "fastjs.Object", .tp_basicsize = sizeof(Object), .tp_flags = Py_TPFLAGS_DEFAULT,
|
|
213
|
+
.tp_dealloc = (destructor)object_dealloc, .tp_methods = object_methods, .tp_call = (ternaryfunc)object_call,
|
|
214
|
+
};
|
|
215
|
+
static PyModuleDef module = {PyModuleDef_HEAD_INIT, .m_name = "_native", .m_size = -1};
|
|
216
|
+
PyMODINIT_FUNC PyInit__native(void) {
|
|
217
|
+
if (PyType_Ready(&ContextType) < 0 || PyType_Ready(&ObjectType) < 0) return NULL;
|
|
218
|
+
PyObject *m = PyModule_Create(&module);
|
|
219
|
+
if (!m) return NULL;
|
|
220
|
+
JSException = PyErr_NewException("fastjs.JSException", NULL, NULL);
|
|
221
|
+
if (!JSException || PyModule_AddObjectRef(m, "JSException", JSException) < 0 ||
|
|
222
|
+
PyModule_AddObjectRef(m, "Context", (PyObject *)&ContextType) < 0 ||
|
|
223
|
+
PyModule_AddObjectRef(m, "Object", (PyObject *)&ObjectType) < 0) { Py_DECREF(m); return NULL; }
|
|
224
|
+
return m;
|
|
225
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastjs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python bindings for Bellard's QuickJS engine
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
License-File: licenses/QuickJS.txt
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# fastjs
|
|
15
|
+
|
|
16
|
+
Python bindings for [Bellard's QuickJS](https://github.com/bellard/quickjs).
|
|
17
|
+
Source builds download QuickJS revision
|
|
18
|
+
`e585640ffa2cf88ce7c2d587583c6fe15928ec2f` (version 2026-06-04), verify its
|
|
19
|
+
SHA-256 checksum, and compile it into the CPython extension. This requires a
|
|
20
|
+
compiler supporting GNU C11 and Python development headers. Linux, macOS, and
|
|
21
|
+
Windows x86_64 builds are configured; Windows uses MinGW-w64 UCRT64 GCC rather
|
|
22
|
+
than MSVC. The release workflow tests wheels on each platform.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
python -m pip install .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import fastjs
|
|
30
|
+
|
|
31
|
+
assert fastjs.eval('1 + 2') == 3
|
|
32
|
+
ctx = fastjs.Context(memory_limit=64 * 1024 * 1024, time_limit=0.5)
|
|
33
|
+
ctx.set('name', 'Python')
|
|
34
|
+
assert ctx.eval('`Hello, ${name}!`') == 'Hello, Python!'
|
|
35
|
+
add = ctx.eval('(a, b) => a + b')
|
|
36
|
+
assert add(20, 22) == 42
|
|
37
|
+
|
|
38
|
+
obj = ctx.parse_json('{"items": [1, 2, 3]}')
|
|
39
|
+
ctx.set('data', obj)
|
|
40
|
+
assert ctx.eval('data.items.length') == 3
|
|
41
|
+
assert obj.json() == '{"items":[1,2,3]}'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`Context.eval(source, filename='<eval>')` runs a global script. JavaScript
|
|
45
|
+
exceptions become `fastjs.JSException`. Global lexical declarations (`let`,
|
|
46
|
+
`const`) are available to subsequent scripts, but `get(name)` reads properties
|
|
47
|
+
of the global object, such as those created by `var`.
|
|
48
|
+
|
|
49
|
+
JavaScript null and undefined both become Python `None`; booleans, numbers,
|
|
50
|
+
strings and BigInts become Python scalars. Other values become `Object` handles.
|
|
51
|
+
Functions support positional calls with undefined as their `this` value.
|
|
52
|
+
`Object.json()` returns a JSON string, or `None` when JSON.stringify returns
|
|
53
|
+
undefined. Symbols remain opaque handles. Python arguments and globals support
|
|
54
|
+
`None`, bool, float, str, safe integers and objects from the same context.
|
|
55
|
+
Python integers outside ±(2**53 - 1) raise OverflowError; create BigInts through
|
|
56
|
+
JavaScript expressions. Use `parse_json(json.dumps(value))` for Python lists and
|
|
57
|
+
dicts. Python callbacks are not implemented yet.
|
|
58
|
+
|
|
59
|
+
`execute_pending_job()` runs one queued promise job and returns whether one ran:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
ctx.eval('var answer; Promise.resolve(42).then(v => { answer = v; })')
|
|
63
|
+
while ctx.execute_pending_job():
|
|
64
|
+
pass
|
|
65
|
+
assert ctx.get('answer') == 42
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Contexts and their objects must be used on the thread that created the context.
|
|
69
|
+
Objects keep their context alive. The extension holds the GIL during execution.
|
|
70
|
+
`time_limit` is seconds per operation (zero disables it); `memory_limit` is bytes
|
|
71
|
+
(zero disables it); `stack_limit` defaults to 1 MiB and must be positive.
|
|
72
|
+
The interrupt handler also checks Python signals. Limits help bound execution;
|
|
73
|
+
this package is not a security boundary for hostile code. No filesystem,
|
|
74
|
+
network, module loader, timers, console or QuickJS libc helpers are installed.
|
|
75
|
+
Optional Intl/Temporal support is not built.
|
|
76
|
+
|
|
77
|
+
Development:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
uv venv --python 3.14
|
|
81
|
+
uv pip install -e '.[test]'
|
|
82
|
+
.venv/bin/python -m pytest
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Downloaded archives are cached under `$XDG_CACHE_HOME/fastjs` (default:
|
|
86
|
+
`~/.cache/fastjs`). Set `FASTJS_CACHE_DIR` to override that directory. The checksum
|
|
87
|
+
is checked on every build, including cache hits. Source builds need network
|
|
88
|
+
access on the first build; subsequent builds can reuse the cached archive
|
|
89
|
+
offline. Generating package metadata and source distributions does not download
|
|
90
|
+
QuickJS. Installing a prebuilt wheel needs neither QuickJS sources nor a compiler.
|
|
91
|
+
|
|
92
|
+
QuickJS retains its upstream MIT license in `licenses/QuickJS.txt`, which is also
|
|
93
|
+
included in distribution metadata.
|
|
94
|
+
|
|
95
|
+
The `.github/workflows/test.yml` workflow builds and installs the extension and
|
|
96
|
+
runs the tests on branch pushes and pull requests. It covers CPython 3.10–3.15
|
|
97
|
+
on Linux (x86_64 and aarch64), macOS (Intel and Apple Silicon), and Windows
|
|
98
|
+
(x86_64). It does not publish packages.
|
|
99
|
+
|
|
100
|
+
Releases are published to PyPI by `.github/workflows/release.yml` when a version
|
|
101
|
+
tag is pushed. Set `project.version` in `pyproject.toml` to the release version,
|
|
102
|
+
commit the changes, then push the commit and matching tag:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
git tag 0.1.0
|
|
106
|
+
git push origin HEAD
|
|
107
|
+
git push origin 0.1.0
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The tag must exactly match the package version (no `v` prefix). The workflow
|
|
111
|
+
builds a source distribution and tests CPython 3.10–3.15 wheels on Linux
|
|
112
|
+
(manylinux, x86_64 and aarch64), macOS (Intel and Apple Silicon), and Windows
|
|
113
|
+
(x86_64) before publishing. Windows ARM64, 32-bit, and free-threaded Python
|
|
114
|
+
wheels are not built.
|
|
115
|
+
PyPI trusted publishing must name the GitHub repository and `release.yml`, with
|
|
116
|
+
no environment specified. Publishing uses OIDC and needs no PyPI API token.
|
|
117
|
+
|
|
118
|
+
For a Windows source build, install MSYS2's `mingw-w64-ucrt-x86_64-gcc` package
|
|
119
|
+
and add the MSYS2 `ucrt64\bin` directory to PATH. Use standard Windows CPython,
|
|
120
|
+
then run `python -m pip wheel .`. The build selects setuptools' `mingw32`
|
|
121
|
+
compiler automatically. Published wheels need neither MSYS2 nor GCC installed;
|
|
122
|
+
the extension statically links compiler runtime support and winpthreads. The
|
|
123
|
+
compiler itself is used only during builds.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
_quickjs_build.py
|
|
5
|
+
pyproject.toml
|
|
6
|
+
setup.py
|
|
7
|
+
licenses/QuickJS.txt
|
|
8
|
+
src/fastjs/__init__.py
|
|
9
|
+
src/fastjs/__init__.pyi
|
|
10
|
+
src/fastjs/_native.c
|
|
11
|
+
src/fastjs/py.typed
|
|
12
|
+
src/fastjs.egg-info/PKG-INFO
|
|
13
|
+
src/fastjs.egg-info/SOURCES.txt
|
|
14
|
+
src/fastjs.egg-info/dependency_links.txt
|
|
15
|
+
src/fastjs.egg-info/requires.txt
|
|
16
|
+
src/fastjs.egg-info/top_level.txt
|
|
17
|
+
tests/test_fastjs.py
|
|
18
|
+
tests/test_quickjs_build.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fastjs
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import gc
|
|
2
|
+
import json
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import fastjs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize(('source', 'expected'), [
|
|
11
|
+
('1 + 2', 3), ('1.5', 1.5), ('true', True), ('null', None),
|
|
12
|
+
('undefined', None), ('"héllo\\u0000世界"', 'héllo\0世界'),
|
|
13
|
+
('123456789012345678901234567890n', 123456789012345678901234567890),
|
|
14
|
+
])
|
|
15
|
+
def test_scalars(source, expected):
|
|
16
|
+
assert fastjs.eval(source) == expected
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_context_and_calls():
|
|
20
|
+
ctx = fastjs.Context()
|
|
21
|
+
ctx.set('name', 'world')
|
|
22
|
+
assert ctx.eval('`hello ${name}`') == 'hello world'
|
|
23
|
+
fn = ctx.eval('(a, b) => a + b')
|
|
24
|
+
assert fn(20, 22) == 42
|
|
25
|
+
ctx.eval('var counter = 0')
|
|
26
|
+
ctx.eval('counter++')
|
|
27
|
+
assert ctx.get('counter') == 1
|
|
28
|
+
with pytest.raises(TypeError):
|
|
29
|
+
fn(a=1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_objects_and_lifetime():
|
|
33
|
+
ctx = fastjs.Context()
|
|
34
|
+
obj = ctx.parse_json('{"items": [1, 2, 3]}')
|
|
35
|
+
ctx.set('data', obj)
|
|
36
|
+
assert ctx.eval('data.items[1]') == 2
|
|
37
|
+
assert json.loads(obj.json()) == {'items': [1, 2, 3]}
|
|
38
|
+
fn = ctx.eval('(obj) => obj.items.length')
|
|
39
|
+
del ctx
|
|
40
|
+
gc.collect()
|
|
41
|
+
assert fn(obj) == 3
|
|
42
|
+
assert fastjs.eval('() => 42')() == 42
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_errors_and_recovery():
|
|
46
|
+
ctx = fastjs.Context()
|
|
47
|
+
for script in ('throw new Error("boom")', 'const =', 'JSON.parse("!")'):
|
|
48
|
+
with pytest.raises(fastjs.JSException):
|
|
49
|
+
ctx.eval(script)
|
|
50
|
+
with pytest.raises(fastjs.JSException):
|
|
51
|
+
ctx.parse_json('{')
|
|
52
|
+
assert ctx.eval('42') == 42
|
|
53
|
+
with pytest.raises(fastjs.JSException, match='circular'):
|
|
54
|
+
ctx.eval('var x = {}; x.x = x; x').json()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_invalid_arguments():
|
|
58
|
+
ctx = fastjs.Context()
|
|
59
|
+
for value in ([], {}, object()):
|
|
60
|
+
with pytest.raises(TypeError):
|
|
61
|
+
ctx.set('x', value)
|
|
62
|
+
with pytest.raises(OverflowError):
|
|
63
|
+
ctx.set('x', 2**60)
|
|
64
|
+
with pytest.raises(ValueError):
|
|
65
|
+
ctx.set('x', fastjs.Context().eval('({})'))
|
|
66
|
+
with pytest.raises(TypeError):
|
|
67
|
+
ctx.eval('({})')()
|
|
68
|
+
with pytest.raises(ValueError):
|
|
69
|
+
ctx.set('a\0b', 1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_promise_jobs():
|
|
73
|
+
ctx = fastjs.Context()
|
|
74
|
+
ctx.eval('var answer; Promise.resolve(42).then(v => {answer = v})')
|
|
75
|
+
assert ctx.execute_pending_job() is True
|
|
76
|
+
assert ctx.execute_pending_job() is False
|
|
77
|
+
assert ctx.get('answer') == 42
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_time_limit_and_recovery():
|
|
81
|
+
ctx = fastjs.Context(time_limit=0.01)
|
|
82
|
+
with pytest.raises(fastjs.JSException, match='interrupted'):
|
|
83
|
+
ctx.eval('while (true) {}')
|
|
84
|
+
assert ctx.eval('42') == 42
|
|
85
|
+
with pytest.raises(fastjs.JSException, match='interrupted'):
|
|
86
|
+
ctx.eval('() => {while (true) {}}')()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.parametrize('kwargs', [
|
|
90
|
+
{'memory_limit': -1}, {'stack_limit': 0}, {'time_limit': -1},
|
|
91
|
+
{'time_limit': float('nan')}, {'time_limit': float('inf')},
|
|
92
|
+
])
|
|
93
|
+
def test_invalid_limits(kwargs):
|
|
94
|
+
with pytest.raises(ValueError):
|
|
95
|
+
fastjs.Context(**kwargs)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_memory_limit():
|
|
99
|
+
ctx = fastjs.Context(memory_limit=2 * 1024 * 1024)
|
|
100
|
+
with pytest.raises(fastjs.JSException):
|
|
101
|
+
ctx.eval('var a = []; for (var i = 0; i < 1000000; i++) a.push({i})')
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_thread_affinity():
|
|
105
|
+
ctx = fastjs.Context()
|
|
106
|
+
errors = []
|
|
107
|
+
def run():
|
|
108
|
+
try:
|
|
109
|
+
ctx.eval('1')
|
|
110
|
+
except RuntimeError as exc:
|
|
111
|
+
errors.append(str(exc))
|
|
112
|
+
thread = threading.Thread(target=run)
|
|
113
|
+
thread.start()
|
|
114
|
+
thread.join()
|
|
115
|
+
assert errors and 'creating thread' in errors[0]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_global_properties_and_safe_integers():
|
|
119
|
+
ctx = fastjs.Context()
|
|
120
|
+
ctx.set('世界', 2**53 - 1)
|
|
121
|
+
assert ctx.get('世界') == 2**53 - 1
|
|
122
|
+
ctx.set('negative', -(2**53 - 1))
|
|
123
|
+
assert ctx.get('negative') == -(2**53 - 1)
|
|
124
|
+
ctx.eval('Object.defineProperty(globalThis, "fixed", {value: 1})')
|
|
125
|
+
with pytest.raises(fastjs.JSException):
|
|
126
|
+
ctx.set('fixed', 2)
|
|
127
|
+
with pytest.raises(ValueError):
|
|
128
|
+
ctx.get('a\0b')
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_call_conversion_failure_recovers():
|
|
132
|
+
ctx = fastjs.Context()
|
|
133
|
+
fn = ctx.eval('(...args) => args.length')
|
|
134
|
+
with pytest.raises(TypeError):
|
|
135
|
+
fn('allocated first', {})
|
|
136
|
+
with pytest.raises(ValueError):
|
|
137
|
+
fn(fastjs.Context().eval('({})'))
|
|
138
|
+
assert fn(1, 'two', None) == 3
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_shared_memory_atomics():
|
|
142
|
+
ctx = fastjs.Context()
|
|
143
|
+
result = ctx.eval('''
|
|
144
|
+
var shared = new Int32Array(new SharedArrayBuffer(4));
|
|
145
|
+
Atomics.store(shared, 0, 7);
|
|
146
|
+
var previous = Atomics.add(shared, 0, 2);
|
|
147
|
+
[previous, Atomics.load(shared, 0), Atomics.notify(shared, 0)];
|
|
148
|
+
''')
|
|
149
|
+
assert json.loads(result.json()) == [7, 9, 0]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Build input integrity and offline cache behavior, without network access."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import importlib.util
|
|
4
|
+
import io
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import tarfile
|
|
7
|
+
from urllib.error import URLError
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def build(monkeypatch, tmp_path):
|
|
14
|
+
spec = importlib.util.spec_from_file_location(
|
|
15
|
+
"_quickjs_build", Path(__file__).resolve().parents[1] / "_quickjs_build.py"
|
|
16
|
+
)
|
|
17
|
+
module = importlib.util.module_from_spec(spec)
|
|
18
|
+
spec.loader.exec_module(module)
|
|
19
|
+
monkeypatch.setenv("FASTJS_CACHE_DIR", str(tmp_path / "cache"))
|
|
20
|
+
return module
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def forbid_network(*args, **kwargs):
|
|
24
|
+
raise AssertionError("Unexpected download")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_verified_download_and_offline_cache(build, monkeypatch):
|
|
28
|
+
content = b"verified archive"
|
|
29
|
+
monkeypatch.setattr(build, "SHA256", hashlib.sha256(content).hexdigest())
|
|
30
|
+
monkeypatch.setattr(build, "urlopen", lambda *a, **kw: io.BytesIO(content))
|
|
31
|
+
assert build.fetch_archive() == content
|
|
32
|
+
monkeypatch.setattr(build, "urlopen", forbid_network)
|
|
33
|
+
assert build.fetch_archive() == content
|
|
34
|
+
assert list(build.cache_dir().iterdir()) == [build.cache_dir() / build.ARCHIVE_NAME]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_corrupt_cache_is_rejected(build, monkeypatch):
|
|
38
|
+
build.cache_dir().mkdir()
|
|
39
|
+
(build.cache_dir() / build.ARCHIVE_NAME).write_bytes(b"corrupt")
|
|
40
|
+
monkeypatch.setattr(build, "urlopen", forbid_network)
|
|
41
|
+
with pytest.raises(RuntimeError, match="SHA-256 mismatch"):
|
|
42
|
+
build.fetch_archive()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_corrupt_download_is_not_cached(build, monkeypatch):
|
|
46
|
+
monkeypatch.setattr(build, "urlopen", lambda *a, **kw: io.BytesIO(b"corrupt"))
|
|
47
|
+
with pytest.raises(RuntimeError, match="SHA-256 mismatch"):
|
|
48
|
+
build.fetch_archive()
|
|
49
|
+
assert not (build.cache_dir() / build.ARCHIVE_NAME).exists()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_download_failure_explains_cache(build, monkeypatch):
|
|
53
|
+
def unavailable(*args, **kwargs):
|
|
54
|
+
raise URLError("offline")
|
|
55
|
+
monkeypatch.setattr(build, "urlopen", unavailable)
|
|
56
|
+
with pytest.raises(RuntimeError, match="FASTJS_CACHE_DIR"):
|
|
57
|
+
build.fetch_archive()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def archive_bytes(build, *, symlink=False):
|
|
61
|
+
output = io.BytesIO()
|
|
62
|
+
with tarfile.open(fileobj=output, mode="w:gz") as archive:
|
|
63
|
+
for name in (*build.FILES, "../escape"):
|
|
64
|
+
entry = tarfile.TarInfo(f"quickjs-{build.REVISION}/{name}")
|
|
65
|
+
data = (build.VERSION if name == "VERSION" else name).encode()
|
|
66
|
+
if symlink and name == "quickjs.c":
|
|
67
|
+
entry.type = tarfile.SYMTYPE
|
|
68
|
+
entry.linkname = "../../outside.c"
|
|
69
|
+
archive.addfile(entry)
|
|
70
|
+
else:
|
|
71
|
+
entry.size = len(data)
|
|
72
|
+
archive.addfile(entry, io.BytesIO(data))
|
|
73
|
+
return output.getvalue()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_extracts_only_required_files(build, monkeypatch, tmp_path):
|
|
77
|
+
monkeypatch.setattr(build, "fetch_archive", lambda: archive_bytes(build))
|
|
78
|
+
destination = tmp_path / "sources"
|
|
79
|
+
assert build.prepare_sources(destination) == destination
|
|
80
|
+
assert {p.name for p in destination.iterdir()} == set(build.FILES)
|
|
81
|
+
assert not (tmp_path / "escape").exists()
|
|
82
|
+
# Reusing identical sources preserves timestamps for incremental builds.
|
|
83
|
+
before = (destination / "quickjs.c").stat().st_mtime_ns
|
|
84
|
+
build.prepare_sources(destination)
|
|
85
|
+
assert (destination / "quickjs.c").stat().st_mtime_ns == before
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_archive_links_are_rejected(build, monkeypatch, tmp_path):
|
|
89
|
+
monkeypatch.setattr(build, "fetch_archive", lambda: archive_bytes(build, symlink=True))
|
|
90
|
+
with pytest.raises(RuntimeError, match="not a regular file"):
|
|
91
|
+
build.prepare_sources(tmp_path / "sources")
|