boabem 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,9 +31,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
31
31
 
32
32
  [[package]]
33
33
  name = "bitflags"
34
- version = "2.9.2"
34
+ version = "2.9.3"
35
35
  source = "registry+https://github.com/rust-lang/crates.io-index"
36
- checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29"
36
+ checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d"
37
37
 
38
38
  [[package]]
39
39
  name = "boa_ast"
@@ -244,7 +244,7 @@ dependencies = [
244
244
 
245
245
  [[package]]
246
246
  name = "boabem"
247
- version = "0.1.0"
247
+ version = "0.2.0"
248
248
  dependencies = [
249
249
  "boa_engine",
250
250
  "boa_runtime",
@@ -976,9 +976,9 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
976
976
 
977
977
  [[package]]
978
978
  name = "indexmap"
979
- version = "2.10.0"
979
+ version = "2.11.0"
980
980
  source = "registry+https://github.com/rust-lang/crates.io-index"
981
- checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
981
+ checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9"
982
982
  dependencies = [
983
983
  "equivalent",
984
984
  "hashbrown 0.15.5",
@@ -1729,13 +1729,14 @@ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
1729
1729
 
1730
1730
  [[package]]
1731
1731
  name = "url"
1732
- version = "2.5.6"
1732
+ version = "2.5.7"
1733
1733
  source = "registry+https://github.com/rust-lang/crates.io-index"
1734
- checksum = "137a3c834eaf7139b73688502f3f1141a0337c5d8e4d9b536f9b8c796e26a7c4"
1734
+ checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
1735
1735
  dependencies = [
1736
1736
  "form_urlencoded",
1737
1737
  "idna",
1738
1738
  "percent-encoding",
1739
+ "serde",
1739
1740
  ]
1740
1741
 
1741
1742
  [[package]]
@@ -1949,9 +1950,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1949
1950
 
1950
1951
  [[package]]
1951
1952
  name = "winnow"
1952
- version = "0.7.12"
1953
+ version = "0.7.13"
1953
1954
  source = "registry+https://github.com/rust-lang/crates.io-index"
1954
- checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
1955
+ checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
1955
1956
  dependencies = [
1956
1957
  "memchr",
1957
1958
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "boabem"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Python bindings for the Rust crate 'boa', an embeddable JavaScript engine"
5
5
  edition = "2024"
6
6
  authors = ["Dowon <ks2515@naver.com>"]
@@ -1,10 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boabem
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: JavaScript
6
6
  Requires-Dist: hypothesis[pytest] ; extra == 'test'
7
7
  Requires-Dist: pytest-pretty ; extra == 'test'
8
+ Requires-Dist: json-five ; extra == 'test'
9
+ Requires-Dist: deepdiff ; extra == 'test'
8
10
  Provides-Extra: test
9
11
  License-File: LICENSE-APACHE
10
12
  License-File: LICENSE-MIT
@@ -60,6 +62,8 @@ result = ctx.eval_from_filepath(Path("script.js"))
60
62
  - eval(source: str) -> Any
61
63
  - eval_from_bytes(source: str) -> Any (same behavior as eval)
62
64
  - eval_from_filepath(path: str | os.PathLike[str]) -> Any
65
+ - boabem.PanicException
66
+ - Exception class exposed for Rust panics (e.g., attempting to use a Context across threads).
63
67
  - boabem.Undefined
64
68
  - Sentinel type representing JavaScript `undefined`.
65
69
  - String representation: "Undefined".
@@ -82,6 +86,22 @@ Notes:
82
86
  - Some JS values (e.g., Symbol) cannot be converted and will raise an error.
83
87
  - Each `undefined` you get back is a distinct Python object, but compares equal to another `Undefined`.
84
88
 
89
+ ### Object/Array conversion details
90
+
91
+ When converting composite values (JavaScript Objects and Arrays) to Python `dict`/`list`, elements are converted recursively with a few caveats:
92
+
93
+ - BigInt inside Objects/Arrays is converted to Python `int`.
94
+ - Examples: `({ a: 1n, 1: 2n, 2n: 3n }) -> {"a": 1, "1": 2, "2": 3}` and `[1, 2, 3n] -> [1, 2, 3]`.
95
+ - `NaN` and `±Infinity` inside Objects/Arrays are preserved as Python floats (`float('nan')` / `float('inf')`).
96
+ - Examples: `({ a: NaN, b: Infinity }) -> {"a": nan, "b": inf}` and `[1, 2, NaN, Infinity] -> [1, 2, nan, inf]`.
97
+ - `undefined` inside Objects/Arrays is converted to `boabem.Undefined`.
98
+
99
+ Additional notes:
100
+
101
+ - JavaScript object property keys are coerced to strings during conversion; for example, a `2n` property name becomes the Python key `"2"`.
102
+
103
+ Note: Top-level primitives are still mapped as documented above (e.g., `10n` -> `int`, `NaN`/`Infinity` -> `float('nan')`/`float('inf')`). The special rules here apply only to values nested within Objects/Arrays.
104
+
85
105
  ## Threading and processes
86
106
 
87
107
  Context is not thread-sendable or picklable:
@@ -90,6 +110,8 @@ Context is not thread-sendable or picklable:
90
110
  - Do not send a Context to another process (cannot pickle).
91
111
  - Create and use a Context only in the thread where it was created.
92
112
 
113
+ If you try to use a Context across threads, you'll get a Rust panic surfaced as `pyo3_runtime.PanicException` (exposed as `boabem.PanicException`).
114
+
93
115
  ## Errors
94
116
 
95
117
  - JavaScript exceptions (e.g., `throw new Error('boom')`) raise RuntimeError with the JS message.
@@ -42,6 +42,8 @@ result = ctx.eval_from_filepath(Path("script.js"))
42
42
  - eval(source: str) -> Any
43
43
  - eval_from_bytes(source: str) -> Any (same behavior as eval)
44
44
  - eval_from_filepath(path: str | os.PathLike[str]) -> Any
45
+ - boabem.PanicException
46
+ - Exception class exposed for Rust panics (e.g., attempting to use a Context across threads).
45
47
  - boabem.Undefined
46
48
  - Sentinel type representing JavaScript `undefined`.
47
49
  - String representation: "Undefined".
@@ -64,6 +66,22 @@ Notes:
64
66
  - Some JS values (e.g., Symbol) cannot be converted and will raise an error.
65
67
  - Each `undefined` you get back is a distinct Python object, but compares equal to another `Undefined`.
66
68
 
69
+ ### Object/Array conversion details
70
+
71
+ When converting composite values (JavaScript Objects and Arrays) to Python `dict`/`list`, elements are converted recursively with a few caveats:
72
+
73
+ - BigInt inside Objects/Arrays is converted to Python `int`.
74
+ - Examples: `({ a: 1n, 1: 2n, 2n: 3n }) -> {"a": 1, "1": 2, "2": 3}` and `[1, 2, 3n] -> [1, 2, 3]`.
75
+ - `NaN` and `±Infinity` inside Objects/Arrays are preserved as Python floats (`float('nan')` / `float('inf')`).
76
+ - Examples: `({ a: NaN, b: Infinity }) -> {"a": nan, "b": inf}` and `[1, 2, NaN, Infinity] -> [1, 2, nan, inf]`.
77
+ - `undefined` inside Objects/Arrays is converted to `boabem.Undefined`.
78
+
79
+ Additional notes:
80
+
81
+ - JavaScript object property keys are coerced to strings during conversion; for example, a `2n` property name becomes the Python key `"2"`.
82
+
83
+ Note: Top-level primitives are still mapped as documented above (e.g., `10n` -> `int`, `NaN`/`Infinity` -> `float('nan')`/`float('inf')`). The special rules here apply only to values nested within Objects/Arrays.
84
+
67
85
  ## Threading and processes
68
86
 
69
87
  Context is not thread-sendable or picklable:
@@ -72,6 +90,8 @@ Context is not thread-sendable or picklable:
72
90
  - Do not send a Context to another process (cannot pickle).
73
91
  - Create and use a Context only in the thread where it was created.
74
92
 
93
+ If you try to use a Context across threads, you'll get a Rust panic surfaced as `pyo3_runtime.PanicException` (exposed as `boabem.PanicException`).
94
+
75
95
  ## Errors
76
96
 
77
97
  - JavaScript exceptions (e.g., `throw new Error('boom')`) raise RuntimeError with the JS message.
@@ -13,7 +13,7 @@ classifiers = [
13
13
  dynamic = ["version"]
14
14
 
15
15
  [project.optional-dependencies]
16
- test = ["hypothesis[pytest]", "pytest-pretty"]
16
+ test = ["hypothesis[pytest]", "pytest-pretty", "json-five", "deepdiff"]
17
17
 
18
18
  [project.urls]
19
19
  source = "https://github.com/Bing-su/boabem"
@@ -0,0 +1,3 @@
1
+ from .boabem import Context, PanicException, Undefined, __version__
2
+
3
+ __all__ = ["Context", "PanicException", "Undefined", "__version__"]
@@ -4,9 +4,9 @@ from typing import Any
4
4
  __version__: str
5
5
 
6
6
  class Context:
7
- def __init__(self): ...
8
7
  def eval(self, source: str) -> Any: ...
9
8
  def eval_from_bytes(self, source: str) -> Any: ...
10
9
  def eval_from_filepath(self, source: str | os.PathLike[str]) -> Any: ...
11
10
 
12
11
  class Undefined: ...
12
+ class PanicException(BaseException): ...
@@ -1,7 +1,11 @@
1
+ use boa_engine::value::TryFromJs;
1
2
  use boa_engine::{Context, JsValue, Source};
2
3
  use eyre::{Result, eyre};
4
+ use pyo3::IntoPyObjectExt;
3
5
  use pyo3::prelude::*;
6
+ use pyo3::types::{PyDict, PyList};
4
7
  use pythonize::pythonize;
8
+ use std::collections::HashMap;
5
9
  use std::path::PathBuf;
6
10
 
7
11
  #[pyclass(name = "Undefined", module = "boabem.boabem", str, eq, frozen)]
@@ -11,8 +15,8 @@ pub struct PyUndefined {}
11
15
  #[pymethods]
12
16
  impl PyUndefined {
13
17
  #[new]
14
- fn new() -> Self {
15
- PyUndefined {}
18
+ fn py_new() -> Self {
19
+ Self::new()
16
20
  }
17
21
 
18
22
  fn __repr__(&self) -> &str {
@@ -20,6 +24,12 @@ impl PyUndefined {
20
24
  }
21
25
  }
22
26
 
27
+ impl PyUndefined {
28
+ fn new() -> Self {
29
+ Self {}
30
+ }
31
+ }
32
+
23
33
  impl std::fmt::Display for PyUndefined {
24
34
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25
35
  write!(f, "undefined")
@@ -65,7 +75,7 @@ impl PyContext {
65
75
  }
66
76
  }
67
77
 
68
- fn pybigint(value: &str) -> Result<PyObject> {
78
+ fn to_pybigint(value: &str) -> Result<PyObject> {
69
79
  Python::with_gil(|py| {
70
80
  let builtins = PyModule::import(py, "builtins")?;
71
81
  let int_class = builtins.getattr("int")?;
@@ -74,24 +84,21 @@ fn pybigint(value: &str) -> Result<PyObject> {
74
84
  })
75
85
  }
76
86
 
77
- fn pyfloat(value: f64) -> Result<PyObject> {
78
- Python::with_gil(|py| {
79
- let pyfloat = value.into_pyobject(py)?;
80
- Ok(pyfloat.into())
81
- })
87
+ fn to_pyobject<'a, T: IntoPyObjectExt<'a>>(py: Python<'a>, value: T) -> Result<PyObject> {
88
+ Ok(value.into_py_any(py)?)
82
89
  }
83
90
 
84
91
  impl PyContext {
85
92
  fn jsvalue_to_pyobject(&mut self, value: JsValue) -> Result<PyObject> {
86
93
  match value {
87
- JsValue::Undefined => {
88
- Python::with_gil(|py| Ok(Py::new(py, PyUndefined::new())?.into_any()))
89
- }
94
+ JsValue::Undefined => Python::with_gil(|py| to_pyobject(py, PyUndefined::new())),
90
95
  JsValue::BigInt(js_bigint) => {
91
96
  let bigint_str = js_bigint.to_string_radix(10);
92
- pybigint(&bigint_str)
97
+ to_pybigint(&bigint_str)
93
98
  }
94
- JsValue::Rational(f) => pyfloat(f),
99
+ JsValue::Rational(v) => Python::with_gil(|py| to_pyobject(py, v)),
100
+ JsValue::Object(obj) if obj.is_array() => self.jsobj_to_py_list(&JsValue::Object(obj)),
101
+ JsValue::Object(obj) => self.jsobj_to_py_dict(&JsValue::Object(obj)),
95
102
  other => {
96
103
  let json = other
97
104
  .to_json(&mut self.context)
@@ -104,4 +111,32 @@ impl PyContext {
104
111
  }
105
112
  }
106
113
  }
114
+
115
+ fn jsobj_to_py_list(&mut self, obj: &JsValue) -> Result<PyObject> {
116
+ let arr: Vec<JsValue> =
117
+ Vec::try_from_js(obj, &mut self.context).map_err(|e| eyre!(e.to_string()))?;
118
+
119
+ Python::with_gil(|py| {
120
+ let py_list = PyList::empty(py);
121
+ for item in arr {
122
+ let py_item = self.jsvalue_to_pyobject(item)?;
123
+ py_list.append(py_item)?;
124
+ }
125
+ Ok(py_list.into())
126
+ })
127
+ }
128
+
129
+ fn jsobj_to_py_dict(&mut self, obj: &JsValue) -> Result<PyObject> {
130
+ let map: HashMap<String, JsValue> =
131
+ HashMap::try_from_js(obj, &mut self.context).map_err(|e| eyre!(e.to_string()))?;
132
+
133
+ Python::with_gil(|py| {
134
+ let py_dict = PyDict::new(py);
135
+ for (key, value) in map {
136
+ let py_value = self.jsvalue_to_pyobject(value)?;
137
+ py_dict.set_item(key, py_value)?;
138
+ }
139
+ Ok(py_dict.into())
140
+ })
141
+ }
107
142
  }
@@ -1,10 +1,12 @@
1
+ use pyo3::panic::PanicException;
1
2
  use pyo3::prelude::*;
2
3
  mod hebi;
3
4
 
4
5
  #[pymodule]
5
- fn boabem(m: &Bound<'_, PyModule>) -> PyResult<()> {
6
+ fn boabem(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
6
7
  m.add("__version__", env!("CARGO_PKG_VERSION"))?;
7
8
  m.add_class::<hebi::PyUndefined>()?;
8
9
  m.add_class::<hebi::PyContext>()?;
10
+ m.add("PanicException", py.get_type::<PanicException>())?;
9
11
  Ok(())
10
12
  }
File without changes
@@ -0,0 +1,155 @@
1
+ import sys
2
+ from math import isnan
3
+ from typing import Any
4
+
5
+ import json5
6
+ import pytest
7
+ from deepdiff import DeepDiff
8
+ from hypothesis import given
9
+ from hypothesis import strategies as st
10
+
11
+ from boabem import Context, Undefined
12
+
13
+
14
+ def json(*, finite_only: bool = True):
15
+ """Helper function to describe JSON objects, with optional inf and nan."""
16
+ numbers = st.floats(allow_infinity=not finite_only, allow_nan=not finite_only)
17
+ return st.recursive(
18
+ st.none() | st.booleans() | st.integers() | numbers | st.text(),
19
+ extend=lambda xs: st.lists(xs) | st.dictionaries(st.text(), xs),
20
+ )
21
+
22
+
23
+ def json_array(*, finite_only: bool = True):
24
+ return st.lists(json(finite_only=finite_only))
25
+
26
+
27
+ def json_object(*, finite_only: bool = True):
28
+ return st.dictionaries(st.text(), json(finite_only=finite_only))
29
+
30
+
31
+ @given(value=st.integers(min_value=-(10**9), max_value=10**9))
32
+ def test_int(value: int):
33
+ ctx = Context()
34
+ code = str(value)
35
+ assert ctx.eval(code) == value
36
+
37
+
38
+ @given(value=st.floats(allow_infinity=True, allow_nan=True))
39
+ def test_float(value: float):
40
+ ctx = Context()
41
+ code = json5.dumps(value).replace("nan", "NaN")
42
+ result = ctx.eval(code)
43
+ assert value == result or (isnan(value) and isnan(result))
44
+
45
+
46
+ @given(value=st.integers(min_value=-(10**100), max_value=10**100))
47
+ def test_bigint(value: int):
48
+ ctx = Context()
49
+ code = f"{value}n"
50
+ assert ctx.eval(code) == value
51
+
52
+
53
+ @given(value=json_array(finite_only=False))
54
+ def test_json_array(value: list[Any]):
55
+ ctx = Context()
56
+ code = json5.dumps(value).replace("nan", "NaN")
57
+ result = ctx.eval(f"let arr = {code};\narr")
58
+ assert isinstance(result, list)
59
+
60
+ diff = DeepDiff(
61
+ value, result, ignore_nan_inequality=True, ignore_numeric_type_changes=True
62
+ )
63
+ assert len(diff.affected_paths) == 0
64
+
65
+
66
+ @given(value=json_object(finite_only=False))
67
+ def test_json_object(value: dict[str, Any]):
68
+ ctx = Context()
69
+ code = json5.dumps(value).replace("nan", "NaN")
70
+ result = ctx.eval(f"let obj = {code};\nobj")
71
+ assert isinstance(result, dict)
72
+
73
+ diff = DeepDiff(
74
+ value, result, ignore_nan_inequality=True, ignore_numeric_type_changes=True
75
+ )
76
+ assert len(diff.affected_paths) == 0
77
+
78
+
79
+ @given(value=json(finite_only=False))
80
+ def test_any_json(value: Any):
81
+ ctx = Context()
82
+ code = json5.dumps(value).replace("nan", "NaN")
83
+ result = ctx.eval(f"let obj = {code};\nobj")
84
+ diff = DeepDiff(
85
+ value, result, ignore_nan_inequality=True, ignore_numeric_type_changes=True
86
+ )
87
+ assert len(diff.affected_paths) == 0
88
+
89
+
90
+ def test_deep_object():
91
+ ctx = Context()
92
+ code = """
93
+ let obj = {a:{b:{c:{d:{e:{f:{g:{h:{i:{j:1}}}}}}}}}};
94
+ obj
95
+ """
96
+ result = ctx.eval(code)
97
+ diff = DeepDiff(result, json5.loads("{a:{b:{c:{d:{e:{f:{g:{h:{i:{j:1}}}}}}}}}}"))
98
+ assert len(diff.affected_paths) == 0
99
+
100
+
101
+ @pytest.mark.skipif(
102
+ sys.version_info < (3, 11),
103
+ reason="https://docs.python.org/3.13/library/stdtypes.html#integer-string-conversion-length-limitation",
104
+ )
105
+ def test_integer_string_conversion_length_limitation():
106
+ ctx = Context()
107
+ code = "10n ** 4300n"
108
+ with pytest.raises(ValueError, match="Exceeds the limit"):
109
+ ctx.eval(code)
110
+
111
+
112
+ def test_bigint_in_object():
113
+ ctx = Context()
114
+ assert ctx.eval("({ a: 1n, 1: 2n, 2n: 3n })") == {"a": 1, "1": 2, "2": 3}
115
+ assert ctx.eval("[1, 2, 3n]") == [1, 2, 3]
116
+
117
+
118
+ def test_nan_inf_in_object():
119
+ ctx = Context()
120
+ result = ctx.eval(
121
+ "({ a: NaN, b: Infinity, NaN: NaN, Infinity: Infinity, null: null })"
122
+ )
123
+ assert isinstance(result, dict)
124
+ assert result == pytest.approx(
125
+ {
126
+ "a": float("nan"),
127
+ "b": float("inf"),
128
+ "NaN": float("nan"),
129
+ "Infinity": float("inf"),
130
+ "null": None,
131
+ },
132
+ nan_ok=True,
133
+ )
134
+
135
+ result = ctx.eval("[1, 2, NaN, Infinity]")
136
+ assert isinstance(result, list)
137
+ assert result == pytest.approx([1, 2, float("nan"), float("inf")], nan_ok=True)
138
+
139
+
140
+ def test_undefined_in_object():
141
+ ctx = Context()
142
+ assert ctx.eval("({ a: undefined, undefined: undefined })") == {
143
+ "a": Undefined(),
144
+ "undefined": Undefined(),
145
+ }
146
+ assert ctx.eval("[1, 2, undefined]") == [1, 2, Undefined()]
147
+
148
+
149
+ def test_function():
150
+ ctx = Context()
151
+ code = """
152
+ let test_add = (a, b) => a + b;
153
+ test_add
154
+ """
155
+ assert ctx.eval(code) == {"length": 2, "name": "test_add"}
@@ -1,5 +1,4 @@
1
1
  import json
2
- import sys
3
2
  from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
4
3
  from math import isinf, isnan
5
4
  from pathlib import Path
@@ -7,7 +6,7 @@ from typing import Any
7
6
 
8
7
  import pytest
9
8
 
10
- from boabem import Context, Undefined
9
+ from boabem import Context, PanicException, Undefined
11
10
 
12
11
 
13
12
  def test_banana():
@@ -393,7 +392,7 @@ def test_thread_pool():
393
392
  future = executor.submit(ctx.eval, "1 + 1")
394
393
 
395
394
  # pyo3_runtime.PanicException
396
- with pytest.raises(BaseException, match="unsendable"):
395
+ with pytest.raises(PanicException, match="unsendable"):
397
396
  future.result()
398
397
 
399
398
 
@@ -404,14 +403,3 @@ def test_process_pool():
404
403
 
405
404
  with pytest.raises(TypeError, match="cannot pickle"):
406
405
  future.result()
407
-
408
-
409
- @pytest.mark.skipif(
410
- sys.version_info < (3, 11),
411
- reason="https://docs.python.org/3.13/library/stdtypes.html#integer-string-conversion-length-limitation",
412
- )
413
- def test_integer_string_conversion_length_limitation():
414
- ctx = Context()
415
- code = "10n ** 4300n"
416
- with pytest.raises(ValueError, match="Exceeds the limit"):
417
- ctx.eval(code)
@@ -1,3 +0,0 @@
1
- from .boabem import Context, Undefined, __version__
2
-
3
- __all__ = ["Context", "Undefined", "__version__"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes