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] 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)