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/2] 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", "12", [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/2] 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))