Skip to content

Commit de12d62

Browse files
committed
feat: add primitive methods and JSON adapters
1 parent a1b8bea commit de12d62

9 files changed

Lines changed: 700 additions & 2 deletions

File tree

‎src/JavaScript/__init__.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
"""JavaScript-style objects, methods and functions for Python."""
22

33
from ._array import Array
4+
from ._json import JSON
5+
from ._math import Math
6+
from ._number import Boolean, Number, parseFloat, parseInt
47
from ._object import Object
8+
from ._string import String
9+
from fetch_api import FetchError, Headers, Promise, Request, Response, fetch
510
from ._values import (
611
Infinity,
712
NaN,
@@ -15,7 +20,8 @@
1520
)
1621

1722
__all__ = [
18-
"Array", "Object",
23+
"Array", "Boolean", "FetchError", "Headers", "JSON", "Math", "Number",
24+
"Object", "Promise", "Request", "Response", "String", "fetch", "parseFloat", "parseInt",
1925
"Infinity", "NaN", "isFinite", "isNaN", "null", "strictEqual",
2026
"truthy", "typeof", "undefined",
2127
]

‎src/JavaScript/_json.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Adapt the standalone JSON parser to runtime values and serialization rules."""
2+
3+
from __future__ import annotations
4+
5+
import json as _json
6+
import math
7+
from collections.abc import Mapping
8+
9+
import JSON as _standalone
10+
11+
from ._array import Array
12+
from ._object import Object, own_entries
13+
from ._string import String
14+
from ._values import is_number, number_string, to_number, to_string, undefined
15+
16+
17+
def _runtime(value):
18+
if isinstance(value, dict):
19+
return Object({key: _runtime(child) for key, child in dict.items(value)})
20+
if isinstance(value, list):
21+
return Array.of(*(_runtime(child) for child in value))
22+
if isinstance(value, str):
23+
return String(value)
24+
return to_number(value) if is_number(value) else value
25+
26+
27+
def _quote(value):
28+
# Delegate escaping to the companion, preserving normal Unicode while
29+
# escaping lone surrogates as required by well-formed JSON.stringify.
30+
value = value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass")
31+
quoted = _standalone.stringify(value, ensure_ascii=False)
32+
return "".join(f"\\u{ord(char):04x}" if 0xD800 <= ord(char) <= 0xDFFF else char for char in quoted)
33+
34+
35+
class _JSON:
36+
JSONDecodeError = _standalone.JSONDecodeError
37+
38+
@staticmethod
39+
def parse(text):
40+
"""Parse JSON after JavaScript string conversion; no reviver overload."""
41+
if isinstance(text, (bytes, bytearray)):
42+
raise TypeError("JavaScript.JSON.parse expects text, not bytes")
43+
def reject_constant(value):
44+
raise ValueError(f"{value} is not a valid JSON numeric constant")
45+
46+
# The standalone parser preserves Python integers. Select binary64
47+
# decoder hooks here so nested -0 and large integers retain JS semantics.
48+
value = _json.loads(
49+
to_string(text), parse_int=float, parse_float=float,
50+
parse_constant=reject_constant,
51+
)
52+
return _runtime(value)
53+
54+
@staticmethod
55+
def stringify(value=undefined, replacer=undefined, space=undefined):
56+
"""Serialize supported values; replacers and toJSON hooks are not supported."""
57+
if replacer is not undefined and replacer is not None:
58+
raise NotImplementedError("JSON replacers are not supported")
59+
if is_number(space):
60+
number = to_number(space)
61+
gap = " " * (0 if math.isnan(number) else int(max(0, min(number, 10))))
62+
elif isinstance(space, str):
63+
gap = String(space).slice(0, 10).valueOf()
64+
else:
65+
gap = ""
66+
active = set()
67+
68+
def encode(child, depth=0):
69+
if child is undefined or callable(child):
70+
return undefined
71+
if child is None:
72+
return "null"
73+
if isinstance(child, bool):
74+
return "true" if child else "false"
75+
if isinstance(child, str):
76+
return _quote(child)
77+
if is_number(child):
78+
return number_string(child) if math.isfinite(to_number(child)) else "null"
79+
if not isinstance(child, (Object, Array, Mapping, list, tuple)):
80+
raise TypeError(f"Unsupported JSON value: {type(child).__name__}")
81+
if isinstance(child, (Object, Mapping)):
82+
if any(key == "toJSON" and callable(item) for key, item in own_entries(child)):
83+
raise NotImplementedError("JSON toJSON hooks are not supported")
84+
identity = id(child)
85+
if identity in active:
86+
raise TypeError("Converting circular structure to JSON")
87+
active.add(identity)
88+
try:
89+
array = isinstance(child, (Array, list, tuple))
90+
parts = []
91+
if array:
92+
for item in child:
93+
text = encode(item, depth + 1)
94+
parts.append("null" if text is undefined else text)
95+
else:
96+
for key, item in own_entries(child):
97+
text = encode(item, depth + 1)
98+
if text is not undefined:
99+
parts.append(_quote(key) + (": " if gap else ":") + text)
100+
left, right = ("[", "]") if array else ("{", "}")
101+
if not parts:
102+
return left + right
103+
if not gap:
104+
return left + ",".join(parts) + right
105+
prefix = gap * (depth + 1)
106+
return left + "\n" + prefix + (",\n" + prefix).join(parts) + "\n" + gap * depth + right
107+
finally:
108+
active.remove(identity)
109+
110+
result = encode(value)
111+
return result if result is undefined else String(result)
112+
113+
114+
JSON = _JSON()

‎src/JavaScript/_math.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Selected Math functions with ECMAScript numeric edge behavior."""
2+
3+
from __future__ import annotations
4+
5+
import math
6+
import random
7+
8+
from ._values import to_number, undefined
9+
10+
11+
class Math:
12+
PI = math.pi
13+
E = math.e
14+
LN2 = math.log(2)
15+
LN10 = math.log(10)
16+
LOG2E = math.log2(math.e)
17+
LOG10E = math.log10(math.e)
18+
SQRT1_2 = math.sqrt(0.5)
19+
SQRT2 = math.sqrt(2)
20+
21+
@staticmethod
22+
def abs(value=undefined):
23+
return abs(to_number(value))
24+
25+
@staticmethod
26+
def floor(value=undefined):
27+
number = to_number(value)
28+
return float(math.floor(number)) if math.isfinite(number) and number != 0 else number
29+
30+
@staticmethod
31+
def ceil(value=undefined):
32+
number = to_number(value)
33+
if not math.isfinite(number) or number == 0:
34+
return number
35+
return -0.0 if -1 < number < 0 else float(math.ceil(number))
36+
37+
@staticmethod
38+
def trunc(value=undefined):
39+
number = to_number(value)
40+
if not math.isfinite(number) or number == 0:
41+
return number
42+
return math.copysign(float(math.trunc(number)), number)
43+
44+
@staticmethod
45+
def round(value=undefined):
46+
number = to_number(value)
47+
if not math.isfinite(number) or number == 0:
48+
return number
49+
if -0.5 <= number < 0:
50+
return -0.0
51+
lower = math.floor(number)
52+
return float(lower if number - lower < 0.5 else lower + 1)
53+
54+
@staticmethod
55+
def sign(value=undefined):
56+
number = to_number(value)
57+
return number if math.isnan(number) or number == 0 else math.copysign(1.0, number)
58+
59+
@staticmethod
60+
def min(*values):
61+
result = math.inf
62+
for value in values:
63+
number = to_number(value)
64+
if math.isnan(number):
65+
return math.nan
66+
if number < result or (number == result == 0 and math.copysign(1, number) < 0):
67+
result = number
68+
return result
69+
70+
@staticmethod
71+
def max(*values):
72+
result = -math.inf
73+
for value in values:
74+
number = to_number(value)
75+
if math.isnan(number):
76+
return math.nan
77+
if number > result or (number == result == 0 and math.copysign(1, number) > 0):
78+
result = number
79+
return result
80+
81+
@staticmethod
82+
def sqrt(value=undefined):
83+
number = to_number(value)
84+
return math.nan if number < 0 else math.sqrt(number)
85+
86+
@staticmethod
87+
def pow(base=undefined, exponent=undefined):
88+
base, exponent = to_number(base), to_number(exponent)
89+
if exponent == 0:
90+
return 1.0
91+
if math.isnan(exponent):
92+
return math.nan
93+
if abs(base) == 1 and math.isinf(exponent):
94+
return math.nan
95+
try:
96+
return math.pow(base, exponent)
97+
except OverflowError:
98+
return -math.inf if base < 0 and exponent.is_integer() and exponent % 2 else math.inf
99+
except ValueError:
100+
if base == 0 and exponent < 0:
101+
return -math.inf if math.copysign(1, base) < 0 and exponent.is_integer() and exponent % 2 else math.inf
102+
return math.nan
103+
104+
@staticmethod
105+
def random():
106+
return random.random()

