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 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.
@@ -0,0 +1,2 @@
1
+ include _quickjs_build.py
2
+ recursive-include tests *.py
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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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,3 @@
1
+
2
+ [test]
3
+ pytest>=7
@@ -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")