From 33289c06d5dedb724b6eece722de34202a270ac2 Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:41:39 -0700 Subject: [PATCH 1/5] feat: establish JavaScript values and packaging --- .github/workflows/ci.yaml | 36 +++++++ pyproject.toml | 32 ++++++ src/JavaScript/__init__.py | 18 ++++ src/JavaScript/_values.py | 195 +++++++++++++++++++++++++++++++++++++ src/JavaScript/py.typed | 0 tests/test_values.py | 67 +++++++++++++ 6 files changed, 348 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 pyproject.toml create mode 100644 src/JavaScript/__init__.py create mode 100644 src/JavaScript/_values.py create mode 100644 src/JavaScript/py.typed create mode 100644 tests/test_values.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..1d9ddce --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + - name: Install build tools + run: python -m pip install --upgrade build twine + - name: Build sdist and wheel from sdist + run: python -m build + - name: Check distribution metadata + run: python -m twine check --strict dist/* + - name: Install wheel with published dependencies + run: python -m pip install --find-links dist Python-JavaScript==0.1.0 + - name: Test installed wheel + run: python -m unittest discover -s tests -v diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4bbf0ae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "Python-JavaScript" +version = "0.1.0" +description = "JavaScript-style objects, methods and functions for Python" +readme = "README.md" +authors = [ + { name = "ellainng", email = "299958882+ellainng@users.noreply.github.com" } +] +requires-python = ">=3.14" +dependencies = [ + "JavaScript-Fetch-API>=0.2.0,<0.3.0", + "JavaScript-JSON>=0.1.0,<0.2.0", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.14", +] +keywords = ["javascript", "ecmascript", "objects", "arrays"] + +[project.urls] +Homepage = "https://github.com/outside-labs/Python-JavaScript" +Repository = "https://github.com/outside-labs/Python-JavaScript" +Issues = "https://github.com/outside-labs/Python-JavaScript/issues" + +[build-system] +requires = ["uv_build>=0.12.19,<0.13.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "JavaScript" diff --git a/src/JavaScript/__init__.py b/src/JavaScript/__init__.py new file mode 100644 index 0000000..85244e0 --- /dev/null +++ b/src/JavaScript/__init__.py @@ -0,0 +1,18 @@ +"""JavaScript-style objects, methods and functions for Python.""" + +from ._values import ( + Infinity, + NaN, + isFinite, + isNaN, + null, + strictEqual, + truthy, + typeof, + undefined, +) + +__all__ = [ + "Infinity", "NaN", "isFinite", "isNaN", "null", "strictEqual", + "truthy", "typeof", "undefined", +] diff --git a/src/JavaScript/_values.py b/src/JavaScript/_values.py new file mode 100644 index 0000000..8f1abd4 --- /dev/null +++ b/src/JavaScript/_values.py @@ -0,0 +1,195 @@ +"""Shared ECMAScript value operations for the supported runtime subset.""" + +from __future__ import annotations + +import inspect +import math +import re +from collections.abc import Mapping +from decimal import Decimal + + +class _Undefined: + __slots__ = () + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "undefined" + + def __bool__(self) -> bool: + return False + + def __reduce__(self): + return (_Undefined, ()) + + +undefined = _Undefined() +null = None +NaN = math.nan +Infinity = math.inf + +# ECMAScript WhiteSpace and LineTerminator code points, including BOM. +WHITESPACE = "\u0009\u000b\u000c\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\ufeff\u000a\u000d\u2028\u2029" +_DECIMAL = re.compile(r"[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|Infinity)\Z") + + +def is_number(value) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def truthy(value=undefined) -> bool: + """JavaScript Boolean conversion; empty containers are true.""" + if value is undefined or value is None or value is False: + return False + if is_number(value): + return value != 0 and not math.isnan(to_number(value)) + if isinstance(value, str): + return len(value) != 0 + return True + + +def primitive(value): + if value is undefined or value is None or isinstance(value, (str, bool, int, float)): + return value + method = getattr(type(value), "_js_primitive", None) + if method is not None: + result = method(value) + if result is undefined or result is None or isinstance(result, (str, bool, int, float)): + return result + raise TypeError("Cannot convert object to a primitive value") + if isinstance(value, (list, tuple)): + return ",".join("" if item is None or item is undefined else to_string(item) for item in value) + if isinstance(value, Mapping): + return "[object Object]" + raise TypeError(f"Unsupported JavaScript value: {type(value).__name__}") + + +def to_number(value=undefined) -> float: + value = primitive(value) + if value is undefined: + return NaN + if value is None: + return 0.0 + if isinstance(value, (bool, int, float)): + try: + return float(value) + except OverflowError: + return -Infinity if value < 0 else Infinity + text = value.strip(WHITESPACE) + if not text: + return 0.0 + if re.fullmatch(r"0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+", text): + return to_number(int(text, 0)) + if not _DECIMAL.fullmatch(text): + return NaN + return float(text) + + +def number_string(value) -> str: + number = to_number(value) + if math.isnan(number): + return "NaN" + if math.isinf(number): + return "-Infinity" if number < 0 else "Infinity" + if number == 0: + return "0" + text = repr(number) + if 1e-6 <= abs(number) < 1e21: + text = format(Decimal(text), "f") + return text.rstrip("0").rstrip(".") if "." in text else text + mantissa, exponent = text.split("e") + exponent = int(exponent) + return f"{mantissa.removesuffix('.0')}e{'+' if exponent >= 0 else '-'}{abs(exponent)}" + + +def to_string(value=undefined) -> str: + value = primitive(value) + if value is undefined: + return "undefined" + if value is None: + return "null" + if value is True: + return "true" + if value is False: + return "false" + if isinstance(value, str): + return str(value) + return number_string(value) + + +def integer(value=undefined): + number = to_number(value) + if math.isnan(number) or number == 0: + return 0 + return number if math.isinf(number) else math.trunc(number) + + +def relative_index(value, length: int) -> int: + index = integer(value) + if index < 0: + return max(length + index, 0) + return min(index, length) + + +def strictEqual(left, right) -> bool: + """ECMAScript strict equality for supported values (objects use identity).""" + if is_number(left) and is_number(right): + return to_number(left) == to_number(right) + if isinstance(left, str) and isinstance(right, str): + return str(left) == str(right) + return left is right + + +def same_value(left, right) -> bool: + if is_number(left) and is_number(right): + a, b = to_number(left), to_number(right) + if math.isnan(a) and math.isnan(b): + return True + if a == b == 0: + return math.copysign(1, a) == math.copysign(1, b) + return strictEqual(left, right) + + +def same_value_zero(left, right) -> bool: + return strictEqual(left, right) or (is_number(left) and is_number(right) and math.isnan(to_number(left)) and math.isnan(to_number(right))) + + +def typeof(value=undefined) -> str: + if value is undefined: + return "undefined" + if value is None: + return "object" + if isinstance(value, bool): + return "boolean" + if is_number(value): + return "number" + if isinstance(value, str): + return "string" + return "function" if callable(value) else "object" + + +def isNaN(value=undefined) -> bool: + return math.isnan(to_number(value)) + + +def isFinite(value=undefined) -> bool: + return math.isfinite(to_number(value)) + + +def callback(function): + """Adapt declared positional arity once, without retrying callback errors.""" + if not callable(function): + raise TypeError("Callback must be callable") + try: + parameters = inspect.signature(function).parameters.values() + except (TypeError, ValueError): + return function + if any(p.kind == p.VAR_POSITIONAL for p in parameters): + return function + count = sum(p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) for p in parameters) + return lambda *args: function(*args[:count]) diff --git a/src/JavaScript/py.typed b/src/JavaScript/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_values.py b/tests/test_values.py new file mode 100644 index 0000000..9e2fd51 --- /dev/null +++ b/tests/test_values.py @@ -0,0 +1,67 @@ +import copy +import math +import pickle +import unittest + +from JavaScript import isFinite, isNaN, strictEqual, truthy, typeof, undefined +from JavaScript._values import callback, same_value, same_value_zero, to_number, to_string + + +class ValueTests(unittest.TestCase): + def test_undefined_is_distinct_and_preserves_identity(self): + self.assertIsNot(undefined, None) + self.assertFalse(undefined) + self.assertEqual(repr(undefined), "undefined") + for value in (copy.copy(undefined), copy.deepcopy(undefined), pickle.loads(pickle.dumps(undefined))): + self.assertIs(value, undefined) + + def test_boolean_conversion_uses_javascript_rules(self): + for value in (undefined, None, False, 0, -0.0, math.nan, ""): + self.assertFalse(truthy(value), repr(value)) + for value in ([], {}, (), True, "0", "false", math.inf, object()): + self.assertTrue(truthy(value), repr(value)) + + def test_number_conversion_and_numeric_grammar(self): + for value, result in [(None, 0), (True, 1), ("", 0), (" \ufeff42\n", 42), ("0xff", 255), ("0b11", 3), ("0o10", 8), (".5e2", 50), ([], 0), ([2], 2)]: + self.assertEqual(to_number(value), result) + for value in (undefined, "NaN", "inf", "+0xff", "1_000", "\x851", "οΌ‘οΌ’", [1, 2], {}): + self.assertTrue(math.isnan(to_number(value)), repr(value)) + self.assertEqual(math.copysign(1, to_number("-0")), -1) + self.assertEqual(to_number(2**53 + 1), 2**53) + self.assertEqual(to_number(10**400), math.inf) + + def test_string_conversion(self): + for value, result in [(undefined, "undefined"), (None, "null"), (False, "false"), (-0.0, "0"), (1e-7, "1e-7"), (1e-6, "0.000001"), (1e20, "100000000000000000000"), (1e21, "1e+21"), (math.nan, "NaN"), ({}, "[object Object]"), ([1, None, undefined], "1,,")]: + self.assertEqual(to_string(value), result) + + def test_equality_relations(self): + self.assertFalse(strictEqual(True, 1)) + self.assertFalse(strictEqual({}, {})) + self.assertFalse(strictEqual([], [])) + self.assertFalse(strictEqual(math.nan, math.nan)) + self.assertTrue(strictEqual(0, -0.0)) + self.assertFalse(same_value(0, -0.0)) + self.assertTrue(same_value(math.nan, math.nan)) + self.assertTrue(same_value_zero(math.nan, math.nan)) + self.assertTrue(same_value_zero(0, -0.0)) + + def test_typeof_and_coercing_predicates(self): + for value, name in [(undefined, "undefined"), (None, "object"), (True, "boolean"), (1, "number"), ("", "string"), ([], "object"), (lambda: None, "function")]: + self.assertEqual(typeof(value), name) + self.assertTrue(isNaN("bad")) + self.assertFalse(isNaN(None)) + self.assertTrue(isFinite("12")) + self.assertFalse(isFinite(math.inf)) + + def test_callback_arity_and_errors_are_not_retried(self): + self.assertEqual(callback(lambda value: value * 2)(3, 0, []), 6) + self.assertEqual(callback(lambda *args: args)(3, 0), (3, 0)) + calls = [] + def fail(value): + calls.append(value) + raise TypeError("from callback body") + with self.assertRaisesRegex(TypeError, "from callback body"): + callback(fail)(1, 2, 3) + self.assertEqual(calls, [1]) + with self.assertRaises(TypeError): + callback(None) From a1b8beabd93f73220a7794cc2aa814a50ba78378 Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:44:56 -0700 Subject: [PATCH 2/5] feat: add Object and sparse Array methods --- src/JavaScript/__init__.py | 3 + src/JavaScript/_array.py | 335 +++++++++++++++++++++++++++++++++++++ src/JavaScript/_object.py | 182 ++++++++++++++++++++ tests/test_array.py | 159 ++++++++++++++++++ tests/test_object.py | 84 ++++++++++ 5 files changed, 763 insertions(+) create mode 100644 src/JavaScript/_array.py create mode 100644 src/JavaScript/_object.py create mode 100644 tests/test_array.py create mode 100644 tests/test_object.py diff --git a/src/JavaScript/__init__.py b/src/JavaScript/__init__.py index 85244e0..82dbb51 100644 --- a/src/JavaScript/__init__.py +++ b/src/JavaScript/__init__.py @@ -1,5 +1,7 @@ """JavaScript-style objects, methods and functions for Python.""" +from ._array import Array +from ._object import Object from ._values import ( Infinity, NaN, @@ -13,6 +15,7 @@ ) __all__ = [ + "Array", "Object", "Infinity", "NaN", "isFinite", "isNaN", "null", "strictEqual", "truthy", "typeof", "undefined", ] diff --git a/src/JavaScript/_array.py b/src/JavaScript/_array.py new file mode 100644 index 0000000..afc0d08 --- /dev/null +++ b/src/JavaScript/_array.py @@ -0,0 +1,335 @@ +"""Sparse arrays and selected ECMAScript Array algorithms.""" + +from __future__ import annotations + +from functools import cmp_to_key +from reprlib import recursive_repr + +from ._object import array_index, ordered_keys +from ._values import ( + callback, integer, is_number, relative_index, same_value_zero, strictEqual, + to_number, to_string, truthy, undefined, +) + + +class Array: + """Identity-based sparse indexed values; use Array.of for one numeric item.""" + + __slots__ = ("_items", "_length", "_properties") + + def __init__(self, *values): + self._items = {} + self._properties = {} + self._length = 0 + if len(values) == 1 and is_number(values[0]): + self.length = values[0] + else: + self.push(*values) + + @property + def length(self): + return self._length + + @length.setter + def length(self, value): + number = to_number(value) + if not 0 <= number <= 2**32 - 1 or number != int(number): + raise ValueError("Invalid array length") + length = int(number) + for index in list(self._items): + if index >= length: + del self._items[index] + self._length = length + + def __getitem__(self, key): + key = to_string(key) + if key == "length": + return self.length + index = array_index(key) + return self._items.get(index, undefined) if index is not None else self._properties.get(key, undefined) + + def __setitem__(self, key, value): + key = to_string(key) + if key == "length": + self.length = value + return + index = array_index(key) + if index is None: + self._properties[key] = value + else: + self._items[index] = value + self._length = max(self._length, index + 1) + + def __delitem__(self, key): + key = to_string(key) + if key == "length": + raise TypeError("Cannot delete array length") + index = array_index(key) + (self._items if index is not None else self._properties).pop(index if index is not None else key, None) + + def __contains__(self, key): + return self._js_has_own(to_string(key)) + + def _js_has_own(self, key): + index = array_index(key) + return key == "length" or (index in self._items if index is not None else key in self._properties) + + def _js_entries(self): + return [(str(i), self._items[i]) for i in sorted(self._items)] + [(key, self._properties[key]) for key in ordered_keys(self._properties)] + + def __iter__(self): + index = 0 + while index < self.length: + yield self[index] + index += 1 + + def __len__(self): + return self.length + + def __bool__(self): + return True + + @recursive_repr("Array(...)") + def __repr__(self): + return f"Array(length={self.length}, items={self._items!r})" + + @recursive_repr("") + def _js_primitive(self): + return self.join() + + @classmethod + def of(cls, *values): + result = cls() + result.push(*values) + return result + + @classmethod + def from_(cls, source, mapFn=undefined): + transform = callback(mapFn) if mapFn is not undefined else None + result = cls() + for index, value in enumerate(source): + result.push(transform(value, index) if transform else value) + return result + + @staticmethod + def isArray(value): + return isinstance(value, Array) + + def at(self, index=undefined): + index = integer(index) + if index < 0: + index += self.length + return self[index] if 0 <= index < self.length else undefined + + def push(self, *values): + if self.length + len(values) > 2**32 - 1: + raise ValueError("Invalid array length") + for value in values: + self[self.length] = value + return self.length + + def pop(self): + if not self.length: + return undefined + index = self.length - 1 + value = self[index] + self._items.pop(index, None) + self._length -= 1 + return value + + def shift(self): + if not self.length: + return undefined + value = self[0] + self._items = {i - 1: item for i, item in self._items.items() if i > 0} + self._length -= 1 + return value + + def unshift(self, *values): + if self.length + len(values) > 2**32 - 1: + raise ValueError("Invalid array length") + self._items = {i + len(values): item for i, item in self._items.items()} + self._items.update(enumerate(values)) + self._length += len(values) + return self.length + + def slice(self, start=0, end=undefined): + length = self.length + start = relative_index(start, length) + end = length if end is undefined else relative_index(end, length) + result = Array(max(end - start, 0)) + for i in range(start, end): + if i in self._items: + result[i - start] = self[i] + return result + + def splice(self, *args): + if not args: + return Array() + start = relative_index(args[0], self.length) + count = self.length - start if len(args) == 1 else max(0, min(integer(args[1]), self.length - start)) + values = args[2:] + new_length = self.length - count + len(values) + if new_length > 2**32 - 1: + raise ValueError("Invalid array length") + removed = self.slice(start, start + count) + items = {i: value for i, value in self._items.items() if i < start} + items.update({i - count + len(values): value for i, value in self._items.items() if i >= start + count}) + items.update({start + i: value for i, value in enumerate(values)}) + self._items, self._length = items, new_length + return removed + + def concat(self, *values): + result = self.slice() + for value in values: + if isinstance(value, Array): + start = result.length + result.length += value.length + for i, child in value._items.items(): + result[start + i] = child + else: + result.push(value) + return result + + def join(self, separator=undefined): + separator = "," if separator is undefined else to_string(separator) + return separator.join("" if value is undefined or value is None else to_string(value) for value in self) + + def toString(self): + return self.join() + + def includes(self, value=undefined, fromIndex=0): + start = relative_index(fromIndex, self.length) + return any(same_value_zero(self[i], value) for i in range(start, self.length)) + + def indexOf(self, value=undefined, fromIndex=0): + for i in range(relative_index(fromIndex, self.length), self.length): + if i in self._items and strictEqual(self[i], value): + return i + return -1 + + def lastIndexOf(self, value=undefined, *args): + start = self.length - 1 if not args else integer(args[0]) + if start < 0: + start += self.length + start = min(start, self.length - 1) + if start < 0: + return -1 + for i in range(start, -1, -1): + if i in self._items and strictEqual(self[i], value): + return i + return -1 + + def map(self, function): + transform = callback(function) + result = Array(self.length) + for i in range(self.length): + if i in self._items: + result[i] = transform(self[i], i, self) + return result + + def filter(self, function): + predicate = callback(function) + result = Array() + for i in range(self.length): + if i in self._items: + value = self[i] + if truthy(predicate(value, i, self)): + result.push(value) + return result + + def forEach(self, function): + visit = callback(function) + for i in range(self.length): + if i in self._items: + visit(self[i], i, self) + return undefined + + def some(self, function): + predicate = callback(function) + return any(i in self._items and truthy(predicate(self[i], i, self)) for i in range(self.length)) + + def every(self, function): + predicate = callback(function) + return all(i not in self._items or truthy(predicate(self[i], i, self)) for i in range(self.length)) + + def _find(self, function, reverse=False): + predicate = callback(function) + indices = range(self.length - 1, -1, -1) if reverse else range(self.length) + for i in indices: + value = self[i] + if truthy(predicate(value, i, self)): + return i, value + return -1, undefined + + def find(self, function): + return self._find(function)[1] + + def findIndex(self, function): + return self._find(function)[0] + + def findLast(self, function): + return self._find(function, True)[1] + + def findLastIndex(self, function): + return self._find(function, True)[0] + + def _reduce(self, function, args, reverse=False): + reducer = callback(function) + indices = iter(range(self.length - 1, -1, -1) if reverse else range(self.length)) + if args: + accumulator = args[0] + else: + for i in indices: + if i in self._items: + accumulator = self[i] + break + else: + raise TypeError("Reduce of empty array with no initial value") + for i in indices: + if i in self._items: + accumulator = reducer(accumulator, self[i], i, self) + return accumulator + + def reduce(self, function, *args): + return self._reduce(function, args) + + def reduceRight(self, function, *args): + return self._reduce(function, args, True) + + def reverse(self): + self._items = {self.length - i - 1: value for i, value in self._items.items()} + return self + + def fill(self, value=undefined, start=0, end=undefined): + end = self.length if end is undefined else relative_index(end, self.length) + for i in range(relative_index(start, self.length), end): + self[i] = value + return self + + def sort(self, compareFn=undefined): + compare = callback(compareFn) if compareFn is not undefined else None + values = [self._items[i] for i in sorted(self._items) if self._items[i] is not undefined] + undefined_count = sum(value is undefined for value in self._items.values()) + if compare: + def order(a, b): + number = to_number(compare(a, b)) + return -1 if number < 0 else 1 if number > 0 else 0 + values.sort(key=cmp_to_key(order)) + else: + values.sort(key=lambda value: to_string(value).encode("utf-16-be", "surrogatepass")) + self._items = dict(enumerate(values + [undefined] * undefined_count)) + return self + + def keys(self): + index = 0 + while index < self.length: + yield index + index += 1 + + def values(self): + return iter(self) + + def entries(self): + for index in self.keys(): + yield Array.of(index, self[index]) diff --git a/src/JavaScript/_object.py b/src/JavaScript/_object.py new file mode 100644 index 0000000..d5487a7 --- /dev/null +++ b/src/JavaScript/_object.py @@ -0,0 +1,182 @@ +"""Own enumerable data properties for the initial Object subset.""" + +from __future__ import annotations + +from collections.abc import Mapping +from reprlib import recursive_repr + +from ._values import same_value, to_string, undefined + + +def array_index(key: str) -> int | None: + if key.isascii() and key.isdecimal() and (key == "0" or not key.startswith("0")): + # Avoid Python's integer-string digit limit on arbitrary property names. + if len(key) <= 10: + number = int(key) + if number < 2**32 - 1: + return number + return None + + +def ordered_keys(properties) -> list[str]: + indices = [key for key in properties if array_index(key) is not None] + names = [key for key in properties if array_index(key) is None] + return sorted(indices, key=int) + names + + +def own_entries(value): + if value is None or value is undefined: + raise TypeError("Cannot convert undefined or null to object") + method = getattr(type(value), "_js_entries", None) + if method is not None: + return method(value) + if isinstance(value, Mapping): + properties = {to_string(key): value[key] for key in value} + return [(key, properties[key]) for key in ordered_keys(properties)] + if isinstance(value, str): + data = value.encode("utf-16-le", "surrogatepass") + return [(str(i // 2), data[i:i + 2].decode("utf-16-le", "surrogatepass")) for i in range(0, len(data), 2)] + if isinstance(value, (int, float, bool)): + return [] + raise TypeError("Expected Object, Array, mapping or primitive") + + +class Object: + """An identity-based object containing enumerable, writable data properties. + + Missing properties return undefined. Own keys shadow ordinary attributes; + use Object.keys(obj) rather than obj.keys() when calling static methods. + Special Python attributes retain their Python meaning. + """ + + __slots__ = ("_properties",) + + def __new__(cls, value=undefined): + if isinstance(value, cls): + return value + return super().__new__(cls) + + def __init__(self, value=undefined): + if value is self: + return + if value is not None and value is not undefined and not isinstance(value, Mapping): + raise TypeError("Object() supports mappings, Object, null or undefined") + object.__setattr__(self, "_properties", {}) + if value is not None and value is not undefined: + for key, child in own_entries(value): + self[key] = child + + def __getattribute__(self, name): + if not (name.startswith("__") and name.endswith("__")): + properties = object.__getattribute__(self, "_properties") + if name in properties: + return properties[name] + return object.__getattribute__(self, name) + + def __getattr__(self, name): + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + return undefined + + def __setattr__(self, name, value): + if name.startswith("__") and name.endswith("__"): + raise AttributeError("Use brackets for special Python names") + self[name] = value + + def __delattr__(self, name): + if name.startswith("__") and name.endswith("__"): + raise AttributeError("Use brackets for special Python names") + del self[name] + + def __getitem__(self, key): + return object.__getattribute__(self, "_properties").get(to_string(key), undefined) + + def __setitem__(self, key, value): + object.__getattribute__(self, "_properties")[to_string(key)] = value + + def __delitem__(self, key): + object.__getattribute__(self, "_properties").pop(to_string(key), None) + + def __contains__(self, key): + return to_string(key) in object.__getattribute__(self, "_properties") + + def __iter__(self): + return iter(ordered_keys(object.__getattribute__(self, "_properties"))) + + def __len__(self): + return len(object.__getattribute__(self, "_properties")) + + def __bool__(self): + return True + + def __repr__(self): + return _object_repr(self) + + def _js_entries(self): + properties = object.__getattribute__(self, "_properties") + return [(key, properties[key]) for key in ordered_keys(properties)] + + def _js_primitive(self): + return "[object Object]" + + def toString(self): + return "[object Object]" + + @staticmethod + def keys(value): + from ._array import Array + return Array.of(*(key for key, _ in own_entries(value))) + + @staticmethod + def values(value): + from ._array import Array + return Array.of(*(child for _, child in own_entries(value))) + + @staticmethod + def entries(value): + from ._array import Array + return Array.of(*(Array.of(key, child) for key, child in own_entries(value))) + + @staticmethod + def assign(target, *sources): + if not isinstance(target, Object): + raise TypeError("assign target must be an Object") + for source in sources: + if source is not None and source is not undefined: + for key, value in own_entries(source): + target[key] = value + return target + + @staticmethod + def fromEntries(entries): + from ._array import Array + result = Object() + for entry in entries: + if isinstance(entry, (Object, Array)): + key, value = entry[0], entry[1] + elif isinstance(entry, Mapping): + properties = dict(own_entries(entry)) + key, value = properties.get("0", undefined), properties.get("1", undefined) + elif isinstance(entry, (list, tuple)): + key = entry[0] if entry else undefined + value = entry[1] if len(entry) > 1 else undefined + else: + raise TypeError("Iterator entry must be an object") from None + result[key] = value + return result + + @staticmethod + def hasOwn(value, key): + if value is None or value is undefined: + raise TypeError("Cannot convert undefined or null to object") + method = getattr(type(value), "_js_has_own", None) + if method is not None: + return method(value, to_string(key)) + return to_string(key) in {name for name, _ in own_entries(value)} + + is_ = staticmethod(same_value) + + +@recursive_repr("Object({...})") +def _object_repr(value): + return f"Object({object.__getattribute__(value, '_properties')!r})" diff --git a/tests/test_array.py b/tests/test_array.py new file mode 100644 index 0000000..7ff618d --- /dev/null +++ b/tests/test_array.py @@ -0,0 +1,159 @@ +import math +import unittest + +from JavaScript import Array, Object, undefined + + +class ArrayTests(unittest.TestCase): + def test_constructors_length_holes_and_presence(self): + value = Array(3) + self.assertEqual(value.length, 3) + self.assertEqual(list(value), [undefined] * 3) + self.assertEqual(list(Object.keys(value)), []) + value[1] = undefined + self.assertTrue(Object.hasOwn(value, 1)) + self.assertFalse(Object.hasOwn(value, 0)) + self.assertEqual(list(Array.of(3)), [3]) + self.assertEqual(list(Array(True)), [True]) + self.assertTrue(Array()) + self.assertNotEqual(Array(), Array()) + self.assertTrue(Array.isArray(value)) + self.assertFalse(Array.isArray([])) + + def test_length_validation_truncation_and_sparse_expansion(self): + value = Array.of(1, 2, 3) + value.length = 1 + value.length = "4" + self.assertEqual(list(value), [1, undefined, undefined, undefined]) + self.assertFalse(Object.hasOwn(value, 2)) + value[10] = 8 + self.assertEqual(value.length, 11) + del value[10] + self.assertEqual(value.length, 11) + for length in (-1, 1.5, math.nan, math.inf, 2**32): + with self.assertRaises(ValueError): + value.length = length + self.assertEqual(Array(2**32 - 1).length, 2**32 - 1) + + def test_negative_bracket_is_a_property_and_at_is_relative(self): + value = Array.of("a", "b") + self.assertIs(value[-1], undefined) + value[-1] = "named" + self.assertEqual(value[-1], "named") + self.assertEqual(value.at(-1), "b") + self.assertIs(value.at(-3), undefined) + self.assertIs(value.at(math.inf), undefined) + self.assertEqual(value.length, 2) + + def test_mutators_preserve_holes_and_return_javascript_results(self): + value = Array(2) + value[1] = 2 + self.assertEqual(value.push(3, 4), 4) + self.assertEqual(value.pop(), 4) + self.assertIs(value.shift(), undefined) + self.assertEqual(value.unshift(0, 1), 4) + self.assertEqual(list(value), [0, 1, 2, 3]) + self.assertIs(Array().pop(), undefined) + self.assertIs(Array().shift(), undefined) + + def test_slice_splice_concat_and_fill(self): + value = Array(4) + value[1], value[3] = "a", "b" + sliced = value.slice(0, -1) + self.assertEqual(sliced.length, 3) + self.assertEqual(list(Object.keys(sliced)), ["1"]) + removed = value.splice(1, 2, "x", "y", "z") + self.assertEqual(list(removed), ["a", undefined]) + self.assertFalse(Object.hasOwn(removed, 1)) + self.assertEqual(list(value), [undefined, "x", "y", "z", "b"]) + combined = Array(1).concat(Array.of(1), [2, 3]) + self.assertEqual(combined.length, 3) + self.assertFalse(Object.hasOwn(combined, 0)) + self.assertEqual(combined[2], [2, 3]) + self.assertIs(value.fill(7, -2), value) + self.assertEqual(list(value)[-2:], [7, 7]) + self.assertEqual(value.splice().length, 0) + + def test_search_uses_correct_equality_and_hole_rules(self): + value = Array(1).concat(Array.of(math.nan, True, 1, -0.0, undefined)) + self.assertTrue(value.includes(math.nan)) + self.assertEqual(value.indexOf(math.nan), -1) + self.assertEqual(value.indexOf(undefined), 5) + self.assertTrue(Array(1).includes(undefined)) + self.assertEqual(value.indexOf(1), 3) + self.assertEqual(value.lastIndexOf(undefined), 5) + self.assertEqual(value.lastIndexOf(undefined, undefined), -1) + self.assertEqual(value.indexOf(0, -2), 4) + target = Object() + self.assertEqual(Array.of(Object(), target).indexOf(target), 1) + + def test_callback_methods_skip_holes_except_find_and_find_last(self): + value = Array(3) + value[1] = 2 + mapped = value.map(lambda child: child * 3) + self.assertEqual(list(Object.keys(mapped)), ["1"]) + self.assertEqual(mapped[1], 6) + self.assertEqual(list(value.filter(lambda child: True)), [2]) + visited = [] + self.assertIs(value.forEach(lambda child, index: visited.append((child, index))), undefined) + self.assertEqual(visited, [(2, 1)]) + self.assertTrue(value.every(lambda child: child == 2)) + self.assertTrue(value.some(lambda child: child == 2)) + self.assertEqual(value.findIndex(lambda child: child is undefined), 0) + self.assertEqual(value.findLastIndex(lambda child: child is undefined), 2) + self.assertIs(value.findLast(lambda child: child is undefined), undefined) + self.assertTrue(Array.of(1).some(lambda child: [])) + with self.assertRaises(TypeError): + Array().map(None) + + def test_callbacks_snapshot_length_and_observe_mutation(self): + value = Array.of(1, 2, 3) + seen = [] + def visit(child, index, array): + seen.append(child) + if index == 0: + array[1] = 9 + del array[2] + array.push(4) + return child + mapped = value.map(visit) + self.assertEqual(seen, [1, 9]) + self.assertEqual(mapped.length, 3) + self.assertFalse(Object.hasOwn(mapped, 2)) + + def test_reductions_distinguish_missing_and_explicit_undefined(self): + value = Array(1).concat(Array.of(2, 3)) + self.assertEqual(value.reduce(lambda total, child: total + child), 5) + self.assertEqual(value.reduceRight(lambda total, child: total - child), 1) + self.assertEqual(value.reduce(lambda total, child: total + child, 10), 15) + self.assertIs(Array().reduce(lambda total, child: child, undefined), undefined) + with self.assertRaises(TypeError): + Array(3).reduce(lambda total, child: child) + + def test_sort_reverse_and_join(self): + value = Array(1).concat(Array.of(10, 2, undefined, 1)) + self.assertIs(value.sort(), value) + self.assertEqual(list(value), [1, 10, 2, undefined, undefined]) + self.assertTrue(Object.hasOwn(value, 3)) + self.assertFalse(Object.hasOwn(value, 4)) + self.assertEqual(list(Array.of(10, 2, 1).sort(lambda a, b: a - b)), [1, 2, 10]) + self.assertIs(value.reverse(), value) + self.assertFalse(Object.hasOwn(value, 0)) + self.assertEqual(Array.of(1, None, undefined, Array.of(2, 3)).join("|"), "1|||2,3") + + def test_from_and_iterators(self): + self.assertEqual(list(Array.from_(Array(2))), [undefined, undefined]) + self.assertEqual(list(Array.from_([2, 3], lambda value, index: value + index)), [2, 4]) + value = Array.of("a") + iterator = value.values() + self.assertEqual(next(iterator), "a") + value.push("b") + self.assertEqual(next(iterator), "b") + self.assertEqual(list(value.keys()), [0, 1]) + self.assertEqual([list(entry) for entry in value.entries()], [[0, "a"], [1, "b"]]) + + def test_cycle_repr_and_string_conversion(self): + value = Array() + value.push(value) + self.assertIn("...", repr(value)) + self.assertEqual(value.toString(), "") diff --git a/tests/test_object.py b/tests/test_object.py new file mode 100644 index 0000000..ff71753 --- /dev/null +++ b/tests/test_object.py @@ -0,0 +1,84 @@ +import math +import unittest + +from JavaScript import Array, Object, undefined + + +class ObjectTests(unittest.TestCase): + def test_properties_missing_values_and_identity(self): + child = Object({"name": "Ada"}) + value = Object({"child": child, "items": 3, "__class__": "data"}) + self.assertIs(value.child, child) + self.assertEqual(value.items, 3) + self.assertEqual(value["__class__"], "data") + self.assertIs(value.__class__, Object) + self.assertIs(value.missing, undefined) + self.assertIs(value["missing"], undefined) + self.assertNotIn("missing", value) + self.assertIs(Object(value), value) + self.assertNotEqual(value, Object({"child": child, "items": 3})) + self.assertTrue(Object()) + + def test_mutation_key_conversion_and_safe_names(self): + value = Object() + value.name = "Grace" + value[True] = 1 + value[None] = 2 + value[-0.0] = 3 + value["_properties"] = "user data" + self.assertEqual(value["true"], 1) + self.assertEqual(value["null"], 2) + self.assertEqual(value[0], 3) + self.assertEqual(value._properties, "user data") + del value.name + del value["absent"] + self.assertIs(value.name, undefined) + with self.assertRaises(AttributeError): + value.__class__ = str + + def test_numeric_key_order_and_reinsertion(self): + value = Object({"b": 1, "10": 2, "2": 3, "01": 4, "0": 5, "4294967295": 6}) + self.assertEqual(list(Object.keys(value)), ["0", "2", "10", "b", "01", "4294967295"]) + del value.b + value.b = 7 + self.assertEqual(list(Object.keys(value))[-1], "b") + value["9" * 5000] = "long key" + self.assertTrue(Object.hasOwn(value, "9" * 5000)) + + def test_enumeration_copy_and_from_entries(self): + child = Object() + value = Object.assign(Object(), None, {"a": child}, {"a": 2, "b": child}) + self.assertEqual(list(Object.values(value)), [2, child]) + self.assertIs(value.b, child) + restored = Object.fromEntries(Object.entries(value)) + self.assertIs(restored.b, child) + self.assertEqual(restored.a, 2) + self.assertEqual(Object.fromEntries([["a", 1], ["a", 2]]).a, 2) + self.assertIs(Object.fromEntries([["a"]]).a, undefined) + self.assertEqual(Object.fromEntries([Object({"0": "x", "1": 4})]).x, 4) + with self.assertRaises(TypeError): + Object.fromEntries(["xy"]) + + def test_array_and_string_own_properties(self): + value = Array(3) + value[1] = undefined + value["-1"] = 9 + self.assertEqual(list(Object.keys(value)), ["1", "-1"]) + self.assertTrue(Object.hasOwn(value, "length")) + self.assertFalse(Object.hasOwn(value, 0)) + self.assertTrue(Object.hasOwn(value, 1)) + self.assertEqual(list(Object.keys("πŸ˜€")), ["0", "1"]) + self.assertEqual(list(Object.values("πŸ˜€")), ["\ud83d", "\ude00"]) + + def test_null_errors_and_same_value(self): + for value in (None, undefined): + with self.assertRaises(TypeError): + Object.keys(value) + self.assertTrue(Object.is_(math.nan, math.nan)) + self.assertFalse(Object.is_(0, -0.0)) + self.assertFalse(Object.is_(Object(), Object())) + + def test_repr_handles_cycles(self): + value = Object() + value.self = value + self.assertIn("...", repr(value)) From de12d6215d2fe6cafc135f6690e2d331af93f909 Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:48:45 -0700 Subject: [PATCH 3/5] feat: add primitive methods and JSON adapters --- src/JavaScript/__init__.py | 8 +- src/JavaScript/_json.py | 114 +++++++++++++++++++++++++ src/JavaScript/_math.py | 106 +++++++++++++++++++++++ src/JavaScript/_number.py | 90 ++++++++++++++++++++ src/JavaScript/_string.py | 170 +++++++++++++++++++++++++++++++++++++ src/JavaScript/_values.py | 2 +- tests/test_integration.py | 26 ++++++ tests/test_json.py | 62 ++++++++++++++ tests/test_primitives.py | 124 +++++++++++++++++++++++++++ 9 files changed, 700 insertions(+), 2 deletions(-) create mode 100644 src/JavaScript/_json.py create mode 100644 src/JavaScript/_math.py create mode 100644 src/JavaScript/_number.py create mode 100644 src/JavaScript/_string.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_json.py create mode 100644 tests/test_primitives.py diff --git a/src/JavaScript/__init__.py b/src/JavaScript/__init__.py index 82dbb51..ce24ecf 100644 --- a/src/JavaScript/__init__.py +++ b/src/JavaScript/__init__.py @@ -1,7 +1,12 @@ """JavaScript-style objects, methods and functions for Python.""" from ._array import Array +from ._json import JSON +from ._math import Math +from ._number import Boolean, Number, parseFloat, parseInt from ._object import Object +from ._string import String +from fetch_api import FetchError, Headers, Promise, Request, Response, fetch from ._values import ( Infinity, NaN, @@ -15,7 +20,8 @@ ) __all__ = [ - "Array", "Object", + "Array", "Boolean", "FetchError", "Headers", "JSON", "Math", "Number", + "Object", "Promise", "Request", "Response", "String", "fetch", "parseFloat", "parseInt", "Infinity", "NaN", "isFinite", "isNaN", "null", "strictEqual", "truthy", "typeof", "undefined", ] diff --git a/src/JavaScript/_json.py b/src/JavaScript/_json.py new file mode 100644 index 0000000..f0c8c88 --- /dev/null +++ b/src/JavaScript/_json.py @@ -0,0 +1,114 @@ +"""Adapt the standalone JSON parser to runtime values and serialization rules.""" + +from __future__ import annotations + +import json as _json +import math +from collections.abc import Mapping + +import JSON as _standalone + +from ._array import Array +from ._object import Object, own_entries +from ._string import String +from ._values import is_number, number_string, to_number, to_string, undefined + + +def _runtime(value): + if isinstance(value, dict): + return Object({key: _runtime(child) for key, child in dict.items(value)}) + if isinstance(value, list): + return Array.of(*(_runtime(child) for child in value)) + if isinstance(value, str): + return String(value) + return to_number(value) if is_number(value) else value + + +def _quote(value): + # Delegate escaping to the companion, preserving normal Unicode while + # escaping lone surrogates as required by well-formed JSON.stringify. + value = value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass") + quoted = _standalone.stringify(value, ensure_ascii=False) + return "".join(f"\\u{ord(char):04x}" if 0xD800 <= ord(char) <= 0xDFFF else char for char in quoted) + + +class _JSON: + JSONDecodeError = _standalone.JSONDecodeError + + @staticmethod + def parse(text): + """Parse JSON after JavaScript string conversion; no reviver overload.""" + if isinstance(text, (bytes, bytearray)): + raise TypeError("JavaScript.JSON.parse expects text, not bytes") + def reject_constant(value): + raise ValueError(f"{value} is not a valid JSON numeric constant") + + # The standalone parser preserves Python integers. Select binary64 + # decoder hooks here so nested -0 and large integers retain JS semantics. + value = _json.loads( + to_string(text), parse_int=float, parse_float=float, + parse_constant=reject_constant, + ) + return _runtime(value) + + @staticmethod + def stringify(value=undefined, replacer=undefined, space=undefined): + """Serialize supported values; replacers and toJSON hooks are not supported.""" + if replacer is not undefined and replacer is not None: + raise NotImplementedError("JSON replacers are not supported") + if is_number(space): + number = to_number(space) + gap = " " * (0 if math.isnan(number) else int(max(0, min(number, 10)))) + elif isinstance(space, str): + gap = String(space).slice(0, 10).valueOf() + else: + gap = "" + active = set() + + def encode(child, depth=0): + if child is undefined or callable(child): + return undefined + if child is None: + return "null" + if isinstance(child, bool): + return "true" if child else "false" + if isinstance(child, str): + return _quote(child) + if is_number(child): + return number_string(child) if math.isfinite(to_number(child)) else "null" + if not isinstance(child, (Object, Array, Mapping, list, tuple)): + raise TypeError(f"Unsupported JSON value: {type(child).__name__}") + if isinstance(child, (Object, Mapping)): + if any(key == "toJSON" and callable(item) for key, item in own_entries(child)): + raise NotImplementedError("JSON toJSON hooks are not supported") + identity = id(child) + if identity in active: + raise TypeError("Converting circular structure to JSON") + active.add(identity) + try: + array = isinstance(child, (Array, list, tuple)) + parts = [] + if array: + for item in child: + text = encode(item, depth + 1) + parts.append("null" if text is undefined else text) + else: + for key, item in own_entries(child): + text = encode(item, depth + 1) + if text is not undefined: + parts.append(_quote(key) + (": " if gap else ":") + text) + left, right = ("[", "]") if array else ("{", "}") + if not parts: + return left + right + if not gap: + return left + ",".join(parts) + right + prefix = gap * (depth + 1) + return left + "\n" + prefix + (",\n" + prefix).join(parts) + "\n" + gap * depth + right + finally: + active.remove(identity) + + result = encode(value) + return result if result is undefined else String(result) + + +JSON = _JSON() diff --git a/src/JavaScript/_math.py b/src/JavaScript/_math.py new file mode 100644 index 0000000..2744f66 --- /dev/null +++ b/src/JavaScript/_math.py @@ -0,0 +1,106 @@ +"""Selected Math functions with ECMAScript numeric edge behavior.""" + +from __future__ import annotations + +import math +import random + +from ._values import to_number, undefined + + +class Math: + PI = math.pi + E = math.e + LN2 = math.log(2) + LN10 = math.log(10) + LOG2E = math.log2(math.e) + LOG10E = math.log10(math.e) + SQRT1_2 = math.sqrt(0.5) + SQRT2 = math.sqrt(2) + + @staticmethod + def abs(value=undefined): + return abs(to_number(value)) + + @staticmethod + def floor(value=undefined): + number = to_number(value) + return float(math.floor(number)) if math.isfinite(number) and number != 0 else number + + @staticmethod + def ceil(value=undefined): + number = to_number(value) + if not math.isfinite(number) or number == 0: + return number + return -0.0 if -1 < number < 0 else float(math.ceil(number)) + + @staticmethod + def trunc(value=undefined): + number = to_number(value) + if not math.isfinite(number) or number == 0: + return number + return math.copysign(float(math.trunc(number)), number) + + @staticmethod + def round(value=undefined): + number = to_number(value) + if not math.isfinite(number) or number == 0: + return number + if -0.5 <= number < 0: + return -0.0 + lower = math.floor(number) + return float(lower if number - lower < 0.5 else lower + 1) + + @staticmethod + def sign(value=undefined): + number = to_number(value) + return number if math.isnan(number) or number == 0 else math.copysign(1.0, number) + + @staticmethod + def min(*values): + result = math.inf + for value in values: + number = to_number(value) + if math.isnan(number): + return math.nan + if number < result or (number == result == 0 and math.copysign(1, number) < 0): + result = number + return result + + @staticmethod + def max(*values): + result = -math.inf + for value in values: + number = to_number(value) + if math.isnan(number): + return math.nan + if number > result or (number == result == 0 and math.copysign(1, number) > 0): + result = number + return result + + @staticmethod + def sqrt(value=undefined): + number = to_number(value) + return math.nan if number < 0 else math.sqrt(number) + + @staticmethod + def pow(base=undefined, exponent=undefined): + base, exponent = to_number(base), to_number(exponent) + if exponent == 0: + return 1.0 + if math.isnan(exponent): + return math.nan + if abs(base) == 1 and math.isinf(exponent): + return math.nan + try: + return math.pow(base, exponent) + except OverflowError: + return -math.inf if base < 0 and exponent.is_integer() and exponent % 2 else math.inf + except ValueError: + if base == 0 and exponent < 0: + return -math.inf if math.copysign(1, base) < 0 and exponent.is_integer() and exponent % 2 else math.inf + return math.nan + + @staticmethod + def random(): + return random.random() diff --git a/src/JavaScript/_number.py b/src/JavaScript/_number.py new file mode 100644 index 0000000..c64d0d4 --- /dev/null +++ b/src/JavaScript/_number.py @@ -0,0 +1,90 @@ +"""Number conversion, predicates and prefix parsing.""" + +from __future__ import annotations + +import math +import re +import sys + +from ._values import WHITESPACE, is_number, number_string, to_number, to_string, truthy, undefined + + +class Number(float): + """Binary64 conversion with selected JavaScript Number static methods.""" + + EPSILON = 2**-52 + MAX_SAFE_INTEGER = 2**53 - 1 + MIN_SAFE_INTEGER = -(2**53 - 1) + MAX_VALUE = sys.float_info.max + MIN_VALUE = float.fromhex("0x0.0000000000001p-1022") + NaN = math.nan + POSITIVE_INFINITY = math.inf + NEGATIVE_INFINITY = -math.inf + + def __new__(cls, value=0): + return super().__new__(cls, to_number(value)) + + @staticmethod + def isNaN(value=undefined): + return is_number(value) and math.isnan(to_number(value)) + + @staticmethod + def isFinite(value=undefined): + return is_number(value) and math.isfinite(to_number(value)) + + @staticmethod + def isInteger(value=undefined): + return Number.isFinite(value) and to_number(value).is_integer() + + @staticmethod + def isSafeInteger(value=undefined): + return Number.isInteger(value) and abs(to_number(value)) <= Number.MAX_SAFE_INTEGER + + def toString(self): + from ._string import String + return String(number_string(self)) + + def valueOf(self): + return float(self) + + +def Boolean(value=undefined) -> bool: + return truthy(value) + + +def parseFloat(value=undefined): + text = to_string(value).lstrip(WHITESPACE) + match = re.match(r"[+-]?(?:Infinity|(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)", text) + return float(match[0]) if match else math.nan + + +def parseInt(value=undefined, radix=undefined): + from ._string import uint32 + text = to_string(value).lstrip(WHITESPACE) + sign = -1 if text.startswith("-") else 1 + if text.startswith(("-", "+")): + text = text[1:] + base = uint32(radix) + if base >= 2**31: + base -= 2**32 + if base != 0 and not 2 <= base <= 36: + return math.nan + if base in (0, 16) and text.startswith(("0x", "0X")): + text, base = text[2:], 16 + if base == 0: + base = 10 + digits = "0123456789abcdefghijklmnopqrstuvwxyz"[:base] + result = 0 + count = 0 + for char in text.lower(): + if char not in digits: + break + result = result * base + digits.index(char) + count += 1 + if not count: + return math.nan + return to_number(result) * sign + + +Number.parseFloat = staticmethod(parseFloat) +Number.parseInt = staticmethod(parseInt) diff --git a/src/JavaScript/_string.py b/src/JavaScript/_string.py new file mode 100644 index 0000000..5f74faa --- /dev/null +++ b/src/JavaScript/_string.py @@ -0,0 +1,170 @@ +"""Selected String methods using JavaScript UTF-16 code-unit indexing.""" + +from __future__ import annotations + +import math + +from ._array import Array +from ._values import WHITESPACE, integer, relative_index, to_number, to_string, undefined + + +def units(value: str) -> str: + data = value.encode("utf-16-le", "surrogatepass") + return "".join(chr(int.from_bytes(data[i:i + 2], "little")) for i in range(0, len(data), 2)) + + +def from_units(value: str) -> str: + return value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass") + + +def uint32(value): + number = to_number(value) + return math.trunc(number) % 2**32 if math.isfinite(number) and number else 0 + + +def clamp(value, length): + return max(0, min(integer(value), length)) + + +class String(str): + """A Python string exposing selected camelCase JavaScript String methods. + + JavaScript methods and bracket access use UTF-16 code units. Python's + inherited operators and methods otherwise retain their Python behavior. + """ + + def __new__(cls, value=""): + return super().__new__(cls, to_string(value)) + + @property + def length(self): + return len(self.encode("utf-16-le", "surrogatepass")) // 2 + + def __getitem__(self, key): + key = to_string(key) + if key == "length": + return self.length + if not key.isascii() or not key.isdecimal() or (len(key) > 1 and key.startswith("0")) or len(key) > 10: + return undefined + return self.at(int(key)) + + def __iter__(self): + return iter(from_units(units(self))) + + def at(self, index=undefined): + index = integer(index) + data = units(self) + if index < 0: + index += len(data) + return String(data[index]) if 0 <= index < len(data) else undefined + + def charAt(self, index=undefined): + index = integer(index) + data = units(self) + return String(data[index]) if 0 <= index < len(data) else String() + + def charCodeAt(self, index=undefined): + value = self.charAt(index) + return ord(value) if value else math.nan + + def codePointAt(self, index=undefined): + index = integer(index) + data = units(self) + if not 0 <= index < len(data): + return undefined + first = ord(data[index]) + if 0xD800 <= first <= 0xDBFF and index + 1 < len(data): + second = ord(data[index + 1]) + if 0xDC00 <= second <= 0xDFFF: + return (first - 0xD800) * 0x400 + second - 0xDC00 + 0x10000 + return first + + def slice(self, start=0, end=undefined): + data = units(self) + start = relative_index(start, len(data)) + end = len(data) if end is undefined else relative_index(end, len(data)) + return String(from_units(data[start:end])) + + def substring(self, start=0, end=undefined): + data = units(self) + start = clamp(start, len(data)) + end = len(data) if end is undefined else clamp(end, len(data)) + start, end = min(start, end), max(start, end) + return String(from_units(data[start:end])) + + def indexOf(self, searchString=undefined, position=0): + data = units(self) + return data.find(units(to_string(searchString)), clamp(position, len(data))) + + def lastIndexOf(self, searchString=undefined, position=undefined): + data, search = units(self), units(to_string(searchString)) + number = to_number(position) + start = len(data) if math.isnan(number) else clamp(number, len(data)) + return data.rfind(search, 0, min(start + len(search), len(data))) + + def includes(self, searchString=undefined, position=0): + return self.indexOf(searchString, position) >= 0 + + def startsWith(self, searchString=undefined, position=0): + data = units(self) + return data.startswith(units(to_string(searchString)), clamp(position, len(data))) + + def endsWith(self, searchString=undefined, endPosition=undefined): + data = units(self) + end = len(data) if endPosition is undefined else clamp(endPosition, len(data)) + return data[:end].endswith(units(to_string(searchString))) + + def split(self, separator=undefined, limit=undefined): + count = 2**32 - 1 if limit is undefined else uint32(limit) + if count == 0: + return Array() + if separator is undefined: + return Array.of(String(self)) + data, separator = units(self), units(to_string(separator)) + if separator == "": + return Array.of(*(String(char) for char in data[:count])) + return Array.of(*(String(from_units(part)) for part in data.split(separator, count)[:count])) + + def concat(self, *values): + return String(str(self) + "".join(to_string(value) for value in values)) + + def repeat(self, count=0): + count = integer(count) + if count < 0 or math.isinf(count): + raise ValueError("Invalid repeat count") + return String(str(self) * count) + + def trim(self): + return String(str(self).strip(WHITESPACE)) + + def trimStart(self): + return String(str(self).lstrip(WHITESPACE)) + + def trimEnd(self): + return String(str(self).rstrip(WHITESPACE)) + + def toLowerCase(self): + return String(str(self).lower()) + + def toUpperCase(self): + return String(str(self).upper()) + + def toString(self): + return self + + def valueOf(self): + return str(self) + + @staticmethod + def fromCharCode(*codes): + return String(from_units("".join(chr(uint32(code) % 2**16) for code in codes))) + + @staticmethod + def fromCodePoint(*codes): + values = [] + for code in codes: + number = to_number(code) + if not math.isfinite(number) or number != int(number) or not 0 <= number <= 0x10FFFF: + raise ValueError("Invalid code point") + values.append(chr(int(number))) + return String("".join(values)) diff --git a/src/JavaScript/_values.py b/src/JavaScript/_values.py index 8f1abd4..3b8ded2 100644 --- a/src/JavaScript/_values.py +++ b/src/JavaScript/_values.py @@ -141,7 +141,7 @@ def strictEqual(left, right) -> bool: if is_number(left) and is_number(right): return to_number(left) == to_number(right) if isinstance(left, str) and isinstance(right, str): - return str(left) == str(right) + return left.encode("utf-16-le", "surrogatepass") == right.encode("utf-16-le", "surrogatepass") return left is right diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..12a595a --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,26 @@ +import asyncio +import unittest + +import fetch_api + +from JavaScript import JSON, Array, FetchError, Headers, Promise, Request, Response, fetch + + +class CompanionTests(unittest.TestCase): + def test_fetch_and_promise_are_standalone_identities(self): + for value in (FetchError, Headers, Promise, Request, Response, fetch): + self.assertIs(value, getattr(fetch_api, value.__name__)) + + def test_runtime_transform_after_response_text(self): + async def run(): + response = Response('{"names": ["Ada", "Lin"]}') + value = JSON.parse(await response.text()) + self.assertEqual(list(value.names.map(lambda name: name.toUpperCase())), ["ADA", "LIN"]) + self.assertEqual(await Promise.resolve(value.names.length).then(lambda length: length + 1), 3) + asyncio.run(run()) + + def test_fetch_json_retains_standalone_contract(self): + value = Response('{"names": ["Ada"]}').json().result(timeout=2) + self.assertIsInstance(value, fetch_api.JSONObject) + self.assertIs(type(value.names), list) + self.assertFalse(Array.isArray(value.names)) diff --git a/tests/test_json.py b/tests/test_json.py new file mode 100644 index 0000000..a283b93 --- /dev/null +++ b/tests/test_json.py @@ -0,0 +1,62 @@ +import math +import unittest + +import JSON as standalone + +from JavaScript import Array, JSON, Number, Object, String, undefined + + +class JSONTests(unittest.TestCase): + def test_parse_wraps_runtime_values_and_rounds_binary64(self): + value = JSON.parse('{"items": [{"name": "Ada"}], "big": 9007199254740993, "null": null}') + self.assertIsInstance(value, Object) + self.assertIsInstance(value.items, Array) + self.assertIsInstance(value.items[0].name, String) + self.assertEqual(value.items[0].name.toUpperCase(), "ADA") + self.assertEqual(value.big, 2**53) + self.assertIsNone(value["null"]) + self.assertIs(value.absent, undefined) + self.assertIs(JSON.parse(True), True) + self.assertEqual(JSON.parse(12), 12) + self.assertEqual(standalone.parse("9007199254740993"), 2**53 + 1) + self.assertIs(type(standalone.parse("[]")), list) + self.assertEqual(math.copysign(1, JSON.parse('{"x": [-0]}').x[0]), -1) + + def test_parse_failure_and_no_byte_extension(self): + with self.assertRaises(JSON.JSONDecodeError): + JSON.parse("{") + with self.assertRaises(TypeError): + JSON.parse(b"{}") + + def test_undefined_functions_holes_and_non_finite_numbers(self): + value = Object({"missing": undefined, "fn": lambda: None, "null": None, "array": Array(1).concat(Array.of(undefined, math.nan, math.inf, -0.0))}) + self.assertEqual(JSON.stringify(value), '{"null":null,"array":[null,null,null,null,0]}') + self.assertIs(JSON.stringify(), undefined) + self.assertIs(JSON.stringify(lambda: None), undefined) + + def test_key_order_unicode_and_numeric_rendering(self): + value = Object({"b": "ι›ͺπŸ˜€", "2": 1.0, "1": "\ud800", "number": 1e-7}) + self.assertEqual(JSON.stringify(value), '{"1":"\\ud800","2":1,"b":"ι›ͺπŸ˜€","number":1e-7}') + self.assertEqual(JSON.stringify(1e20), "100000000000000000000") + self.assertEqual(JSON.stringify("\ud83d\ude00"), '"πŸ˜€"') + + def test_space_and_roundtrip(self): + value = Object({"x": Array.of(1, Object({"y": True}))}) + text = JSON.stringify(value, None, 2) + self.assertEqual(text, '{\n "x": [\n 1,\n {\n "y": true\n }\n ]\n}') + self.assertEqual(JSON.stringify(JSON.parse(text)), '{"x":[1,{"y":true}]}') + self.assertEqual(JSON.stringify(Object(), None, 100), "{}") + + def test_cycles_shared_values_and_unsupported_overloads(self): + value = Object() + value.self = value + with self.assertRaisesRegex(TypeError, "circular"): + JSON.stringify(value) + shared = Object({"x": 1}) + self.assertEqual(JSON.stringify(Array.of(shared, shared)), '[{"x":1},{"x":1}]') + with self.assertRaises(NotImplementedError): + JSON.stringify({}, lambda key, value: value) + with self.assertRaises(NotImplementedError): + JSON.stringify(Object({"toJSON": lambda: 1})) + with self.assertRaises(TypeError): + JSON.stringify(object()) diff --git a/tests/test_primitives.py b/tests/test_primitives.py new file mode 100644 index 0000000..d5fbb8a --- /dev/null +++ b/tests/test_primitives.py @@ -0,0 +1,124 @@ +import math +import unittest + +from JavaScript import Array, Boolean, Math, Number, String, isFinite, parseFloat, parseInt, strictEqual, undefined + + +class StringTests(unittest.TestCase): + def test_conversion_and_utf16_indexing(self): + value = String("aπŸ˜€b") + self.assertEqual(value.length, 4) + self.assertEqual(value[1], "\ud83d") + self.assertEqual(value.at(-2), "\ude00") + self.assertIs(value[-1], undefined) + self.assertIs(value["01"], undefined) + self.assertEqual(value.charAt(20), "") + self.assertTrue(math.isnan(value.charCodeAt(20))) + self.assertEqual(value.charCodeAt(1), 0xD83D) + self.assertEqual(value.codePointAt(1), 0x1F600) + self.assertEqual(value.codePointAt(2), 0xDE00) + self.assertIs(value.codePointAt(4), undefined) + self.assertEqual(list(value), ["a", "πŸ˜€", "b"]) + self.assertEqual(list(Array.from_(value)), ["a", "πŸ˜€", "b"]) + self.assertTrue(strictEqual("πŸ˜€", "\ud83d\ude00")) + self.assertEqual(String(), "") + self.assertEqual(String(undefined), "undefined") + self.assertEqual(String(None), "null") + + def test_search_and_substrings_use_code_units(self): + value = String("aπŸ˜€bπŸ˜€") + self.assertEqual(value.indexOf("πŸ˜€"), 1) + self.assertEqual(value.lastIndexOf("πŸ˜€"), 4) + self.assertEqual(value.lastIndexOf("πŸ˜€", 3), 1) + self.assertEqual(value.indexOf("", math.inf), 6) + self.assertTrue(value.startsWith("πŸ˜€", 1)) + self.assertTrue(value.endsWith("b", 4)) + self.assertTrue(value.includes("\ude00")) + self.assertEqual(value.slice(1, 3), "πŸ˜€") + self.assertEqual(value.slice(-2), "πŸ˜€") + self.assertEqual(value.substring(3, 1), "πŸ˜€") + self.assertEqual(value.substring(-5, 1), "a") + + def test_split_and_constructor_helpers(self): + self.assertEqual(list(String("πŸ˜€").split("")), ["\ud83d", "\ude00"]) + self.assertEqual(list(String("a,b,c").split(",", 2)), ["a", "b"]) + self.assertEqual(list(String("a,").split(",")), ["a", ""]) + self.assertEqual(list(String("").split("")), []) + self.assertEqual(list(String("ab").split()), ["ab"]) + self.assertEqual(list(String("ab").split("", 2**32)), []) + self.assertEqual(String.fromCharCode(0xD83D, 0xDE00), "πŸ˜€") + self.assertEqual(String.fromCodePoint(0x1F600), "πŸ˜€") + self.assertEqual(String.fromCharCode(65537), "\x01") + with self.assertRaises(ValueError): + String.fromCodePoint(1.5) + + def test_trim_case_concat_and_repeat(self): + self.assertEqual(String("\ufeff hi \n").trim(), "hi") + self.assertEqual(String("\x85hi\x85").trim(), "\x85hi\x85") + self.assertEqual(String("AbC").toLowerCase().toUpperCase(), "ABC") + self.assertEqual(String("x").concat(1, None, True), "x1nulltrue") + self.assertEqual(String("ab").repeat(2.8), "abab") + with self.assertRaises(ValueError): + String("a").repeat(-1) + + +class NumberTests(unittest.TestCase): + def test_conversion_and_noncoercing_predicates(self): + self.assertEqual(Number(), 0) + self.assertEqual(Number("0xff"), 255) + self.assertTrue(math.isnan(Number(undefined))) + self.assertEqual(Number(2**53 + 1), 2**53) + self.assertFalse(Number.isNaN("bad")) + self.assertTrue(Number.isNaN(math.nan)) + self.assertTrue(isFinite("1")) + self.assertFalse(Number.isFinite("1")) + self.assertFalse(Number.isInteger(True)) + self.assertTrue(Number.isInteger(3.0)) + self.assertFalse(Number.isSafeInteger(2**53)) + self.assertEqual(Number(1e-6).toString(), "0.000001") + self.assertEqual(Number.MIN_VALUE, 5e-324) + self.assertTrue(Boolean([])) + self.assertFalse(Boolean(math.nan)) + + def test_prefix_parsers(self): + for value, expected in [("12px", 12), ("0xff", 255), ("010", 10), ("1e3", 1), ("-0", -0.0)]: + self.assertEqual(parseInt(value), expected) + self.assertEqual(parseInt("ffz", 16), 255) + self.assertEqual(Number.parseInt("11", 2), 3) + self.assertTrue(math.isnan(parseInt("12", 1))) + self.assertTrue(math.isnan(parseInt("0x"))) + self.assertEqual(math.copysign(1, parseInt("-0")), -1) + self.assertEqual(parseFloat("-1.2e2px"), -120) + self.assertEqual(parseFloat("1e+"), 1) + self.assertEqual(parseFloat("0xff"), 0) + self.assertEqual(Number.parseFloat("Infinity!"), math.inf) + self.assertTrue(math.isnan(parseFloat("bad"))) + + +class MathTests(unittest.TestCase): + def test_rounding_and_negative_zero(self): + self.assertEqual(Math.round(2.5), 3) + self.assertEqual(Math.round(-1.5), -1) + self.assertEqual(Math.round(0.49999999999999994), 0) + for value in (Math.round(-0.5), Math.ceil(-0.2), Math.trunc(-0.2), Math.sign(-0.0), Math.sqrt(-0.0)): + self.assertEqual(math.copysign(1, value), -1) + self.assertEqual(Math.floor(-1.1), -2) + self.assertEqual(Math.ceil(math.inf), math.inf) + self.assertTrue(math.isnan(Math.round())) + + def test_extrema_domains_and_powers(self): + self.assertEqual(Math.min(), math.inf) + self.assertEqual(Math.max(), -math.inf) + self.assertEqual(math.copysign(1, Math.min(0, -0.0)), -1) + self.assertEqual(math.copysign(1, Math.max(-0.0, 0)), 1) + self.assertTrue(math.isnan(Math.max(1, math.nan))) + self.assertTrue(math.isnan(Math.sqrt(-1))) + self.assertEqual(Math.pow(math.nan, 0), 1) + self.assertTrue(math.isnan(Math.pow(1, math.nan))) + self.assertTrue(math.isnan(Math.pow(1, math.inf))) + self.assertEqual(Math.pow(-0.0, -3), -math.inf) + self.assertEqual(Math.pow(-2, 1025), -math.inf) + self.assertTrue(math.isnan(Math.pow(-1, 0.5))) + self.assertEqual(Math.abs("-2"), 2) + self.assertGreaterEqual(Math.random(), 0) + self.assertLess(Math.random(), 1) From e1a17fbb32969e868bf590d77498df8502cd8178 Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:54:17 -0700 Subject: [PATCH 4/5] test: verify conformance and prepare v0.1.0 release --- .github/workflows/ci.yaml | 11 ++- .github/workflows/release.yaml | 80 +++++++++++++++++++++ CHANGELOG.md | 10 +++ README.md | 100 +++++++++++++++++++++++++- RELEASING.md | 35 ++++++++++ docs/compatibility.md | 113 ++++++++++++++++++++++++++++++ pyproject.toml | 8 +++ scripts/install_distribution.py | 32 +++++++++ tests/fixtures/conformance.mjs | 97 ++++++++++++++++++++++++++ tests/test_conformance.py | 120 ++++++++++++++++++++++++++++++++ uv.lock | 36 ++++++++++ 11 files changed, 638 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 CHANGELOG.md create mode 100644 RELEASING.md create mode 100644 docs/compatibility.md create mode 100644 scripts/install_distribution.py create mode 100644 tests/fixtures/conformance.mjs create mode 100644 tests/test_conformance.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1d9ddce..fbda96e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,6 +24,11 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.19.0' + - name: Require Node conformance runner + run: node --version - name: Install build tools run: python -m pip install --upgrade build twine - name: Build sdist and wheel from sdist @@ -31,6 +36,10 @@ jobs: - name: Check distribution metadata run: python -m twine check --strict dist/* - name: Install wheel with published dependencies - run: python -m pip install --find-links dist Python-JavaScript==0.1.0 + run: python scripts/install_distribution.py wheel - name: Test installed wheel run: python -m unittest discover -s tests -v + - name: Install source distribution with published dependencies + run: python scripts/install_distribution.py sdist + - name: Test installed source distribution + run: python -m unittest discover -s tests -v diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..f6509e8 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,80 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: pypi-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.19.0' + - name: Require Node conformance runner + run: node --version + - name: Verify release tag matches package version + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + python - <<'PYTHON' + import os + import tomllib + from pathlib import Path + + project = tomllib.loads(Path("pyproject.toml").read_text())["project"] + expected = "v" + project["version"] + if os.environ["RELEASE_TAG"] != expected: + raise SystemExit(f"Release tag must be {expected}") + PYTHON + - name: Install build tools + run: python -m pip install --upgrade build twine + - name: Build sdist and wheel from the sdist + run: python -m build + - name: Check distribution metadata + run: python -m twine check --strict dist/* + - name: Install the wheel + run: python scripts/install_distribution.py wheel + - name: Test the installed wheel + run: python -m unittest discover -s tests -v + - name: Install and test source distribution + run: | + python scripts/install_distribution.py sdist + python -m unittest discover -s tests -v + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: distributions + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + url: https://pypi.org/project/Python-JavaScript/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: distributions + path: dist/ + - name: Publish to PyPI using Trusted Publishing + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aab8dd7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 0.1.0 + +- Introduce the case-sensitive `JavaScript` namespace with Object, sparse Array, + UTF-16 String methods, Number, Boolean, selected Math functions and JSON. +- Add undefined, truthiness, conversion, equality and parsing helpers. +- Depend on standalone JavaScript-Fetch-API 0.2 and JavaScript-JSON 0.1; expose + Fetch and Promise without duplicating their implementations. +- Document the supported compatibility subset and future translation direction. diff --git a/README.md b/README.md index b0c161a..769e2d7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,99 @@ # Python-JavaScript -JavaScript-style objects and standard-library methods in Python, using the -case-sensitive `JavaScript` namespace. Development is tracked in this -repository's issues and the organization Project. +JavaScript-style objects, methods and functions for Python 3.14+, using the +case-sensitive `JavaScript` namespace. An alpha compatibility layer with a +documented subset of ECMAScript behavior. + +```sh +python -m pip install Python-JavaScript==0.1.0 +``` + +```python +from JavaScript import Array, JSON, Math, Object, String, undefined + +people = JSON.parse('[{"name":"Ada","score":8},{"name":"Lin","score":6}]') +names = people.filter(lambda person: person.score >= 7).map( + lambda person: person.name.toUpperCase() +) +assert names.join(", ") == "ADA" + +profile = Object({"name": String("Ada"), "scores": Array.of(8, 6)}) +assert profile.missing is undefined +assert profile.scores.reduce(lambda total, score: total + score, 0) == 14 +assert Math.round(2.5) == 3 +assert JSON.stringify(profile) == '{"name":"Ada","scores":[8,6]}' +``` + +Explicit `Object`, `Array`, `String` and `Number` values expose JavaScript names +and behavior. Ordinary Python literals and operators retain Python semantics. +Objects and arrays compare by identity and remain truthy when empty. Use +`Boolean(value)` or `truthy(value)` when testing JavaScript truthiness of values +that might include Python containers. + +## Standalone packages + +Installation includes two independently useful dependencies: + +| Distribution | Import | Purpose | +| --- | --- | --- | +| `JavaScript-Fetch-API>=0.2.0,<0.3.0` | `fetch_api` | Standard-library HTTP Fetch and Promise APIs | +| `JavaScript-JSON>=0.1.0,<0.2.0` | `JSON` | Python-friendly JSON parsing, serialization and dot traversal | + +```python +import asyncio +from fetch_api import fetch +from JavaScript import JSON + +async def main(): + response = await fetch("https://example.com/api/people") + if not response.ok: + raise RuntimeError(f"HTTP {response.status}") + people = JSON.parse(await response.text()) + return people + +# Run against your own JSON endpoint: +# people = asyncio.run(main()) +``` + +`JavaScript.fetch`, `Promise`, `Headers`, `Request`, `Response` and `FetchError` +are the same objects exported by `fetch_api`. Fetch 0.2.0 changes the old +`from javascript import fetch` spelling to `from fetch_api import fetch`. + +`response.json()` retains Fetch's standalone `JSONObject`/list result. Use +`JavaScript.JSON.parse(await response.text())` for runtime Object/Array/String +values. Standalone `import JSON` preserves Python integers and containers; +`from JavaScript import JSON` uses binary64 numbers and JavaScript serialization +rules for its supported subset. + +## Supported surface + +Core data objects and sparse arrays; UTF-16 String methods; Number predicates and +prefix parsers; Boolean conversion; selected Math functions; JSON; and the +standalone Fetch/Promise APIs. See the complete +[compatibility matrix](docs/compatibility.md) for method lists and limits. + +Python keywords require `Array.from_`, `Object.is_`, and `Promise.finally_`. +`None` represents `null`; `undefined` is a separate singleton. Array bracket +indices follow JavaScript rules: `values[-1]` is a named property, and +`values.at(-1)` accesses the last element. `Array(3)` creates three holes; +`Array.of(3)` creates one element containing 3. + +The longer-term goal is a binding-aware Python-to-JavaScript translator. v0.1.0 +provides the runtime foundation; it does not translate source or execute +JavaScript. Prototypes, descriptors, Date, RegExp, Map/Set, typed arrays, browser +globals and other unlisted APIs remain future work. Development is tracked in +the [issues](https://github.com/outside-labs/Python-JavaScript/issues) and +[Project](https://github.com/orgs/outside-labs/projects/11). + +## Development + +```sh +uv sync +uv run python -m unittest discover -s tests -v +uv build +``` + +Node.js 24 runs the native ECMAScript differential fixture. That test is skipped +when Node is absent locally; CI and release builds require it. Runtime code +needs only Python and the two declared companions. Tests make no external +network requests. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..baf5b52 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,35 @@ +# Publishing Python-JavaScript + +The distribution name is `Python-JavaScript`; the Python import is `JavaScript`. +Requires Python 3.14 or later. + +## Trusted Publisher + +The PyPI publisher must match these values exactly: + +| Field | Value | +| --- | --- | +| PyPI project | `Python-JavaScript` | +| GitHub owner | `outside-labs` | +| Repository | `Python-JavaScript` | +| Workflow | `release.yaml` | +| Environment | `pypi` | + +The GitHub environment is named `pypi`. Publishing uses OpenID Connect; no API +token belongs in the repository or workflow. + +## Release + +1. Merge the passing task PRs and confirm final main CI on all three platforms. +2. Confirm both declared companions are available on PyPI. Verify clean wheel + and sdist installs and review the packaged file list. +3. Set the package version and matching changelog. Create and publish the GitHub + release with tag `v` targeting the verified main commit. +4. Monitor Release: tag/version validation, source/wheel build, strict metadata, + installed-wheel and installed-sdist tests, then publication via `pypi`. +5. Verify a new environment can install `Python-JavaScript==` from PyPI, + run the examples/tests, and compare published SHA256 hashes with build artifacts. + +Publishing a GitHub release triggers upload; a tag push alone does not. PyPI +versions are immutable. Repository visibility stays private; published artifacts +include package source. No open-source license has been selected for this release. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..99c5cba --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,113 @@ +# v0.1.0 compatibility + +This is a selected ECMAScript-compatible runtime API implemented in Python. +Method names and the tested behaviors below follow the +[ECMAScript specification](https://tc39.es/ecma262/). It is not a complete language +implementation. The native Node.js fixture checks the adopted subset, rather +than asserting general Test262 conformance. + +## Supported API + +| Surface | Implemented members | +| --- | --- | +| Values | `undefined`, `null`, `NaN`, `Infinity`, `typeof`, `truthy`, `strictEqual`, global `isNaN`, global `isFinite` | +| `Object` | Mapping/empty construction, dot/bracket data properties, deletion, missing `undefined`, `keys`, `values`, `entries`, `assign`, `fromEntries`, `hasOwn`, `is_`, `toString` | +| `Array` | Numeric-length/element construction, `of`, iterable `from_`, `isArray`, `length`, bracket access/deletion, `at`, `push`, `pop`, `shift`, `unshift`, `slice`, `splice`, `concat`, `join`, `toString`, `includes`, `indexOf`, `lastIndexOf` | +| Array callbacks | `map`, `filter`, `forEach`, `some`, `every`, `find`, `findIndex`, `findLast`, `findLastIndex`, `reduce`, `reduceRight` | +| Array mutation/iteration | `reverse`, `fill`, `sort`, `keys`, `values`, `entries`, Python iteration | +| `String` | Conversion, `length`, bracket code units, `at`, `charAt`, `charCodeAt`, `codePointAt`, `slice`, `substring`, `indexOf`, `lastIndexOf`, `includes`, `startsWith`, `endsWith`, `split`, `concat`, `repeat`, `trim`, `trimStart`, `trimEnd`, `toLowerCase`, `toUpperCase`, `toString`, `valueOf`, `fromCharCode`, `fromCodePoint` | +| `Number` | Conversion, `isNaN`, `isFinite`, `isInteger`, `isSafeInteger`, `parseInt`, `parseFloat`, decimal `toString`, `valueOf`, `EPSILON`, `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `NaN`, `POSITIVE_INFINITY`, `NEGATIVE_INFINITY` | +| `Boolean` | Primitive conversion returning a Python bool | +| `Math` | `abs`, `floor`, `ceil`, `trunc`, `round`, `sign`, `min`, `max`, `sqrt`, `pow`, `random`; `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT1_2`, `SQRT2` | +| `JSON` | `parse(text)`, `stringify(value, replacer=undefined, space=undefined)`, `JSONDecodeError` | +| Global parsing | `parseInt`, `parseFloat` | +| Companions | `fetch`, `FetchError`, `Headers`, `Request`, `Response`, `Promise`, re-exported unchanged from `fetch_api` | + +## Data model and Python boundaries + +- `Object` stores own enumerable writable data properties. Keys convert to + strings. Array-index keys enumerate numerically before other keys in insertion + order. Deleting and re-adding a non-index key moves it to the end. +- `Object()`/`Object(None)` create empty objects; `Object(existing)` returns the + existing Object. Other construction accepts a Python mapping. Primitive + boxing and prototype/descriptor operations are unsupported. +- Objects preserve supplied child values; passing a nested Python dict/list + does not recursively convert it. JSON parsing creates runtime containers. +- Own Object keys shadow ordinary Python attributes. Use `Object.keys(obj)` + for static methods and brackets for special names such as `__class__`. + Missing ordinary attributes return `undefined`; Python `hasattr` therefore + cannot test property presence. Use `Object.hasOwn` or `key in obj`. +- Arrays store holes separately from explicit undefined values. Length changes + truncate or expand sparsely. Callback methods skip holes where ECMAScript does; + find methods and includes observe holes as undefined. `Object.hasOwn(array, i)` + distinguishes them. `length` is an own, non-enumerable property. +- Use bracket notation for named Array properties; arbitrary dot property + assignment and overriding Array methods are outside this subset. Python `in` + tests property presence, matching JavaScript `in`; use `includes` for values. +- `Array.from_` accepts Python iterables, including runtime arrays and strings. + JavaScript array-like objects, custom iterators/Symbol protocols, `thisArg`, + subclass species and `Symbol.isConcatSpreadable` are not implemented. `concat` + spreads runtime Array values; ordinary Python lists remain single elements. +- Callbacks receive JavaScript's arguments, truncated to their inspectable Python + positional arity. Zero-argument and variadic callbacks work. Uninspectable + callables receive all arguments; wrap them in a lambda if needed. Keyword-only + callback parameters and JavaScript `this` binding are unsupported. Exceptions + propagate once without retrying the callback. +- `sort` is stable and uses UTF-16 lexicographic order by default. Undefined + elements follow defined elements; holes follow undefined. Comparators may + return a number; mutating the sorted array from its comparator is unsupported. +- String methods and brackets index UTF-16 code units. String iteration visits + code points. Python `len(string)` and inherited str methods/operators retain + Python semantics; use `.length` for JavaScript length. Case conversion uses + Python's Unicode tables, which can differ from a JavaScript engine's version. +- `Number` is a float subclass with binary64 conversion. Plain Python integers, + arithmetic, division, modulo, comparisons, `and`/`or`, `==`, container literals + and inherited methods retain Python behavior. Use explicit helpers for + JavaScript semantics. No operator rewriting or automatic primitive boxing is + provided. `Boolean` does not create boxed Boolean objects. +- Decimal number strings use Python's shortest float representation adjusted to + JavaScript exponent/fixed-format thresholds. Tested ordinary values agree; + rare shortest-decimal tie choices may differ between Python and ECMAScript. + Other number radices and `toFixed`/`toPrecision` are not implemented. +- Errors use Python `TypeError`, `ValueError` and `JSONDecodeError`; no full + ECMAScript Error hierarchy or matching message text is promised. Invalid array + lengths, repeat counts and code points raise ValueError. Invalid JSON numeric + constants raise ValueError. Unsupported overloads raise TypeError or + NotImplementedError. + +## JSON boundaries + +`JavaScript.JSON.parse` creates Object, Array and String values, and binary64 +Python floats for numbers. Negative zero and integer rounding follow the number +model. It uses the standard-library decoder's numeric hooks because standalone +`JSON.parse` intentionally preserves Python integers. The companion's serializer +provides string escaping; its public decoder exception type is shared. + +`stringify` emits compact JSON by default, orders object keys, writes non-finite +numbers as null, omits undefined/callable object properties, and writes them as +null in arrays. Top-level undefined/callables return undefined. Shared references +are allowed; cycles raise TypeError. A numeric/string `space` is capped at ten +characters. Lone surrogates are escaped. Plain Python mappings/lists/tuples are +accepted as a convenience. + +Revivers, replacer functions/property lists, `toJSON` hooks, symbols and BigInt +are unsupported. A non-null replacer or callable own toJSON hook raises +NotImplementedError. Byte input is a standalone JSON extension and is rejected +by the runtime facade. The facade does not change standalone `import JSON` or +Fetch's `response.json()` result types. + +## Fetch and Promise boundaries + +The [standalone Fetch documentation](https://github.com/outside-labs/JavaScript-Fetch-API) +is the complete contract. Core HTTP(S) requests, buffered bodies, headers, +redirects, promises and await work. Browser policies, streaming, abort, cookies +and browser globals are not provided. Promise callbacks use a background Python +thread, not JavaScript microtask scheduling. Rejections use Python exceptions; +handlers return values or Promise objects, rather than raw Python coroutines. + +## Future translation + +The runtime deliberately keeps recognizable call shapes, but a Python program +using it is not automatically safe to translate. The future translator must +resolve bindings and validate an approved Python AST subset. It must diagnose +unsupported operators and Python-only constructs before emitting JavaScript. diff --git a/pyproject.toml b/pyproject.toml index 4bbf0ae..8361f5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,3 +30,11 @@ build-backend = "uv_build" [tool.uv.build-backend] module-name = "JavaScript" +source-include = [ + "tests/test_*.py", + "tests/fixtures/conformance.mjs", + "docs/compatibility.md", + "scripts/install_distribution.py", + "CHANGELOG.md", + "RELEASING.md", +] diff --git a/scripts/install_distribution.py b/scripts/install_distribution.py new file mode 100644 index 0000000..5762935 --- /dev/null +++ b/scripts/install_distribution.py @@ -0,0 +1,32 @@ +"""Install exactly the locally built artifact, including its PyPI dependencies.""" + +from pathlib import Path +import subprocess +import sys +import tomllib + + +def main(): + suffix = "*.whl" if sys.argv[1:] == ["wheel"] else "*.tar.gz" if sys.argv[1:] == ["sdist"] else None + if suffix is None: + raise SystemExit("Usage: install_distribution.py wheel|sdist") + artifacts = list(Path("dist").glob(suffix)) + if len(artifacts) != 1: + raise SystemExit(f"Expected one {suffix} artifact, found {len(artifacts)}") + version = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] + subprocess.run([sys.executable, "-m", "pip", "install", "--force-reinstall", str(artifacts[0])], check=True) + subprocess.run([sys.executable, "-m", "pip", "check"], check=True) + # New interpreter: imports must resolve from the installed artifact, not src. + subprocess.run([ + sys.executable, "-c", + "from importlib.metadata import version; import sys; import JavaScript, fetch_api, JSON; " + "assert version('Python-JavaScript') == sys.argv[1]; " + "assert 'site-packages' in JavaScript.__file__; " + "assert JavaScript.fetch is fetch_api.fetch; " + "print('Installed namespace:', JavaScript.__file__)", + version, + ], check=True) + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/conformance.mjs b/tests/fixtures/conformance.mjs new file mode 100644 index 0000000..8e3b650 --- /dev/null +++ b/tests/fixtures/conformance.mjs @@ -0,0 +1,97 @@ +// Native ECMAScript oracle for the explicitly supported runtime subset. +function encode(value) { + if (value === undefined) return {type: "undefined"}; + if (value === null) return {type: "null"}; + if (typeof value === "number") { + return {type: "number", value: Number.isNaN(value) ? "NaN" : + value === Infinity ? "Infinity" : value === -Infinity ? "-Infinity" : + Object.is(value, -0) ? "-0" : value}; + } + if (typeof value === "string" || typeof value === "boolean") { + return {type: typeof value, value}; + } + if (Array.isArray(value)) { + return {type: "array", length: value.length, + items: Object.keys(value).map(key => [key, encode(value[key])])}; + } + return {type: "object", entries: Object.entries(value).map(([key, child]) => [key, encode(child)])}; +} + +const cases = {}; +const values = [undefined, null, false, true, 0, -0, NaN, Infinity, -Infinity, + "", "0", "false", " \ufeff42\n", "0xff", "-0x1", "0b11", "0o10", ".5e2", "1_000", + "inf", "\x851", "οΌ‘οΌ’", [], [1], [1, 2], {}]; +cases.conversion = values.map(value => [Boolean(value), Number(value), String(value), typeof value]); +cases.equality = [1 === true, NaN === NaN, 0 === -0, Object.is(0, -0), + Object.is(NaN, NaN), {} === {}, "πŸ˜€" === "\ud83d\ude00"]; +const obj = {b: 1, 10: 2, 2: 3, "01": 4, 0: 5, "4294967295": 6}; +cases.objectOrder = Object.entries(obj); +delete obj.b; obj.b = 7; +cases.objectReinsert = Object.keys(obj); +cases.objectCopy = Object.assign({}, {a: 1}, null, undefined, {a: 2, b: 3}); +cases.fromEntries = Object.fromEntries([["a", 1], ["a", 2], ["b"]]); + +const sparse = Array(4); sparse[1] = undefined; sparse[3] = 4; +cases.sparse = sparse; +cases.sparseMap = sparse.map((value, index) => [value, index]); +cases.sparseFilter = sparse.filter(() => true); +cases.sparseFind = [sparse.findIndex(value => value === undefined), + sparse.findLastIndex(value => value === undefined)]; +cases.sparseSearch = [sparse.includes(undefined), sparse.indexOf(undefined), + sparse.lastIndexOf(undefined), sparse.lastIndexOf(undefined, undefined)]; +cases.sparseSlice = sparse.slice(0, -1); +const spliced = sparse.slice(); +cases.sparseSpliceRemoved = spliced.splice(1, 2, "x", "y", "z"); +cases.sparseSpliceResult = spliced; +cases.sparseReverse = sparse.slice().reverse(); +cases.sparseSort = Array(1).concat([10, 2, undefined, 1]).sort(); +cases.numericSort = [10, 2, 1].sort((a, b) => a - b); +cases.arraySearch = [NaN, true, 1, -0, undefined].map(value => + [1, true, NaN, 0, undefined].indexOf(value)); +cases.arrayIncludes = [NaN, true, 1, -0, undefined].map(value => + [1, true, NaN, 0].includes(value)); +const mutated = [1, 2, 3], seen = []; +cases.callbackMutation = mutated.map((value, index, array) => { + seen.push(value); + if (index === 0) { array[1] = 9; delete array[2]; array.push(4); } + return value; +}); +cases.callbackSeen = seen; +cases.reduce = [sparse.reduce((total, value) => total + (value || 0), 0), + [2, 3].reduceRight((a, b) => a - b)]; +cases.fromSparse = Array.from(Array(2)); +cases.join = [1, null, undefined, [2, 3]].join("|"); +cases.arrayPredicates = [[].every(() => false), [].some(() => true), + [1].some(() => []), Array.isArray([]), Array.isArray({})]; + +const text = "aπŸ˜€bπŸ˜€"; +cases.stringIndex = [text.length, text[1], text[-1], text.at(-1), + text.charAt(20), text.charCodeAt(20), text.charCodeAt(1), text.codePointAt(1), text.codePointAt(2)]; +cases.stringSearch = [text.indexOf("πŸ˜€"), text.lastIndexOf("πŸ˜€"), text.lastIndexOf("πŸ˜€", 3), + text.indexOf("", Infinity), text.startsWith("πŸ˜€", 1), text.endsWith("b", 4), text.includes("\ude00")]; +cases.stringSlice = [text.slice(1, 3), text.slice(-2), text.substring(3, 1), text.substring(-5, 1)]; +cases.stringSplit = ["πŸ˜€".split(""), "a,b,c".split(",", 2), "a,".split(","), "".split(""), "ab".split(), "ab".split("", 2**32)]; +cases.stringConstructors = [String.fromCharCode(0xD83D, 0xDE00), String.fromCharCode(65537), String.fromCodePoint(0x1F600), Array.from(text)]; +cases.stringOther = ["\ufeff hi \n".trim(), "\x85hi\x85".trim(), "Straße".toUpperCase(), "AbC".toLowerCase(), "ab".repeat(2.8)]; +cases.stringBoundary = ["abc".lastIndexOf("", 0), "abc".lastIndexOf("", -1), "abc".lastIndexOf("", NaN), "abc".substring(Infinity, -Infinity)]; + +const nums = [undefined, null, true, "1", NaN, Infinity, -0, 3.5, 3, 2**53 - 1, 2**53]; +cases.numberPredicates = nums.map(value => [Number.isNaN(value), Number.isFinite(value), Number.isInteger(value), Number.isSafeInteger(value)]); +cases.parseInt = ["12px", "0xff", "010", "1e3", "-0", "bad", "0x"].map(value => parseInt(value)); +cases.parseRadix = [["ffz", 16], ["11", 2], ["12", 1], ["z", 36], ["0b11", 0]].map(([text, radix]) => parseInt(text, radix)); +cases.parseFloat = ["-1.2e2px", "1e+", "0xff", "Infinity!", "bad"].map(value => parseFloat(value)); +const rounding = [-Infinity, -2.5, -1.5, -0.5, -0.2, -0, 0, 0.49999999999999994, 0.5, 1.5, Infinity, NaN]; +cases.mathRounding = rounding.map(value => [Math.floor(value), Math.ceil(value), Math.trunc(value), Math.round(value), Math.sign(value)]); +cases.mathExtrema = [Math.min(), Math.max(), Math.min(0, -0), Math.max(-0, 0), Math.min(NaN, 1), Math.sqrt(-1), Math.sqrt(-0)]; +const powers = [[NaN, 0], [1, NaN], [1, Infinity], [-1, Infinity], [-0, -3], [-2, 1025], [-1, .5], [-Infinity, 3], [-Infinity, -3], [0, -Infinity]]; +cases.mathPow = powers.map(([base, exponent]) => Math.pow(base, exponent)); +cases.numberString = [1e-7, 1e-6, 1e20, 1e21, 5e-324, -0, 9007199254740993].map(value => String(value)); + +cases.jsonParse = JSON.parse('{"items":[{"name":"Ada"}],"big":9007199254740993,"zero":-0}'); +cases.jsonOmit = JSON.stringify({missing: undefined, fn: () => null, null: null, + array: Array(1).concat([undefined, NaN, Infinity, -0])}); +cases.jsonUnicode = JSON.stringify({b: "ι›ͺπŸ˜€", 2: 1, 1: "\ud800", number: 1e-7}); +cases.jsonSpace = JSON.stringify({x: [1, {y: true}]}, null, 2); +cases.jsonTop = [JSON.stringify(undefined), JSON.stringify(NaN), JSON.stringify(-0), JSON.stringify("\ud83d\ude00")]; + +process.stdout.write(JSON.stringify(encode(cases))); diff --git a/tests/test_conformance.py b/tests/test_conformance.py new file mode 100644 index 0000000..00098df --- /dev/null +++ b/tests/test_conformance.py @@ -0,0 +1,120 @@ +"""Compare independent native-JavaScript fixtures with Python observations.""" + +import json +import math +from pathlib import Path +import shutil +import subprocess +import unittest + +from JavaScript import Array, Boolean, JSON, Math, Number, Object, String, parseFloat, parseInt, strictEqual, typeof, undefined + + +def encode(value): + if value is undefined: + return {"type": "undefined"} + if value is None: + return {"type": "null"} + if isinstance(value, bool): + return {"type": "boolean", "value": value} + if isinstance(value, (int, float)): + if math.isnan(value): + value = "NaN" + elif math.isinf(value): + value = "Infinity" if value > 0 else "-Infinity" + elif value == 0 and math.copysign(1, value) < 0: + value = "-0" + return {"type": "number", "value": value} + if isinstance(value, str): + # Canonicalize equivalent Python astral and explicit surrogate sequences. + value = value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass") + return {"type": "string", "value": value} + if isinstance(value, (Array, list, tuple)): + entries = Object.entries(value) if isinstance(value, Array) else enumerate(value) + return {"type": "array", "length": len(value), "items": [[str(key), encode(child)] for key, child in entries]} + entries = Object.entries(value) if isinstance(value, Object) else value.items() + return {"type": "object", "entries": [[key, encode(child)] for key, child in entries]} + + +def observations(): + cases = {} + values = [undefined, None, False, True, 0, -0.0, math.nan, math.inf, -math.inf, + "", "0", "false", " \ufeff42\n", "0xff", "-0x1", "0b11", "0o10", ".5e2", "1_000", + "inf", "\x851", "οΌ‘οΌ’", Array(), Array.of(1), Array.of(1, 2), Object()] + cases["conversion"] = [[Boolean(value), Number(value), String(value), typeof(value)] for value in values] + cases["equality"] = [strictEqual(1, True), strictEqual(math.nan, math.nan), strictEqual(0, -0.0), Object.is_(0, -0.0), Object.is_(math.nan, math.nan), strictEqual(Object(), Object()), strictEqual("πŸ˜€", "\ud83d\ude00")] + obj = Object({"b": 1, "10": 2, "2": 3, "01": 4, "0": 5, "4294967295": 6}) + cases["objectOrder"] = Object.entries(obj) + del obj.b + obj.b = 7 + cases["objectReinsert"] = Object.keys(obj) + cases["objectCopy"] = Object.assign(Object(), {"a": 1}, None, undefined, {"a": 2, "b": 3}) + cases["fromEntries"] = Object.fromEntries([["a", 1], ["a", 2], ["b"]]) + sparse = Array(4) + sparse[1], sparse[3] = undefined, 4 + cases["sparse"] = sparse + cases["sparseMap"] = sparse.map(lambda value, index: Array.of(value, index)) + cases["sparseFilter"] = sparse.filter(lambda: True) + cases["sparseFind"] = [sparse.findIndex(lambda value: value is undefined), sparse.findLastIndex(lambda value: value is undefined)] + cases["sparseSearch"] = [sparse.includes(undefined), sparse.indexOf(undefined), sparse.lastIndexOf(undefined), sparse.lastIndexOf(undefined, undefined)] + cases["sparseSlice"] = sparse.slice(0, -1) + spliced = sparse.slice() + cases["sparseSpliceRemoved"] = spliced.splice(1, 2, "x", "y", "z") + cases["sparseSpliceResult"] = spliced + cases["sparseReverse"] = sparse.slice().reverse() + cases["sparseSort"] = Array(1).concat(Array.of(10, 2, undefined, 1)).sort() + cases["numericSort"] = Array.of(10, 2, 1).sort(lambda a, b: a - b) + cases["arraySearch"] = [Array.of(1, True, math.nan, 0, undefined).indexOf(value) for value in [math.nan, True, 1, -0.0, undefined]] + cases["arrayIncludes"] = [Array.of(1, True, math.nan, 0).includes(value) for value in [math.nan, True, 1, -0.0, undefined]] + mutated, seen = Array.of(1, 2, 3), [] + def visit(value, index, array): + seen.append(value) + if index == 0: + array[1] = 9 + del array[2] + array.push(4) + return value + cases["callbackMutation"] = mutated.map(visit) + cases["callbackSeen"] = seen + cases["reduce"] = [sparse.reduce(lambda total, value: total + (value if Boolean(value) else 0), 0), Array.of(2, 3).reduceRight(lambda a, b: a - b)] + cases["fromSparse"] = Array.from_(Array(2)) + cases["join"] = Array.of(1, None, undefined, Array.of(2, 3)).join("|") + cases["arrayPredicates"] = [Array().every(lambda: False), Array().some(lambda: True), Array.of(1).some(lambda: Array()), Array.isArray(Array()), Array.isArray(Object())] + text = String("aπŸ˜€bπŸ˜€") + cases["stringIndex"] = [text.length, text[1], text[-1], text.at(-1), text.charAt(20), text.charCodeAt(20), text.charCodeAt(1), text.codePointAt(1), text.codePointAt(2)] + cases["stringSearch"] = [text.indexOf("πŸ˜€"), text.lastIndexOf("πŸ˜€"), text.lastIndexOf("πŸ˜€", 3), text.indexOf("", math.inf), text.startsWith("πŸ˜€", 1), text.endsWith("b", 4), text.includes("\ude00")] + cases["stringSlice"] = [text.slice(1, 3), text.slice(-2), text.substring(3, 1), text.substring(-5, 1)] + cases["stringSplit"] = [String("πŸ˜€").split(""), String("a,b,c").split(",", 2), String("a,").split(","), String("").split(""), String("ab").split(), String("ab").split("", 2**32)] + cases["stringConstructors"] = [String.fromCharCode(0xD83D, 0xDE00), String.fromCharCode(65537), String.fromCodePoint(0x1F600), Array.from_(text)] + cases["stringOther"] = [String("\ufeff hi \n").trim(), String("\x85hi\x85").trim(), String("Straße").toUpperCase(), String("AbC").toLowerCase(), String("ab").repeat(2.8)] + cases["stringBoundary"] = [String("abc").lastIndexOf("", 0), String("abc").lastIndexOf("", -1), String("abc").lastIndexOf("", math.nan), String("abc").substring(math.inf, -math.inf)] + nums = [undefined, None, True, "1", math.nan, math.inf, -0.0, 3.5, 3, 2**53 - 1, 2**53] + cases["numberPredicates"] = [[Number.isNaN(value), Number.isFinite(value), Number.isInteger(value), Number.isSafeInteger(value)] for value in nums] + cases["parseInt"] = [parseInt(value) for value in ["12px", "0xff", "010", "1e3", "-0", "bad", "0x"]] + cases["parseRadix"] = [parseInt(text, radix) for text, radix in [("ffz", 16), ("11", 2), ("12", 1), ("z", 36), ("0b11", 0)]] + cases["parseFloat"] = [parseFloat(value) for value in ["-1.2e2px", "1e+", "0xff", "Infinity!", "bad"]] + rounding = [-math.inf, -2.5, -1.5, -0.5, -0.2, -0.0, 0, 0.49999999999999994, 0.5, 1.5, math.inf, math.nan] + cases["mathRounding"] = [[Math.floor(value), Math.ceil(value), Math.trunc(value), Math.round(value), Math.sign(value)] for value in rounding] + cases["mathExtrema"] = [Math.min(), Math.max(), Math.min(0, -0.0), Math.max(-0.0, 0), Math.min(math.nan, 1), Math.sqrt(-1), Math.sqrt(-0.0)] + powers = [(math.nan, 0), (1, math.nan), (1, math.inf), (-1, math.inf), (-0.0, -3), (-2, 1025), (-1, .5), (-math.inf, 3), (-math.inf, -3), (0, -math.inf)] + cases["mathPow"] = [Math.pow(base, exponent) for base, exponent in powers] + cases["numberString"] = [String(value) for value in [1e-7, 1e-6, 1e20, 1e21, 5e-324, -0.0, 9007199254740993]] + cases["jsonParse"] = JSON.parse('{"items":[{"name":"Ada"}],"big":9007199254740993,"zero":-0}') + cases["jsonOmit"] = JSON.stringify(Object({"missing": undefined, "fn": lambda: None, "null": None, "array": Array(1).concat(Array.of(undefined, math.nan, math.inf, -0.0))})) + cases["jsonUnicode"] = JSON.stringify(Object({"b": "ι›ͺπŸ˜€", "2": 1, "1": "\ud800", "number": 1e-7})) + cases["jsonSpace"] = JSON.stringify(Object({"x": Array.of(1, Object({"y": True}))}), None, 2) + cases["jsonTop"] = [JSON.stringify(undefined), JSON.stringify(math.nan), JSON.stringify(-0.0), JSON.stringify("\ud83d\ude00")] + return cases + + +@unittest.skipUnless(shutil.which("node"), "Node.js is required for differential conformance") +class ConformanceTests(unittest.TestCase): + def test_native_javascript_observations(self): + fixture = Path(__file__).parent / "fixtures" / "conformance.mjs" + result = subprocess.run(["node", str(fixture)], capture_output=True, text=True, encoding="utf-8", timeout=30, check=True) + expected = dict(json.loads(result.stdout)["entries"]) + actual = observations() + self.assertEqual(set(actual), set(expected)) + for name, value in actual.items(): + with self.subTest(case=name): + self.assertEqual(encode(value), expected[name]) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..318649e --- /dev/null +++ b/uv.lock @@ -0,0 +1,36 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "javascript-fetch-api" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/48/10ff71084c91abe71951a1ec2d5e0609828f93dd2b715da1ce969181b71a/javascript_fetch_api-0.2.0.tar.gz", hash = "sha256:3af21b723fd570d94beb91c9e1080fa50fed6c3fdc4c9cee3c85dca04fc93d76", size = 11907, upload-time = "2026-10-16T19:45:57.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/50/6255cbb902d7b4ec20c42e7a2bb13c81de87bd0572ac44abb286dd6638c1/javascript_fetch_api-0.2.0-py3-none-any.whl", hash = "sha256:b8a4965645b37928bb3348706994d22de72d0599ed81e9ed3baf1c519c6f60d1", size = 13654, upload-time = "2026-10-16T19:45:55.663Z" }, +] + +[[package]] +name = "javascript-json" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/92/d542ba9ae59528359389ad2afcc5dbd3283755ed9ffc95e7c173a98f74b9/javascript_json-0.1.0.tar.gz", hash = "sha256:19ce727971b44f39fd2a2a59b18ea774fb1c4f7421599964ad42a4aedceb4d4b", size = 4173, upload-time = "2026-10-16T19:08:54.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/a6/7c057df3ddd6799e7f6381da326917903ba52babb972a109702e1077b4b6/javascript_json-0.1.0-py3-none-any.whl", hash = "sha256:e119be47ba76924bb1757af6a55c0b7cd53e0d08b55f8157fc0081fc65052bef", size = 5047, upload-time = "2026-10-16T19:08:53.384Z" }, +] + +[[package]] +name = "python-javascript" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "javascript-fetch-api" }, + { name = "javascript-json" }, +] + +[package.metadata] +requires-dist = [ + { name = "javascript-fetch-api", specifier = ">=0.2.0,<0.3.0" }, + { name = "javascript-json", specifier = ">=0.1.0,<0.2.0" }, +] From 2d0ac9e4074f08687186f7862de96de17a624433 Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:57:42 -0700 Subject: [PATCH 5/5] docs: clarify primitive conversion boundaries --- docs/compatibility.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/compatibility.md b/docs/compatibility.md index 99c5cba..99235a2 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -31,6 +31,9 @@ than asserting general Test262 conformance. - `Object()`/`Object(None)` create empty objects; `Object(existing)` returns the existing Object. Other construction accepts a Python mapping. Primitive boxing and prototype/descriptor operations are unsupported. +- Object-to-primitive conversion uses the built-in data-object representation. + Custom `valueOf`/`toString` conversion hooks and Symbol.toPrimitive are not + implemented. - Objects preserve supplied child values; passing a nested Python dict/list does not recursively convert it. JSON parsing creates runtime containers. - Own Object keys shadow ordinary Python attributes. Use `Object.keys(obj)` @@ -65,6 +68,9 @@ than asserting general Test262 conformance. and inherited methods retain Python behavior. Use explicit helpers for JavaScript semantics. No operator rewriting or automatic primitive boxing is provided. `Boolean` does not create boxed Boolean objects. +- Methods can return native Python primitives, including Array `join` and Math + results. Wrap such results with `String` or `Number` to call their JavaScript + methods; Python does not automatically box primitive values. - Decimal number strings use Python's shortest float representation adjusted to JavaScript exponent/fixed-format thresholds. Tested ordinary values agree; rare shortest-decimal tie choices may differ between Python and ECMAScript.