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
8 changes: 7 additions & 1 deletion src/JavaScript/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""JavaScript-style objects, methods and functions for Python."""

from ._array import Array
from ._json import JSON
from ._math import Math
from ._number import Boolean, Number, parseFloat, parseInt
from ._object import Object
from ._string import String
from fetch_api import FetchError, Headers, Promise, Request, Response, fetch
from ._values import (
Infinity,
NaN,
Expand All @@ -15,7 +20,8 @@
)

__all__ = [
"Array", "Object",
"Array", "Boolean", "FetchError", "Headers", "JSON", "Math", "Number",
"Object", "Promise", "Request", "Response", "String", "fetch", "parseFloat", "parseInt",
"Infinity", "NaN", "isFinite", "isNaN", "null", "strictEqual",
"truthy", "typeof", "undefined",
]
114 changes: 114 additions & 0 deletions src/JavaScript/_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Adapt the standalone JSON parser to runtime values and serialization rules."""

from __future__ import annotations

import json as _json
import math
from collections.abc import Mapping

import JSON as _standalone

from ._array import Array
from ._object import Object, own_entries
from ._string import String
from ._values import is_number, number_string, to_number, to_string, undefined


def _runtime(value):
if isinstance(value, dict):
return Object({key: _runtime(child) for key, child in dict.items(value)})
if isinstance(value, list):
return Array.of(*(_runtime(child) for child in value))
if isinstance(value, str):
return String(value)
return to_number(value) if is_number(value) else value


def _quote(value):
# Delegate escaping to the companion, preserving normal Unicode while
# escaping lone surrogates as required by well-formed JSON.stringify.
value = value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass")
quoted = _standalone.stringify(value, ensure_ascii=False)
return "".join(f"\\u{ord(char):04x}" if 0xD800 <= ord(char) <= 0xDFFF else char for char in quoted)


class _JSON:
JSONDecodeError = _standalone.JSONDecodeError

@staticmethod
def parse(text):
"""Parse JSON after JavaScript string conversion; no reviver overload."""
if isinstance(text, (bytes, bytearray)):
raise TypeError("JavaScript.JSON.parse expects text, not bytes")
def reject_constant(value):
raise ValueError(f"{value} is not a valid JSON numeric constant")

# The standalone parser preserves Python integers. Select binary64
# decoder hooks here so nested -0 and large integers retain JS semantics.
value = _json.loads(
to_string(text), parse_int=float, parse_float=float,
parse_constant=reject_constant,
)
return _runtime(value)

@staticmethod
def stringify(value=undefined, replacer=undefined, space=undefined):
"""Serialize supported values; replacers and toJSON hooks are not supported."""
if replacer is not undefined and replacer is not None:
raise NotImplementedError("JSON replacers are not supported")
if is_number(space):
number = to_number(space)
gap = " " * (0 if math.isnan(number) else int(max(0, min(number, 10))))
elif isinstance(space, str):
gap = String(space).slice(0, 10).valueOf()
else:
gap = ""
active = set()

def encode(child, depth=0):
if child is undefined or callable(child):
return undefined
if child is None:
return "null"
if isinstance(child, bool):
return "true" if child else "false"
if isinstance(child, str):
return _quote(child)
if is_number(child):
return number_string(child) if math.isfinite(to_number(child)) else "null"
if not isinstance(child, (Object, Array, Mapping, list, tuple)):
raise TypeError(f"Unsupported JSON value: {type(child).__name__}")
if isinstance(child, (Object, Mapping)):
if any(key == "toJSON" and callable(item) for key, item in own_entries(child)):
raise NotImplementedError("JSON toJSON hooks are not supported")
identity = id(child)
if identity in active:
raise TypeError("Converting circular structure to JSON")
active.add(identity)
try:
array = isinstance(child, (Array, list, tuple))
parts = []
if array:
for item in child:
text = encode(item, depth + 1)
parts.append("null" if text is undefined else text)
else:
for key, item in own_entries(child):
text = encode(item, depth + 1)
if text is not undefined:
parts.append(_quote(key) + (": " if gap else ":") + text)
left, right = ("[", "]") if array else ("{", "}")
if not parts:
return left + right
if not gap:
return left + ",".join(parts) + right
prefix = gap * (depth + 1)
return left + "\n" + prefix + (",\n" + prefix).join(parts) + "\n" + gap * depth + right
finally:
active.remove(identity)

result = encode(value)
return result if result is undefined else String(result)


