Skip to content

Commit 1efe84f

Browse files
authored
feat: establish JavaScript values and packaging (#16)
1 parent 414a18e commit 1efe84f

6 files changed

Lines changed: 348 additions & 0 deletions

File tree

‎.github/workflows/ci.yaml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
os: [ubuntu-latest, macos-latest, windows-latest]
18+
runs-on: ${{ matrix.os }}
19+
timeout-minutes: 15
20+
steps:
21+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22+
with:
23+
persist-credentials: false
24+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
25+
with:
26+
python-version: '3.14'
27+
- name: Install build tools
28+
run: python -m pip install --upgrade build twine
29+
- name: Build sdist and wheel from sdist
30+
run: python -m build
31+
- name: Check distribution metadata
32+
run: python -m twine check --strict dist/*
33+
- name: Install wheel with published dependencies
34+
run: python -m pip install --find-links dist Python-JavaScript==0.1.0
35+
- name: Test installed wheel
36+
run: python -m unittest discover -s tests -v

‎pyproject.toml‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
[project]
2+
name = "Python-JavaScript"
3+
version = "0.1.0"
4+
description = "JavaScript-style objects, methods and functions for Python"
5+
readme = "README.md"
6+
authors = [
7+
{ name = "ellainng", email = "299958882+ellainng@users.noreply.github.com" }
8+
]
9+
requires-python = ">=3.14"
10+
dependencies = [
11+
"JavaScript-Fetch-API>=0.2.0,<0.3.0",
12+
"JavaScript-JSON>=0.1.0,<0.2.0",
13+
]
14+
classifiers = [
15+
"Development Status :: 3 - Alpha",
16+
"Intended Audience :: Developers",
17+
"Programming Language :: Python :: 3",
18+
"Programming Language :: Python :: 3.14",
19+
]
20+
keywords = ["javascript", "ecmascript", "objects", "arrays"]
21+
22+
[project.urls]
23+
Homepage = "https://github.com/outside-labs/Python-JavaScript"
24+
Repository = "https://github.com/outside-labs/Python-JavaScript"
25+
Issues = "https://github.com/outside-labs/Python-JavaScript/issues"
26+
27+
[build-system]
28+
requires = ["uv_build>=0.12.19,<0.13.0"]
29+
build-backend = "uv_build"
30+
31+
[tool.uv.build-backend]
32+
module-name = "JavaScript"

‎src/JavaScript/__init__.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""JavaScript-style objects, methods and functions for Python."""
2+
3+
from ._values import (
4+
Infinity,
5+
NaN,
6+
isFinite,
7+
isNaN,
8+
null,
9+
strictEqual,
10+
truthy,
11+
typeof,
12+
undefined,
13+
)
14+
15+
__all__ = [
16+
"Infinity", "NaN", "isFinite", "isNaN", "null", "strictEqual",
17+
"truthy", "typeof", "undefined",
18+
]

‎src/JavaScript/_values.py‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"""Shared ECMAScript value operations for the supported runtime subset."""
2+
3+
from __future__ import annotations
4+
5+
import inspect
6+
import math
7+
import re
8+
from collections.abc import Mapping
9+
from decimal import Decimal
10+
11+
12+
class _Undefined:
13+
__slots__ = ()
14+
_instance = None
15+
16+
def __new__(cls):
17+
if cls._instance is None:
18+
cls._instance = super().__new__(cls)
19+
return cls._instance
20+
21+
def __repr__(self) -> str:
22+
return "undefined"
23+
24+
def __bool__(self) -> bool:
25+
return False
26+
27+
def __reduce__(self):
28+
return (_Undefined, ())
29+
30+
31+
undefined = _Undefined()
32+
null = None
33+
NaN = math.nan
34+
Infinity = math.inf
35+
36+
# ECMAScript WhiteSpace and LineTerminator code points, including BOM.
37+
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"
38+
_DECIMAL = re.compile(r"[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|Infinity)\Z")
39+
40+
41+
def is_number(value) -> bool:
42+
return isinstance(value, (int, float)) and not isinstance(value, bool)
43+
44+
45+
def truthy(value=undefined) -> bool:
46+
"""JavaScript Boolean conversion; empty containers are true."""
47+
if value is undefined or value is None or value is False:
48+
return False
49+
if is_number(value):
50+
return value != 0 and not math.isnan(to_number(value))
51+
if isinstance(value, str):
52+
return len(value) != 0
53+
return True
54+
55+
56+
def primitive(value):
57+
if value is undefined or value is None or isinstance(value, (str, bool, int, float)):
58+
return value
59+
method = getattr(type(value), "_js_primitive", None)
60+
if method is not None:
61+
result = method(value)
62+
if result is undefined or result is None or isinstance(result, (str, bool, int, float)):
63+
return result
64+
raise TypeError("Cannot convert object to a primitive value")
65+
if isinstance(value, (list, tuple)):
66+
return ",".join("" if item is None or item is undefined else to_string(item) for item in value)
67+
if isinstance(value, Mapping):
68+
return "[object Object]"
69+
raise TypeError(f"Unsupported JavaScript value: {type(value).__name__}")
70+
71+
72+
def to_number(value=undefined) -> float:
73+
value = primitive(value)
74+
if value is undefined:
75+
return NaN
76+
if value is None:
77+
return 0.0
78+
if isinstance(value, (bool, int, float)):
79+
try:
80+
return float(value)
81+
except OverflowError:
82+
return -Infinity if value < 0 else Infinity
83+
text = value.strip(WHITESPACE)
84+
if not text:
85+
return 0.0
86+
if re.fullmatch(r"0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+", text):
87+
return to_number(int(text, 0))
88+
if not _DECIMAL.fullmatch(text):
89+
return NaN
90+
return float(text)
91+
92+
93+
def number_string(value) -> str:
94+
number = to_number(value)
95+
if math.isnan(number):
96+
return "NaN"
97+
if math.isinf(number):
98+
return "-Infinity" if number < 0 else "Infinity"
99+
if number == 0:
100+
return "0"
101+
text = repr(number)
102+
if 1e-6 <= abs(number) < 1e21:
103+
text = format(Decimal(text), "f")
104+
return text.rstrip("0").rstrip(".") if "." in text else text
105+
mantissa, exponent = text.split("e")
106+
exponent = int(exponent)
107+
return f"{mantissa.removesuffix('.0')}e{'+' if exponent >= 0 else '-'}{abs(exponent)}"
108+
109+
110+
def to_string(value=undefined) -> str:
111+
value = primitive(value)
112+
if value is undefined:
113+
return "undefined"
114+
if value is None:
115+
return "null"
116+
if value is True:
117+
return "true"
118+
if value is False:
119+
return "false"
120+
if isinstance(value, str):
121+
return str(value)
122+
return number_string(value)
123+
124+
125+
def integer(value=undefined):
126+
number = to_number(value)
127+
if math.isnan(number) or number == 0:
128+
return 0
129+
return number if math.isinf(number) else math.trunc(number)
130+
131+
132+
def relative_index(value, length: int) -> int:
133+
index = integer(value)
134+
if index < 0:
135+
return max(length + index, 0)
136+
return min(index, length)
137+
138+
139+
def strictEqual(left, right) -> bool:
140+
"""ECMAScript strict equality for supported values (objects use identity)."""
141+
if is_number(left) and is_number(right):
142+
return to_number(left) == to_number(right)
143+
if isinstance(left, str) and isinstance(right, str):
144+
return str(left) == str(right)
145+
return left is right
146+
147+
148+
def same_value(left, right) -> bool:
149+
if is_number(left) and is_number(right):
150+
a, b = to_number(left), to_number(right)
151+
if math.isnan(a) and math.isnan(b):
152+
return True
153+
if a == b == 0:
154+
return math.copysign(1, a) == math.copysign(1, b)
155+
return strictEqual(left, right)
156+
157+
158+
def same_value_zero(left, right) -> bool:
159+
return strictEqual(left, right) or (is_number(left) and is_number(right) and math.isnan(to_number(left)) and math.isnan(to_number(right)))
160+
161+
162+
def typeof(value=undefined) -> str:
163+
if value is undefined:
164+
return "undefined"
165+
if value is None:
166+
return "object"
167+
if isinstance(value, bool):
168+
return "boolean"
169+
if is_number(value):
170+
return "number"
171+
if isinstance(value, str):
172+
return "string"
173+
return "function" if callable(value) else "object"
174+
175+
176+
def isNaN(value=undefined) -> bool:
177+
return math.isnan(to_number(value))
178+
179+
180+
def isFinite(value=undefined) -> bool:
181+
return math.isfinite(to_number(value))
182+
183+
184+
def callback(function):
185+
"""Adapt declared positional arity once, without retrying callback errors."""
186+
if not callable(function):
187+
raise TypeError("Callback must be callable")
188+
try:
189+
parameters = inspect.signature(function).parameters.values()
190+
except (TypeError, ValueError):
191+
return function
192+
if any(p.kind == p.VAR_POSITIONAL for p in parameters):
193+
return function
194+
count = sum(p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) for p in parameters)
195+
return lambda *args: function(*args[:count])

‎src/JavaScript/py.typed‎

Whitespace-only changes.

‎tests/test_values.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import copy
2+
import math
3+
import pickle
4+
import unittest
5+
6+
from JavaScript import isFinite, isNaN, strictEqual, truthy, typeof, undefined
7+
from JavaScript._values import callback, same_value, same_value_zero, to_number, to_string
8+
9+
10+
class ValueTests(unittest.TestCase):
11+
def test_undefined_is_distinct_and_preserves_identity(self):
12+
self.assertIsNot(undefined, None)
13+
self.assertFalse(undefined)
14+
self.assertEqual(repr(undefined), "undefined")
15+
for value in (copy.copy(undefined), copy.deepcopy(undefined), pickle.loads(pickle.dumps(undefined))):
16+
self.assertIs(value, undefined)
17+
18+
def test_boolean_conversion_uses_javascript_rules(self):
19+
for value in (undefined, None, False, 0, -0.0, math.nan, ""):
20+
self.assertFalse(truthy(value), repr(value))
21+
for value in ([], {}, (), True, "0", "false", math.inf, object()):
22+
self.assertTrue(truthy(value), repr(value))
23+
24+
def test_number_conversion_and_numeric_grammar(self):
25+
for value, result in [(None, 0), (True, 1), ("", 0), (" \ufeff42\n", 42), ("0xff", 255), ("0b11", 3), ("0o10", 8), (".5e2", 50), ([], 0), ([2], 2)]:
26+
self.assertEqual(to_number(value), result)
27+
for value in (undefined, "NaN", "inf", "+0xff", "1_000", "\x851", "12", [1, 2], {}):
28+
self.assertTrue(math.isnan(to_number(value)), repr(value))
29+
self.assertEqual(math.copysign(1, to_number("-0")), -1)
30+
self.assertEqual(to_number(2**53 + 1), 2**53)
31+
self.assertEqual(to_number(10**400), math.inf)
32+
33+
def test_string_conversion(self):
34+
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,,")]:
35+
self.assertEqual(to_string(value), result)
36+
37+
def test_equality_relations(self):
38+
self.assertFalse(strictEqual(True, 1))
39+
self.assertFalse(strictEqual({}, {}))
40+
self.assertFalse(strictEqual([], []))
41+
self.assertFalse(strictEqual(math.nan, math.nan))
42+
self.assertTrue(strictEqual(0, -0.0))
43+
self.assertFalse(same_value(0, -0.0))
44+
self.assertTrue(same_value(math.nan, math.nan))
45+
self.assertTrue(same_value_zero(math.nan, math.nan))
46+
self.assertTrue(same_value_zero(0, -0.0))
47+
48+
def test_typeof_and_coercing_predicates(self):
49+
for value, name in [(undefined, "undefined"), (None, "object"), (True, "boolean"), (1, "number"), ("", "string"), ([], "object"), (lambda: None, "function")]:
50+
self.assertEqual(typeof(value), name)
51+
self.assertTrue(isNaN("bad"))
52+
self.assertFalse(isNaN(None))
53+
self.assertTrue(isFinite("12"))
54+
self.assertFalse(isFinite(math.inf))
55+
56+
def test_callback_arity_and_errors_are_not_retried(self):
57+
self.assertEqual(callback(lambda value: value * 2)(3, 0, []), 6)
58+
self.assertEqual(callback(lambda *args: args)(3, 0), (3, 0))
59+
calls = []
60+
def fail(value):
61+
calls.append(value)
62+
raise TypeError("from callback body")
63+
with self.assertRaisesRegex(TypeError, "from callback body"):
64+
callback(fail)(1, 2, 3)
65+
self.assertEqual(calls, [1])
66+
with self.assertRaises(TypeError):
67+
callback(None)

0 commit comments

Comments
 (0)