‎src/JavaScript/_number.py‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Number conversion, predicates and prefix parsing."""
2+
3+
from __future__ import annotations
4+
5+
import math
6+
import re
7+
import sys
8+
9+
from ._values import WHITESPACE, is_number, number_string, to_number, to_string, truthy, undefined
10+
11+
12+
class Number(float):
13+
"""Binary64 conversion with selected JavaScript Number static methods."""
14+
15+
EPSILON = 2**-52
16+
MAX_SAFE_INTEGER = 2**53 - 1
17+
MIN_SAFE_INTEGER = -(2**53 - 1)
18+
MAX_VALUE = sys.float_info.max
19+
MIN_VALUE = float.fromhex("0x0.0000000000001p-1022")
20+
NaN = math.nan
21+
POSITIVE_INFINITY = math.inf
22+
NEGATIVE_INFINITY = -math.inf
23+
24+
def __new__(cls, value=0):
25+
return super().__new__(cls, to_number(value))
26+
27+
@staticmethod
28+
def isNaN(value=undefined):
29+
return is_number(value) and math.isnan(to_number(value))
30+
31+
@staticmethod
32+
def isFinite(value=undefined):
33+
return is_number(value) and math.isfinite(to_number(value))
34+
35+
@staticmethod
36+
def isInteger(value=undefined):
37+
return Number.isFinite(value) and to_number(value).is_integer()
38+
39+
@staticmethod
40+
def isSafeInteger(value=undefined):
41+
return Number.isInteger(value) and abs(to_number(value)) <= Number.MAX_SAFE_INTEGER
42+
43+
def toString(self):
44+
from ._string import String
45+
return String(number_string(self))
46+
47+
def valueOf(self):
48+
return float(self)
49+
50+
51+
def Boolean(value=undefined) -> bool:
52+
return truthy(value)
53+
54+
55+
def parseFloat(value=undefined):
56+
text = to_string(value).lstrip(WHITESPACE)
57+
match = re.match(r"[+-]?(?:Infinity|(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)", text)
58+
return float(match[0]) if match else math.nan
59+
60+
61+
def parseInt(value=undefined, radix=undefined):
62+
from ._string import uint32
63+
text = to_string(value).lstrip(WHITESPACE)
64+
sign = -1 if text.startswith("-") else 1
65+
if text.startswith(("-", "+")):
66+
text = text[1:]
67+
base = uint32(radix)
68+
if base >= 2**31:
69+
base -= 2**32
70+
if base != 0 and not 2 <= base <= 36:
71+
return math.nan
72+
if base in (0, 16) and text.startswith(("0x", "0X")):
73+
text, base = text[2:], 16
74+
if base == 0:
75+
base = 10
76+
digits = "0123456789abcdefghijklmnopqrstuvwxyz"[:base]
77+
result = 0
78+
count = 0
79+
for char in text.lower():
80+
if char not in digits:
81+
break
82+
result = result * base + digits.index(char)
83+
count += 1
84+
if not count:
85+
return math.nan
86+
return to_number(result) * sign
87+
88+
89+
Number.parseFloat = staticmethod(parseFloat)
90+
Number.parseInt = staticmethod(parseInt)

0 commit comments

Comments
 (0)