Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 18 additions & 0 deletions src/JavaScript/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
195 changes: 195 additions & 0 deletions src/JavaScript/_values.py
Original file line number Diff line number Diff line change
@@ -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])
Empty file added src/JavaScript/py.typed
Empty file.
67 changes: 67 additions & 0 deletions tests/test_values.py
Original file line number Diff line number Diff line change
@@ -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)
Loading