JSON = _JSON()
106 changes: 106 additions & 0 deletions src/JavaScript/_math.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Selected Math functions with ECMAScript numeric edge behavior."""

from __future__ import annotations

import math
import random

from ._values import to_number, undefined


class Math:
PI = math.pi
E = math.e
LN2 = math.log(2)
LN10 = math.log(10)
LOG2E = math.log2(math.e)
LOG10E = math.log10(math.e)
SQRT1_2 = math.sqrt(0.5)
SQRT2 = math.sqrt(2)

@staticmethod
def abs(value=undefined):
return abs(to_number(value))

@staticmethod
def floor(value=undefined):
number = to_number(value)
return float(math.floor(number)) if math.isfinite(number) and number != 0 else number

@staticmethod
def ceil(value=undefined):
number = to_number(value)
if not math.isfinite(number) or number == 0:
return number
return -0.0 if -1 < number < 0 else float(math.ceil(number))

@staticmethod
def trunc(value=undefined):
number = to_number(value)
if not math.isfinite(number) or number == 0:
return number
return math.copysign(float(math.trunc(number)), number)

@staticmethod
def round(value=undefined):
number = to_number(value)
if not math.isfinite(number) or number == 0:
return number
if -0.5 <= number < 0:
return -0.0
lower = math.floor(number)
return float(lower if number - lower < 0.5 else lower + 1)

@staticmethod
def sign(value=undefined):
number = to_number(value)
return number if math.isnan(number) or number == 0 else math.copysign(1.0, number)

@staticmethod
def min(*values):
result = math.inf
for value in values:
number = to_number(value)
if math.isnan(number):
return math.nan
if number < result or (number == result == 0 and math.copysign(1, number) < 0):
result = number
return result

@staticmethod
def max(*values):
result = -math.inf
for value in values:
number = to_number(value)
if math.isnan(number):
return math.nan
if number > result or (number == result == 0 and math.copysign(1, number) > 0):
result = number
return result

@staticmethod
def sqrt(value=undefined):
number = to_number(value)
return math.nan if number < 0 else math.sqrt(number)

@staticmethod
def pow(base=undefined, exponent=undefined):
base, exponent = to_number(base), to_number(exponent)
if exponent == 0:
return 1.0
if math.isnan(exponent):
return math.nan
if abs(base) == 1 and math.isinf(exponent):
return math.nan
try:
return math.pow(base, exponent)
except OverflowError:
return -math.inf if base < 0 and exponent.is_integer() and exponent % 2 else math.inf
except ValueError:
if base == 0 and exponent < 0:
return -math.inf if math.copysign(1, base) < 0 and exponent.is_integer() and exponent % 2 else math.inf
return math.nan

@staticmethod
def random():
return random.random()
90 changes: 90 additions & 0 deletions src/JavaScript/_number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Number conversion, predicates and prefix parsing."""

from __future__ import annotations

import math
import re
import sys

from ._values import WHITESPACE, is_number, number_string, to_number, to_string, truthy, undefined


class Number(float):
"""Binary64 conversion with selected JavaScript Number static methods."""

EPSILON = 2**-52
MAX_SAFE_INTEGER = 2**53 - 1
MIN_SAFE_INTEGER = -(2**53 - 1)
MAX_VALUE = sys.float_info.max
MIN_VALUE = float.fromhex("0x0.0000000000001p-1022")
NaN = math.nan
POSITIVE_INFINITY = math.inf
NEGATIVE_INFINITY = -math.inf

def __new__(cls, value=0):
return super().__new__(cls, to_number(value))

@staticmethod
def isNaN(value=undefined):
return is_number(value) and math.isnan(to_number(value))

@staticmethod
def isFinite(value=undefined):
return is_number(value) and math.isfinite(to_number(value))

@staticmethod
def isInteger(value=undefined):
return Number.isFinite(value) and to_number(value).is_integer()

@staticmethod
def isSafeInteger(value=undefined):
return Number.isInteger(value) and abs(to_number(value)) <= Number.MAX_SAFE_INTEGER

def toString(self):
from ._string import String
return String(number_string(self))

def valueOf(self):
return float(self)


def Boolean(value=undefined) -> bool:
return truthy(value)


def parseFloat(value=undefined):
text = to_string(value).lstrip(WHITESPACE)
match = re.match(r"[+-]?(?:Infinity|(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)", text)
return float(match[0]) if match else math.nan


def parseInt(value=undefined, radix=undefined):
from ._string import uint32
text = to_string(value).lstrip(WHITESPACE)
sign = -1 if text.startswith("-") else 1
if text.startswith(("-", "+")):
text = text[1:]
base = uint32(radix)
if base >= 2**31:
base -= 2**32
if base != 0 and not 2 <= base <= 36:
return math.nan
if base in (0, 16) and text.startswith(("0x", "0X")):
text, base = text[2:], 16
if base == 0:
base = 10
digits = "0123456789abcdefghijklmnopqrstuvwxyz"[:base]
result = 0
count = 0
for char in text.lower():
if char not in digits:
break
result = result * base + digits.index(char)
count += 1
if not count:
return math.nan
return to_number(result) * sign


Number.parseFloat = staticmethod(parseFloat)
Number.parseInt = staticmethod(parseInt)
Loading
Loading